cliHelper.logger.psm1

#!/usr/bin/env pwsh
using namespace System.IO
using namespace System.Text
using namespace System.Threading
using namespace System.Collections.Generic
using namespace System.Management.Automation
using namespace System.Collections.Concurrent

#Requires -Modules PsModuleBase

# Enums
enum LogEventType {
  Debug = 0       # Detailed diagnostic Info
  Info = 1        # General operational information
  Warning = 2     # Indicates a potential problem
  Error = 3       # A recoverable error occurred
  Fatal = 4       # Critical conditions, system may be unusable (same as Critical/Alert/Emergency)
}

# marker classes for log entry data
class ILoggerEntry {
  [string]$Message
  [LogEventType]$Severity
  [Exception]$Exception
  [datetime]$Timestamp = [datetime]::UtcNow
}

class ILogAppender {
  [void] Log([ILoggerEntry]$entry) {
    Write-Warning "Log method not implemented in $($this.GetType().Name)"
  }
}

# .EXAMPLE
# [LoggerEntry]::new()
class LoggerEntry : ILoggerEntry {
  static [LoggerEntry] Create([LogEventType]$severity, [string]$message, [System.Exception]$exception) {
    return [LoggerEntry]@{
      Severity  = $severity
      Message   = $message
      Exception = $exception
      Timestamp = [datetime]::UtcNow
    }
  }
}

class Logger : PsModuleBase, IDisposable {
  [LogEventType] $MinimumLevel = [LogEventType]::Info
  [ValidateNotNull()][IO.DirectoryInfo] $Logdirectory
  hidden [ValidateNotNull()][ILogAppender[]] $Appenders = @()
  hidden [Type] $_entryType = [LoggerEntry]
  hidden [object] $_disposeLock = [object]::new()
  Logger() {
    [void][Logger]::From(
      [IO.Path]::Combine([IO.Path]::GetTempPath(), [guid]::newguid().guid, 'Logs'),
      [ref]$this
    )
  }
  Logger([string]$Logdirectory) {
    [void][Logger]::From($Logdirectory, [ref]$this)
  }
  static hidden [Logger] From([string]$Logdirectory, [ref]$o) {
    if (![IO.Directory]::Exists($Logdirectory)) {
      try {
        PsModuleBase\New-Directory $Logdirectory
        $o.Value.Logdirectory = [IO.DirectoryInfo]::new($Logdirectory)
      } catch {
        Write-Error "Failed to create log directory '$($o.Value.Logdirectory)':`n$_"
        # Decide if this should be fatal or just prevent file logging later
      }
    }
    $o.Value.PsObject.Properties.Add([PsScriptProperty]::new('EntryType', { return $this._entryType }, {
          param($value)
          if ($value -is [Type] -and $value.BaseType.Name -eq 'ILoggerEntry') {
            $this._entryType = $value
          } else {
            throw [SetValueException]::new("EntryType must be a Type that implements ILoggerEntry")
          }
        }
      )
    )
    return $o.Value
  }
  [bool] IsEnabled([LogEventType]$level) {
    return (!$this.IsDisposed) -and ($level -ge $this.MinimumLevel)
  }
  [void] Log([LogEventType]$severity, [string]$message) {
    $this.Log($severity, $message, $null)
  }
  [void] Log([LogEventType]$severity, [string]$message, [Exception]$exception) {
    if ($this.IsEnabled($severity)) {
      $this.Log($this.CreateEntry($severity, $message, $exception))
    }
  }
  [void] Log([ILoggerEntry]$entry) {
    foreach ($appender in $this.Appenders) {
      try {
        $appender.Log($entry)
      } catch {
        # Consider logging this error to the console/debug stream, or a fallback logger
        Write-Error "Logger failed processing appender '$($appender.GetType().Name)': $_"
      }
    }
  }
  [ILoggerEntry] CreateEntry([LogEventType]$severity, [string]$message) {
    return $this.CreateEntry($severity, $message, $null)
  }
  [ILoggerEntry] CreateEntry([LogEventType]$severity, [string]$message, [Exception]$exception) {
    if ($null -ne ($this.EntryType | Get-Member -MemberType Method -Static -Name Create)) {
      return $this.EntryType::Create($severity, $message, $exception)
    }
    return $this.EntryType::New($severity, $message, $exception)
  }
  # --- Convenience Methods ---
  [void] Info([string]$message) { $this.Log([LogEventType]::Info, $message) }
  [void] Debug([string]$message) { $this.Log([LogEventType]::Debug, $message) }

