using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
namespace LlamaApp.Common;
/// Severity threshold; messages at or above are emitted.
public enum LogLevel { Trace, Debug, Info, Warn, Error, None }
///
/// Process-global, dependency-free logger. Writes each line to a daily rolling
/// file (%LOCALAPPDATA%\LlamaApp\logs\LlamaApp-YYYYMMDD.log) and, when the
/// app was launched from a terminal or an IDE that captures stdout (Rider, VS
/// Code, dotnet run), mirrors it to that stdout so logs stream live into
/// the shell / debug panel. The stdout mirror is a no-op when there's no parent
/// console or redirected handle (double-click launch), so the tray experience
/// stays silent. Thread-safe; never throws into the caller.
///
public static class Log
{
public static LogLevel Level { get; set; } = LogLevel.Info;
private static int RetentionDays { get; set; } = 7;
private static readonly object Gate = new();
private static string? _logDir;
///
/// The folder holding the rolled log files
/// (%LOCALAPPDATA%\LlamaApp\logs), exposed so the UI can offer an
/// "Open Folder" affordance. Computed — no initialization side effects,
/// and valid even before the first write.
///
public static string LogDirectory => _logDir ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LlamaApp", "logs");
private static string? _currentPath;
private static string? _currentDate;
private static StreamWriter? _writer;
private static StreamWriter? _consoleWriter;
private static bool _colorEnabled;
private static bool _initialized;
private const int ATTACH_PARENT_PROCESS = -1;
private const int STD_OUTPUT_HANDLE = -11;
private const uint ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004;
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool AttachConsole(int processId);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool FreeConsole();
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr GetStdHandle(int nStdHandle);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetConsoleMode(IntPtr h, out uint mode);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool SetConsoleMode(IntPtr h, uint mode);
///
/// Initializes the logger: pins , creates the log dir,
/// sweeps old files, and opens the stdout mirror (when
/// is true). Idempotent; also runs lazily
/// on the first write so a forgotten never drops
/// early logs.
///
public static void Initialize(LogLevel? level = null, bool enableConsole = true)
{
lock (Gate)
{
if (level is { } l) Level = l;
_logDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LlamaApp", "logs");
Try(() => Directory.CreateDirectory(_logDir));
if (enableConsole && _consoleWriter is null)
TryOpenStdout();
SweepOldLogs();
_initialized = true;
}
}
// ---- Entry points (call-site context via [Caller*]) ----
public static void Trace(string message, [CallerMemberName] string? m = null, [CallerFilePath] string? f = null, [CallerLineNumber] int l = 0)
=> Write(LogLevel.Trace, message, null, m, f, l);
public static void Debug(string message, [CallerMemberName] string? m = null, [CallerFilePath] string? f = null, [CallerLineNumber] int l = 0)
=> Write(LogLevel.Debug, message, null, m, f, l);
public static void Info(string message, [CallerMemberName] string? m = null, [CallerFilePath] string? f = null, [CallerLineNumber] int l = 0)
=> Write(LogLevel.Info, message, null, m, f, l);
public static void Warn(string message, [CallerMemberName] string? m = null, [CallerFilePath] string? f = null, [CallerLineNumber] int l = 0)
=> Write(LogLevel.Warn, message, null, m, f, l);
public static void Warn(Exception ex, string? message = null, [CallerMemberName] string? m = null, [CallerFilePath] string? f = null, [CallerLineNumber] int l = 0)
=> Write(LogLevel.Warn, message, ex, m, f, l);
public static void Error(string message, [CallerMemberName] string? m = null, [CallerFilePath] string? f = null, [CallerLineNumber] int l = 0)
=> Write(LogLevel.Error, message, null, m, f, l);
public static void Error(Exception ex, string? message = null, [CallerMemberName] string? m = null, [CallerFilePath] string? f = null, [CallerLineNumber] int l = 0)
=> Write(LogLevel.Error, message, ex, m, f, l);
// ---- Core write path ----
private static void Write(LogLevel level, string? message, Exception? exception, string? member, string? file, int line)
{
if (level < Level || Level == LogLevel.None) return;
var text = FormatLine(level, message, exception, member, file, line);
lock (Gate)
{
Try(() => { EnsureWriter(); _writer?.Write(text); _writer?.Flush(); });
if (_consoleWriter is not null)
{
try { _consoleWriter.Write(_colorEnabled ? Colorize(level, text) : text); _consoleWriter.Flush(); }
catch { _consoleWriter = null; } // stdout went away — stop retrying
}
if (Debugger.IsAttached)
Debugger.Log(0, "LlamaApp", text);
}
}
private static string FormatLine(LogLevel level, string? message, Exception? exception, string? member, string? file, int line)
{
var sb = new StringBuilder(256);
sb.Append(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"))
.Append(" [").Append(LevelTag(level)).Append("] ")
.Append('[').Append(Environment.CurrentManagedThreadId.ToString().PadLeft(2, ' ')).Append("] ");
if (!string.IsNullOrEmpty(member))
{
sb.Append(member);
if (file is not null)
sb.Append(" (").Append(ShortFileName(file)).Append(':').Append(line).Append(')');
sb.Append(' ');
}
if (!string.IsNullOrEmpty(message))
sb.Append(message);
if (exception is not null)
{
if (sb.Length > 0 && sb[^1] != '\n') sb.AppendLine();
sb.Append(" Exception: ").Append(exception.GetType().FullName).Append(": ").AppendLine(exception.Message);
for (var ex = exception.InnerException; ex is not null; ex = ex.InnerException)
sb.Append(" --> ").Append(ex.GetType().Name).Append(": ").AppendLine(ex.Message);
if (exception.StackTrace is { } trace)
sb.Append(" StackTrace:\n").Append(trace);
}
if (sb.Length == 0 || sb[^1] != '\n') sb.AppendLine();
return sb.ToString();
}
/// Opens today's file, rotating at a calendar-day boundary. Caller holds .
private static void EnsureWriter()
{
if (!_initialized) Initialize();
var today = DateTime.Now.ToString("yyyyMMdd");
if (_writer is not null && _currentDate == today && _currentPath is not null)
return;
Try(() => _writer?.Dispose());
_writer = null;
_currentDate = today;
if (string.IsNullOrEmpty(_logDir)) return;
_currentPath = Path.Combine(_logDir, $"LlamaApp-{today}.log");
try
{
// Append + FileShare.ReadWrite so the file can be opened in a text
// editor / tail while the app keeps writing.
var fs = new FileStream(_currentPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite, 4096);
_writer = new StreamWriter(fs, new UTF8Encoding(false)) { AutoFlush = false };
}
catch { _writer = null; }
}
/// Deletes daily log files older than .
private static void SweepOldLogs()
{
if (string.IsNullOrEmpty(_logDir) || !Directory.Exists(_logDir)) return;
var cutoff = DateTime.Now.AddDays(-RetentionDays);
foreach (var f in Directory.EnumerateFiles(_logDir, "LlamaApp-*.log"))
Try(() => { if (File.GetLastWriteTime(f) < cutoff) File.Delete(f); });
}
///
/// Opens a stdout mirror for the log. Two strategies, tried in order:
///
/// - AttachConsole(ATTACH_PARENT_PROCESS) — when launched from a
/// terminal (cmd/PowerShell), attaches to the parent's console so we get a
/// real console handle that supports ANSI VT color ().
/// - GetStdHandle directly — when AttachConsole fails (no
/// parent console), tries the process's inherited/redirected stdout handle.
/// This is what Rider / VS Code / dotnet run provide: they redirect
/// stdout to a pipe at process creation, so GetStdHandle returns a
/// valid (pipe) handle without a console. No ANSI color (pipes don't support
/// GetConsoleMode), but the text streams into the IDE's debug panel.
///
/// No-op when neither path yields a valid handle (double-click / explorer).
/// Caller holds .
///
private static void TryOpenStdout()
{
if (!OperatingSystem.IsWindows()) return;
try
{
// 1. Terminal launch: attach to the parent's console for a real
// (colorable) console handle.
if (AttachConsole(ATTACH_PARENT_PROCESS))
{
OpenStdoutWriter(GetStdHandle(STD_OUTPUT_HANDLE), enableColor: true);
return;
}
// 2. IDE / redirected-stdout launch (Rider, VS Code, dotnet run):
// AttachConsole failed (no parent console), but the process may
// still have a valid inherited/redirected stdout handle.
OpenStdoutWriter(GetStdHandle(STD_OUTPUT_HANDLE), enableColor: false);
}
catch
{
_consoleWriter = null;
_colorEnabled = false;
}
}
///
/// Wraps in a stored in
/// . When is true
/// (terminal console handle), enables ANSI VT processing for .
/// A null/invalid handle is a no-op. Caller holds .
///
private static void OpenStdoutWriter(IntPtr handle, bool enableColor)
{
if (handle == IntPtr.Zero || handle == new IntPtr(-1))
{
if (enableColor) Try(() => FreeConsole()); // nothing to write to — release the attach
return;
}
if (enableColor && GetConsoleMode(handle, out var mode))
_colorEnabled = SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
// wrapsHandle=false: the console handle is owned/freed by our console
// management (FreeConsole), not by the FileStream.
_consoleWriter = new StreamWriter(
new FileStream(new SafeFileHandle(handle, ownsHandle: false), FileAccess.Write),
new UTF8Encoding(false)) { AutoFlush = false };
}
/// Wraps a line in ANSI color codes for the level (console only; file stays plain).
private static string Colorize(LogLevel level, string text) => level switch
{
LogLevel.Trace or LogLevel.Debug => "\x1b[90m" + text + "\x1b[0m", // gray
LogLevel.Info => "\x1b[37m" + text + "\x1b[0m", // white
LogLevel.Warn => "\x1b[33m" + text + "\x1b[0m", // yellow
LogLevel.Error => "\x1b[31m" + text + "\x1b[0m", // red
_ => text,
};
private static string LevelTag(LogLevel level) => level switch
{
LogLevel.Trace => "TRACE",
LogLevel.Debug => "DEBUG",
LogLevel.Info => "INFO ",
LogLevel.Warn => "WARN ",
LogLevel.Error => "ERROR",
_ => "?????",
};
private static string ShortFileName(string path)
{
var idx = path.LastIndexOfAny(['/', '\\']);
return idx >= 0 ? path[(idx + 1)..] : path;
}
/// Runs and swallows any exception (logging must never throw).
private static void Try(Action action) { try { action(); } catch { /* best-effort */ } }
}