Public/Logging/New-LogANSI.ps1

function New-LogANSI {
    <#
    .SYNOPSIS
    Logs messages with ANSI color support and optional caller information.
 
    .DESCRIPTION
    The New-LogANSI function logs messages to the console or a specified log file with ANSI color support. It supports different log levels, including ERROR, WARNING, INFO, SUCCESS, and DEBUG. The function can include caller information in the log message and handle errors gracefully.
 
    .PARAMETER Message
    The message to be logged. Can be a string, hashtable, or PSCustomObject.
 
    .PARAMETER Level
    Specifies the log level. Valid values are "ERROR", "WARNING", "INFO", "SUCCESS", and "DEBUG". Defaults to "INFO".
 
    .PARAMETER IncludeCallerInfo
    Includes the caller's function name in the log message if specified.
 
    .PARAMETER NoConsole
    Prevents the message from being logged to the console if specified.
 
    .PARAMETER PassThru
    Returns the log message as a string or object instead of logging it to the console.
 
    .PARAMETER AsObject
    Returns the log message as a PSCustomObject.
 
    .PARAMETER ForcedLogFile
    Forces overwriting of the log file if specified.
 
    .PARAMETER LogFilePath
    Specifies the path to the log file where the message should be logged.
 
    .OUTPUTS
    String or PSCustomObject depending on the parameters used.
 
    .EXAMPLE
    # **Example 1**
    # This example demonstrates how to log an informational message to the console.
    New-LogANSI -Message "The process completed successfully." -Level "INFO"
 
    .EXAMPLE
    # **Example 2**
    # This example demonstrates how to log a warning message with caller information included.
    New-LogANSI -Message "This is a warning message." -Level "WARNING" -IncludeCallerInfo
 
    .EXAMPLE
    # **Example 3**
    # This example demonstrates how to log an error message to a specified log file.
    New-LogANSI -Message "A critical error occurred." -Level "ERROR" -LogFilePath "C:\Logs\error.log"
 
    .EXAMPLE
    # **Example 4**
    # This example demonstrates how to return the log message as a PSCustomObject.
    $logObject = New-LogANSI -Message "Debugging information." -Level "DEBUG" -AsObject -PassThru
    Write-Output $logObject
 
    .NOTES
    Author: Futuremotion
    Website: https://github.com/futuremotiondev
    Date: 11-14-2024
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0, ValueFromPipeline = $true)]
        $Message,
        [Parameter(Position = 1)]
        [ValidateSet("ERROR", "WARNING", "INFO", "SUCCESS", "DEBUG")]
        [string]$Level = "INFO",
        [Parameter(Position = 2)]
        [switch]$IncludeCallerInfo = $false,
        [Parameter(Position = 3)]
        [switch]$NoConsole,
        [Parameter(Position = 4)]
        [switch]$PassThru,
        [Parameter(Position = 5)]
        [switch]$AsObject,
        [Parameter(Position = 6)]
        [switch]$ForcedLogFile,
        [Parameter(Position = 7)]
        [string]$LogFilePath
    )
    begin {
        function Write-MessageToConsole {
            if ($LogSentToConsole -eq $true) {
                return
            }
            if (!($NoConsole.IsPresent)) {
                if ($isPSCore) {
                    Write-Host $logMessage
                }
                else {
                    $logMessage | ForEach-Object { Write-Host $_ -ForegroundColor $levelColors[$Level].PS }
                }
            }
            return $true
        }
        function Set-UTF8Encoding {
            [CmdletBinding()]
            param()
            function Test-IsUTF8 {
                [CmdletBinding()]
                param()
                $isUTF8 = $false
                $encodingChecks = @(
                    {
                        $encoding = if ([Console]::OutputEncoding) {
                            [Console]::OutputEncoding
                        }
                        else {
                            [System.Console]::OutputEncoding
                        }
                        $isUTF8 = $encoding -is [System.Text.UTF8Encoding] -or $encoding.WebName -eq 'utf-8' -or $encoding.CodePage -eq 65001
                        $isUTF8
                    },
                    {
                        $encoding = $OutputEncoding
                        $isUTF8 = $encoding -is [System.Text.UTF8Encoding] -or $encoding.WebName -eq 'utf-8' -or $encoding.CodePage -eq 65001
                        $isUTF8
                    },
                    {
                        $codePage = chcp.com
                        $isUTF8 = $codePage -match '65001' -or '65001' -eq (Get-ItemPropertyValue HKLM:\SYSTEM\CurrentControlSet\Control\Nls\CodePage OEMCP)
                        $isUTF8
                    }
                )
                foreach ($check in $encodingChecks) {
                    try {
                        if (& $check) {
                            return $true
                        }
                    }
                    catch {
                        continue
                    }
                }
                return $false
            }
            if ($null -ne $PSDefaultParameterValues) {
                $encodingKeys = $PSDefaultParameterValues.Keys | Where-Object { $_ -like '*Encoding' }
                if ($encodingKeys.Count -eq 0) {
                    $PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'
                    $PSDefaultParameterValues['Get-Content:Encoding'] = 'utf8'
                    $PSDefaultParameterValues['Set-Content:Encoding'] = 'utf8'
                    Write-Verbose 'Set Out-File:Encoding, Get-Content:Encoding, Set-Content:Encoding to "utf8"'
                }
                elseif ($encodingKeys.Count -ge 1) {
                    foreach ($key in $encodingKeys) {
                        $PSDefaultParameterValues[$key] = 'utf8'
                        Write-Verbose "Confirmed: ${key} = 'utf8' is [True]"
                    }
                }
            }
            else {
                $PSDefaultParameterValues = @{}
                $PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'
                $PSDefaultParameterValues['Get-Content:Encoding'] = 'utf8'
                $PSDefaultParameterValues['Set-Content:Encoding'] = 'utf8'
                Write-Verbose '$PSDefaultParameterValues was missing, created it and set Out-File:Encoding, Get-Content:Encoding, Set-Content:Encoding to "utf8"'
            }
            if (Test-IsUTF8 -Verbose:$VerboseParam.IsPresent) {
                Write-Verbose "UTF-8 encoding already set"
                return $true
            }
            $methods = @(
                {
                    [console]::InputEncoding = [console]::OutputEncoding = [System.Text.Encoding]::UTF8
                    $OutputEncoding = [System.Text.Encoding]::UTF8
                },
                {
                    [System.Console]::InputEncoding = [System.Console]::OutputEncoding = New-Object System.Text.UTF8Encoding
                    $OutputEncoding = New-Object System.Text.UTF8Encoding
                },
                {
                    [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
                    $OutputEncoding = [System.Text.Encoding]::UTF8
                },
                {
                    [System.Console]::OutputEncoding = New-Object System.Text.UTF8Encoding
                    $OutputEncoding = New-Object System.Text.UTF8Encoding
                },
                {
                    chcp 65001 | Out-Null
                }
            )
            foreach ($method in $methods) {
                try {
                    & $method
                    $methodCode = $($method.ToString().Trim('{}').Split([Environment]::NewLine).Where{ $_.Trim() }.Trim() -join ' ; ')
                    $encodingsCorrect = (
                        [console]::OutputEncoding.CodePage -eq 65001 -and $OutputEncoding.CodePage -eq 65001
                    )
                    if ($methodCode -match 'chcp 65001 | Out-Null') {
                        Write-Verbose "Successfully set UTF-8 encoding using method: $methodCode"
                        $encodingsCorrect = $true
                        break
                    }
                    if ($encodingsCorrect) {
                        Write-Verbose "Successfully set UTF-8 encoding using method: $methodCode"
                        break
                    }
                    else {
                        Write-Verbose "Method: $methodCode, completed but verification failed"
                    }
                }
                catch {
                    continue
                }
            }
            if ($encodingsCorrect) {
                return $true
            }
            else {
                return $false
            }
        }
        $isPSCore = $PSVersionTable.PSVersion.Major -ge 6
        $levelColors = @{
            "ERROR"   = @{ANSI = "31"; PS = "Red" }
            "WARNING" = @{ANSI = "33"; PS = "Yellow" }
            "SUCCESS" = @{ANSI = "32"; PS = "Green" }
            "DEBUG"   = @{ANSI = "34"; PS = "Blue" }
            "INFO"    = @{ANSI = "37"; PS = "White" }
        }
        $reset = if ($isPSCore) {
            "`e[0m"
        }
        else {
            ""
        }
        $blue = if ($isPSCore) {
            "`e[34m"
        }
        else {
            ""
        }
        if (!(Set-UTF8Encoding)) {
            Write-Host "Failed to set UTF-8 encoding using any available method."
        }
    }
    Process {
        if ($null -eq $Message -and $Level -ne "ERROR") {
            return
        }
        try {
            @('exceptionMessage', 'failedCode', 'scriptLines', 'lineInfo') | ForEach-Object { Set-Variable -Name $_ -Value $null }
            if ($Message -and $Message.GetType().Name -eq 'Hashtable') {
                $Message = New-Object -TypeName PSObject -Property $Message
            }
            if ($Message -and $Message.GetType().Name -notin @("PSCustomObject", "Hashtable", "String", "Software")) {
                Write-Host "Unsupported message type: $($Message.GetType().Name). Must be PSCustomObject, Hashtable or string" -ForegroundColor Red
                return
            }
            $logSentToConsole = $false
            $logMessage = ''
            $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"
            $callerInfo = (Get-PSCallStack)[1]
            $originalMessage = $Message
            $levelColor = if ($isPSCore) {
                $levelColors[$Level].ANSI
            }
            else {
                $levelColors[$Level].PS
            }
            $headerPrefix = if ($isPSCore) {
                "$reset[$blue$timestamp$reset][`e[$($levelColor)m$Level$reset]"
            }
            else {
                "[$timestamp][$Level]"
            }
            if ($Message -isnot [string]) {
                $Message = ($Message | Format-List | Out-String).Trim()
            }
            if ($callerInfo.FunctionName -ne '<ScriptBlock>' -and ($IncludeCallerInfo.IsPresent -or $Level -eq "ERROR")) {
                $functionInfo, $messageLines = if ($isPSCore) {
                    if (!($Message)) {
                        "[${blue}Function${reset}: $($callerInfo.FunctionName)]"; "$headerPrefix${_}"
                    }
                    else {
                        " [${blue}Function${reset}: $($callerInfo.FunctionName)]"; $Message -split "`n" | ForEach-Object { "$headerPrefix $_" }
                    }
                }
                else {
                    if (!($Message)) {
                        "[Function: $($callerInfo.FunctionName)]"; "$headerPrefix${_}"
                    }
                    else {
                        " [Function: $($callerInfo.FunctionName)]"; $Message -split "`n" | ForEach-Object { "$headerPrefix $_" }
                    }
                }
                $logMessage += ($messageLines -join "`n") + $functionInfo
            }
            else {
                $messageLines = if (!($Message)) {
                    "$headerPrefix${_}"
                }
                else {
                    $Message -split "`n" | ForEach-Object { "$headerPrefix $_" }
                }
                $logMessage += $messageLines -join "`n"
            }
            if ($Level -eq "ERROR" -and $Error[0]) {
                $errorRecord = $Error[0]
                $invocationInfo = $errorRecord.InvocationInfo
                try {
                    if ($ErrorRecord.InvocationInfo.PSCommandPath -and (Test-Path -Path $errorRecord.InvocationInfo.PSCommandPath)) {
                        $scriptLines = Get-Content -Path "$($errorRecord.InvocationInfo.PSCommandPath)" -ErrorAction Stop
                    }
                    elseif ($ErrorRecord.InvocationInfo.ScriptName -and (Test-Path -Path $errorRecord.InvocationInfo.ScriptName)) {
                        $scriptLines = Get-Content -Path "$($errorRecord.InvocationInfo.ScriptName)" -ErrorAction Stop
                    }
                }
                catch {
                    if ($isPSCore) {
                        Write-Host "$reset[$blue$timestamp$reset][$($reset)e[31mERROR$reset] An error occurred in New-Log function. $($reset)e[31m$($_.Exception.Message)$reset"
                    }
                    else {
                        Write-Host "[$timestamp][ERROR] An error occurred in New-Log function. $($_.Exception.Message)" -ForegroundColor Red
                    }
                }
                $functionName = $callerInfo.Command
                $failedCode = if ($invocationInfo.Line) {
                    $invocationInfo.Line.Trim()
                }
                else {
                    $null
                }
                [int]$errorLine = $errorRecord.InvocationInfo.ScriptLineNumber
                if ([string]::IsNullOrEmpty($errorLine)) {
                    [int]$errorLine = $invocationInfo.ScriptLineNumber
                }
                if (!([string]::IsNullOrEmpty($scriptLines))) {
                    [int]$functionStartLine = ($scriptLines | Select-String -Pattern "function\s+$functionName" | Select-Object -First 1).LineNumber
                    $lineNumberInFunction = $errorLine - $functionStartLine
                    $lineInfo = "($lineNumberInFunction,$errorLine) (Function,Script)"
                    if ($callerInfo.FunctionName -eq '<ScriptBlock>') {
                        $lineInfo = "$errorLine (Script)"
                    }
                }
                else {
                    $lineNumberInFunction = $errorLine - ([int]$callerInfo.ScriptLineNumber - [int]$invocationInfo.OffsetInLine) - 1
                    $lineInfo = "($lineNumberInFunction,$errorLine) (Function,Script)"
                    if ($callerInfo.FunctionName -eq '<ScriptBlock>') {
                        $lineInfo = "$errorLine (Script)"
                    }
                }
                $exceptionMessage = $($errorRecord.Exception.Message)
                if ($isPSCore) {
                    $logMessage += "[${blue}CodeRow${reset}: $lineInfo]"
                    $logMessage += "[${blue}FailedCode${reset}: $failedCode]"
                    $logMessage += "[${blue}ExceptionMessage${reset}: ${reset}`e[$($levelColors[$Level].ANSI)m$exceptionMessage$reset]"
                }
                else {
                    $logMessage += "[CodeRow: $lineInfo]"
                    $logMessage += "[FailedCode: $failedCode]"
                    $logMessage += "[ExceptionMessage: $exceptionMessage]"
                }
            }
            if (!($NoConsole.IsPresent) -and !($PassThru.IsPresent) -and !($AsObject.IsPresent) -and !($LogFilePath)) {
                $LogSentToConsole = Write-MessageToConsole
            }
            if ($LogFilePath) {
                $LogSentToConsole = Write-MessageToConsole
                $logMessage = [regex]::Replace($logMessage, $([regex]::Escape("`e") + '\[[0-9;]*[mGKHF]'), '')
                if (!(Test-Path -Path (Split-Path -Path $LogFilePath -Parent))) {
                    New-Item -Path (Split-Path -Path $LogFilePath -Parent) -ItemType Directory -Force -ErrorAction Stop | Out-Null
                }
                if ($ForcedLogFile.IsPresent) {
                    Remove-Item -Path $LogFilePath -Force -ErrorAction SilentlyContinue | Out-Null
                    Set-Content -Value $logMessage -Path $LogFilePath -Force -Encoding utf8
                }
                else {
                    $logMessage | Out-File -FilePath $LogFilePath -Append -Encoding utf8
                }
            }
            $object = [PSCustomObject]@{
                Timestamp      = $timestamp
                Level          = $Level
                Message        = if (!([string]::IsNullOrEmpty($originalMessage)) -and $originalMessage.GetType().Name -eq 'String' ) {
                    $message
                }
                else {
                    [pscustomobject](($Message | Format-List | Out-String).Trim()) -split "`n"
                }
                Exception      = if ($exceptionMessage -and !([string]::IsNullOrEmpty($exceptionMessage)) ) {
                    $exceptionMessage
                }
                else {
                    $null
                }
                CallerFunction = if (!([string]::IsNullOrEmpty($callerInfo)) -and $callerInfo.FunctionName -eq '<ScriptBlock>') {
                    $null
                }
                else {
                    $callerInfo.FunctionName
                }
                CodeRow        = if ($lineInfo -and !([string]::IsNullOrEmpty($lineInfo)) ) {
                    $lineInfo
                }
                else {
                    $null
                }
                FailedCode     = if ($FailedCode -and !([string]::IsNullOrEmpty($FailedCode)) ) {
                    $FailedCode
                }
                else {
                    $null
                }
            }
            if ($PassThru.IsPresent -and $AsObject.IsPresent) {
                $LogSentToConsole = Write-MessageToConsole
                return $object
            }
            elseif ($PassThru.IsPresent -and !($AsObject.IsPresent)) {
                $LogSentToConsole = Write-MessageToConsole
                return $logMessage
            }
            elseif (!($NoConsole.IsPresent) -and $AsObject.IsPresent) {
                $object | Out-Host
            }
        }
        catch {
            if ($isPSCore) {
                Write-Host "$reset[$blue$timestamp$reset][`e[31mERROR$reset] An error occurred in New-Log function. `e[31m$($_.Exception.Message)$reset"
            }
            else {
                Write-Host "[$timestamp][ERROR] An error occurred in New-Log function. $($_.Exception.Message)" -ForegroundColor Red
            }
        }
    }
}