  [void] Warning([string]$message) { $this.Log([LogEventType]::Warning, $message) }

  [void] Error([string]$message) { $this.Error($message, $null) }
  [void] Error([string]$message, [Exception]$exception) { $this.Log([LogEventType]::Error, $message, $exception) }

  [void] Fatal([string]$message) { $this.Fatal($message, $null) }
  [void] Fatal([string]$message, [Exception]$exception = $null) { $this.Log([LogEventType]::Fatal, $message, $exception) }

  [string] ToString() {
    return @{
      EntryType    = $this.EntryType
      MinimumLevel = $this.MinimumLevel
      Logdirectory = $this.Logdirectory
      Appenders    = $this.Appenders ? ([IO.FileInfo[]]($this.Appenders.FilePath)).Name :@()
    } | ConvertTo-Json
  }
  [void] ClearLogdirectory() {
    $this.Logdirectory.EnumerateFiles().ForEach({ Remove-Item $_.FullName -Force })
  }
  [void] Dispose() {
    if ($this.IsDisposed) { return }
    # Dispose appenders that implement IDisposable
    foreach ($appender in $this.Appenders) {
      if ($appender -is [IDisposable]) {
        try {
          $appender.Dispose()
        } catch {
          Write-Error "Error disposing appender '$($appender.GetType().Name)': $_"
        }
      }
    }
    # Clear the list to prevent further use and release references
    $this.Appenders.Clear()
    $this.PsObject.Properties.Add([psscriptproperty]::new('IsDisposed', { return $true }, { throw [SetValueException]::new("Its a read-only Property") }))
    [void][System.GC]::SuppressFinalize($this)
  }
}

# Appender that writes to the PowerShell console with colors
class ConsoleAppender : ILogAppender {
  static [hashtable]$ColorMap = @{
    Debug   = [ConsoleColor]::DarkGray
    Info    = [ConsoleColor]::Green
    Warning = [ConsoleColor]::Yellow
    Error   = [ConsoleColor]::Red
    Fatal   = [ConsoleColor]::Magenta
  }

  [void] Log([ILoggerEntry]$entry) {
    # Check if host supports colors - might be unnecessary in modern PS
    $color = [ConsoleAppender]::ColorMap[$entry.Severity.ToString()]
    $timestamp = $entry.Timestamp.ToString('HH:mm:ss') # Concise timestamp for console
    $message = "[$timestamp] [$($entry.Severity.ToString().ToUpper())] $($entry.Message)"

    # Write message
    Write-Host $message -ForegroundColor $color

    # Write exception details if present, use Write-Error for visibility
    if ($null -ne $entry.Exception) {
      # Format exception concisely for console
      $exceptionMessage = " Exception: $($entry.Exception.GetType().Name): $($entry.Exception.Message)"
      # Optionally include stack trace snippet if needed, but can be verbose
      # $stack = ($entry.Exception.StackTrace -split '\r?\n' | Select-Object -First 3) -join "`n "
      # $exceptionMessage += "`n Stack Trace (partial):`n $stack"
      Write-Error $exceptionMessage # Write-Error uses stderr and default error color
    }
  }
}

# Appender that writes log entries as JSON objects to a file
class JsonAppender : ILogAppender, IDisposable {
  [ValidateNotNullOrWhiteSpace()][string]$FilePath
  hidden [ValidateNotNull()][StreamWriter]$_writer
  hidden [ValidateNotNull()][object]$_lock = [object]::new()

  JsonAppender([string]$Path) {
    $this.FilePath = [Logger]::GetUnResolvedPath($Path)
    # Ensure directory exists
    $dir = Split-Path $this.FilePath -Parent
    if (!(Test-Path $dir)) {
      try {
        New-Item -Path $dir -ItemType Directory -Force -ErrorAction Stop | Out-Null
      } catch {
        throw "Failed to create directory for JSON appender '$dir': $_"
      }
    }
    try {
      # Open file for appending with UTF8 encoding
      $this._writer = [StreamWriter]::new($this.FilePath, $true, [Encoding]::UTF8)
      $this._writer.AutoFlush = $true # Flush after every write
    } catch {
      throw "Failed to open file for JSON appender '$($this.FilePath)': $_"
    }
  }

  [void] Log([ILoggerEntry]$entry) {
    if ($this.IsDisposed) { return }

    # Create the object to serialize
    $logObject = [ordered]@{
      timestamp = $entry.Timestamp.ToString('o') # ISO 8601 format
      severity  = $entry.Severity.ToString()
      message   = $entry.Message
      # Include full exception string if present
      exception = if ($null -ne $entry.Exception) { $entry.Exception.ToString() } else { $null }
    }

    # Convert to JSON
    $jsonLine = $logObject | ConvertTo-Json -Compress -Depth 5 # Depth important for exceptions

    if ($this.IsDisposed -or $null -eq $this._writer) { return }
    try {
      $this._writer.WriteLine($jsonLine)
      # AutoFlush is true, manual flush shouldn't be needed unless guaranteeing write before potential crash
    } catch {
      throw [System.Exception]::new("JsonAppender failed to write to '$($this.FilePath)'", $_.Exception)
    }
  }
  [void] Dispose() {
    if ($this.IsDisposed) { return }
    if ($null -ne $this._writer) {
      try {
        $this._writer.Flush() # Final flush
        $this._writer.Dispose()
      } catch {
        Write-Error "JsonAppender error during dispose for file '$($this.FilePath)': $_"
      }
    }
    $this.PsObject.Properties.Add([psscriptproperty]::new('IsDisposed', { return $true }, { throw [SetValueException]::new("Its a read-only Property") }))
  }
}

# Appender that writes formatted text logs to a file
class FileAppender : ILogAppender, IDisposable {
  [string]$FilePath
  hidden [StreamWriter]$_writer
  hidden [ReaderWriterLockSlim]$_lock = [ReaderWriterLockSlim]::new()

  FileAppender([string]$Path) {
    $this.FilePath = [Logger]::GetUnResolvedPath($Path)
    if (![IO.File]::Exists($this.FilePath)) { throw [FileNotFoundException]::new("File '$Path'. Logging to this file may not work.") }
    # Ensure directory exists
    $dir = Split-Path $this.FilePath -Parent
    if (!(Test-Path $dir)) {
      try {
        New-Item -Path $dir -ItemType Directory -Force -ErrorAction Stop | Out-Null
      } catch {
        throw "Failed to create directory for File appender '$dir': $_"
      }
    }
    try {
      # Open file for appending with UTF8 encoding
      $this._writer = [StreamWriter]::new($this.FilePath, $true, [Encoding]::UTF8)
      $this._writer.AutoFlush = $true # Flush after every write
    } catch {
      throw "Failed to open file for File appender '$($this.FilePath)': $_"
    }
  }

  [void] Log([ILoggerEntry]$entry) {
    if ($this.IsDisposed) { return }

    # Format the log line
    $logLine = "[{0:u}] [{1,-11}] {2}" -f $entry.Timestamp, $entry.Severity.ToString().ToUpper(), $entry.Message
    # Add exception info if present
    if ($null -ne $entry.Exception) {
      # Append exception on new lines, indented for readability
      $exceptionText = ($entry.Exception.ToString() -split '\r?\n' | ForEach-Object { " $_" }) -join "`n"
      $logLine += "`n$($exceptionText)"
    }
    # Acquire write lock
    $this._lock.EnterWriteLock()
    try {
      # Re-check disposal after acquiring lock
      if ($this.IsDisposed -or $null -eq $this._writer) { return }
      $this._writer.WriteLine($logLine)
      # AutoFlush is true
    } catch {
      Write-Error "FileAppender failed to write to '$($this.FilePath)': $_"
    } finally {
      $this._lock.ExitWriteLock()
    }
  }

  [void] Dispose() {
    # Prevent new logs trying to acquire lock while disposing
    $this._lock.EnterWriteLock() # Acquire lock to ensure no writes are happening
    try {
      if ($null -ne $this._writer) {
        try {
          $this._writer.Flush()
          $this._writer.Dispose()
        } catch {
          Write-Error "FileAppender error during dispose for file '$($this.FilePath)': $_"
        }
      }
    } finally {
      $this._lock.ExitWriteLock()
    }
    $this.PsObject.Properties.Add([psscriptproperty]::new('IsDisposed', { return $true }, { throw [SetValueException]::new("Its a read-only Property") }))
    $this._lock.Dispose()
  }
}

# A logger that does nothing. Useful as a default or for disabling logging.
class NullLogger : Logger {
  [Type]$EntryType = [LoggerEntry]
  [LogEventType]$MinimumLevel = [LogEventType]::Fatal + 1 # Set above highest level to disable all
  hidden static [NullLogger]$Instance = [NullLogger]::new()
  NullLogger() {}
  [void] Log([LogEventType]$severity, [string]$message, [Exception]$exception = $null) { } # No-op
  [void] Debug([string]$message) { }
  [void] Info([string]$message) { }
  [void] Warning([string]$message) { }
  [void] Error([string]$message, [Exception]$exception) { }
  [void] Fatal([string]$message, [Exception]$exception) { }
  [bool] IsEnabled([LogEventType]$level) { return $false }
}

$typestoExport = @(
  [Logger], [ILoggerEntry], [ILogAppender], [LogEventType], [ConsoleAppender],
  [JsonAppender], [FileAppender], [NullLogger], [LoggerEntry]
)
# Register Type Accelerators
$TypeAcceleratorsClass = [PsObject].Assembly.GetType('System.Management.Automation.TypeAccelerators')
foreach ($Type in $typestoExport) {
  if ($Type.FullName -in $TypeAcceleratorsClass::Get.Keys) {
    $Message = @(
      "Unable to register type accelerator '$($Type.FullName)'"
      'Accelerator already exists.'
    ) -join ' - '
    "TypeAcceleratorAlreadyExists $Message" | Write-Debug
  }
}
# Add type accelerators for every exportable type.
foreach ($Type in $typestoExport) {
  $TypeAcceleratorsClass::Add($Type.FullName, $Type)
}
# Remove type accelerators when the module is removed.
$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {
  foreach ($Type in $typestoExport) {
    $TypeAcceleratorsClass::Remove($Type.FullName)
  }
}.GetNewClosure();

$scripts = @();
$Public = Get-ChildItem "$PSScriptRoot/Public" -Filter "*.ps1" -Recurse -ErrorAction SilentlyContinue
$scripts += Get-ChildItem "$PSScriptRoot/Private" -Filter "*.ps1" -Recurse -ErrorAction SilentlyContinue
$scripts += $Public

foreach ($file in $scripts) {
  Try {
    if ([string]::IsNullOrWhiteSpace($file.fullname)) { continue }
    . "$($file.fullname)"
  } Catch {
    Write-Warning "Failed to import function $($file.BaseName): $_"
    $host.UI.WriteErrorLine($_)
  }
}

$Param = @{
  Function = $Public.BaseName
  Cmdlet   = '*'
  Alias    = '*'
  Verbose  = $false
}
Export-ModuleMember @Param