ShoutOut.psm1


# START: source\.bootstrap.ps1
function _buildBasicFileLogger {
    param(
        [string]$FilePath
    )

    return {
        param($Record)

        if (-not (Test-Path $FilePath -PathType Leaf)) {
            New-Item -Path $FilePath -ItemType File -Force -ErrorAction Stop | Out-Null
        }

        $Record | Out-File $FilePath -Encoding utf8 -Append -Force
    }.GetNewClosure()
}


$script:_ShoutOutSettings = @{
    DefaultMsgType="Info"
    LogFileRedirection=@{}
    MsgStyles=@{
        Success =       @{ ForegroundColor="Green" }
        Exception =     @{ ForegroundColor="Red"; BackgroundColor="Black" }
        Error =         @{ ForegroundColor="Red" }
        Warning =       @{ ForegroundColor="Yellow"; BackgroundColor="Black" }
        Info =          @{ ForegroundColor="Cyan" }
        Result =        @{ ForegroundColor="White" }
    }
    LogContext=$true
    Disabled=$false
}

$defaultLogFilename = "{0}.{1}.{2:yyyyMMddHHmmss}.log" -f $env:COMPUTERNAME, $pid, [datetime]::Now

$defaultLogFile = switch ((whoami).split('\')[0]) {
    System  {
        "{0}\Logs\shoutOut\{1}" -f $env:windir, $defaultLogFilename
    }   
    default {
        "{0}\shoutOut\Logs\{1}" -f $env:APPDATA, $defaultLogFilename
    }
}

$script:_ShoutOutSettings.DefaultLog = _buildBasicFileLogger $defaultLogFile
# END: source\.bootstrap.ps1


# START: source\_ensureShoutOutLogFile.ps1
function _ensureShoutOutLogFile {
    param(
        [string]$logFile,
        [string]$MsgType = "*"
    )

    if (!(Test-Path $logFile -PathType Leaf)) {
        try {
            return new-Item $logFile -ItemType File -Force -ErrorAction Stop
        } catch {
            "Unable to create log file '{0}' for '{1}'." -f $logFile, $msgType | shoutOut -MsgType Error
            "Messages marked with '{0}' will be redirected." -f $msgType | shoutOut -MsgType Error
            shoutOut $_ Error
            throw ("Unable to use log file '{0}', the file cannot be created." -f $logFile)
        }
    }

    return gi $logFile
}
# END: source\_ensureShoutOutLogFile.ps1


# START: source\_validateShoutOutLogHandler.ps1
function _validateShoutOutLogHandler {
    param(
        [scriptblock]$LogHandler,
        [string]$msgType = "*"
    )

    # Valid/recognizable parameters and their expected typing:
    $validParams = @{
        '$Message' = [Object]
        '$Record'  = [String]
        '$details' = [hashtable]
    }

    $params = $LogHandler.Ast.ParamBlock.Parameters

    if ($params.count -eq 0) {
        "Invalid handler, no parameters found: {0}" -f $LogHandler | shoutOut -MsgType Error
        "Messages marked with '{0}' will not be redirected using this handler." -f $msgType | shoutOut -MsgType Error
        throw "No parameters declared by the given handler."
    }

    $recognizedParams = $params | Where-Object { $_.Name.Extent.Text -in $validParams.Keys }

    if ($null -eq $recognizedParams) {
        "Invalid handler, none of the expeted parameters found (expected any of {0}): {1}" -f ($paramNames -join ', '), $LogHandler | shoutOut -MsgType Error
        "Messages marked with '{0}' will not be redirected using this handler." -f $msgType | shoutOut -MsgType Error
        throw ("None of {0} parameters declared by the given handler." -f ($paramNames -join ', '))
    }

    foreach ($param in $recognizedParams) {
        $paramType = $validParams[$param.Name.Extent.Text]
        if (($t = $param.StaticType) -and !($t.IsAssignableFrom($paramType)) ) {
            "Invalid handler, the '{0}' parameter should accept values of type '{1}' (found '{2}' which is not assignable from '{1}')." -f $param.Name, $paramType.Name, $t.Name | shoutOut -MsgType Error
            "Messages marked with '{0}' will not be redirected using this handler." -f $msgType | shoutOut -MsgType Error
            throw ("'{0}' parameter on the given handler is of invalid type (not assignable from [string])." -f $paramNames)
        }
    }

    return $LogHandler
}
# END: source\_validateShoutOutLogHandler.ps1


# START: source\Clear-ShoutOutRedirect.ps1

function Clear-ShoutOutRedirect {
    param(
        [Parameter(HelpMessage="Mesage Type to remove redirection of.")]
        [string]$msgType
    )

    $_ShoutOutSettings.LogFileRedirection.Remove($msgType)
}
# END: source\Clear-ShoutOutRedirect.ps1


# START: source\Get-ShoutOutConfig.ps1
function Get-ShoutOutConfig {
  return $_ShoutOutSettings
}
# END: source\Get-ShoutOutConfig.ps1


# START: source\Get-ShoutOutDefaultLog.ps1
function Get-ShoutOutDefaultLog {
    param()

    return $script:_ShoutOutSettings.DefaultLog
}
# END: source\Get-ShoutOutDefaultLog.ps1


# START: source\Get-ShoutOutRedirect.ps1

function Get-ShoutOutRedirect {
    param(
        [Parameter(HelpMessage="Message Type to retrieve redirection information for.")]
        [string]$msgType
    )

    return $script:_ShoutOutSettings.LogFileRedirection[$msgType]
} 
# END: source\Get-ShoutOutRedirect.ps1


# START: source\Invoke-ShoutOut.ps1

<#
.WISHLIST
    - Update so that that output is fed to shoutOut as it is generated rather than using the result output.
      The goal is to generate logging data continuously so that it's clear whether the script has hung or not.
      [Done]
.SYNOPSIS
    Helper function to execute commands (strings or blocks) with error-handling/reporting.
.DESCRIPTION
    Helper function to execute commands (strings or blocks) with error-handling/reporting.
If a scriptblock is passed as the operation, the function will attempt make any variables referenced by the
scriptblock available to the scriptblock when it is resolved (using variables available in the scope that
called Run-Operation).
 
The variables used in the command are identified using [scriptblock].Ast.FindAll method, and are imported
from the parent scope using $PSCmdlet.SessionState.PSVariable.Get.
 
The following variable-names are restricted and may cause errors if they are used in the operation:
 - $__thisOperation: The operation being run.
 - $__inputVariables: List of the variables being imported to run the operation.
 
.NOTES
   - Transforms ScriptBlocks to Strings prior to execution because of a quirk in iex where it will not allow the
     evaluation of ScriptBlocks without an input (a 'param' statement in the block). iex is used because it yields
     output as each line is evaluated, rather than waiting for the entire $OPeration to complete as would be the
     case with <ScriptBlock>.Invoke().
#>

function Invoke-ShoutOut {
    param(
        [parameter(ValueFromPipeline=$true, position=1)] $Operation,
        [parameter()][Switch] $OutNull,
        [parameter()][Switch] $NotStrict,
        [parameter()][Switch] $LogErrorsOnly,
        [parameter()][Switch] $Quiet
    )
    $shoutOutArgs = @{
        Quiet = $PSBoundParameters.ContainsKey('Quiet')
    }
    
    if (!$NotStrict) {
        # Switch error action preference to catch any errors that might pop up.
        # Works so long as the internal operation doesn't also change the preference.
        $OldErrorActionPreference = $ErrorActionPreference
        $ErrorActionPreference = [System.Management.Automation.ActionPreference]::Stop
    }

    if ($Operation -is [string]) {
        $Operation = [scriptblock]::create($Operation)
    }

    if (!$LogErrorsOnly) {
        $msg = "Running '$Operation'..."
        $msg | shoutOut -MsgType Invocation -ContextLevel 1 @shoutOutArgs
    }

    $r = try {
        
        # Step 1: Get any variables in the parent scope that are referenced by the operation.
        if ($Operation -is [scriptblock]) {
            $variableNames = $Operation.Ast.FindAll(
                {param($o) $o -is [System.Management.Automation.Language.VariableExpressionAst]},
                $true
            ) | ForEach-Object {
                $_.VariablePath.UserPath
            }

            $variables = foreach ($vn in $variableNames) {
                $PSCmdlet.SessionState.PSVariable.Get($vn)
            }
        }

        # Step 2: Convert the scriptblock if necessary.
        if ($Operation -is [scriptblock]) {
            # Under certain circumstances the iex cmdlet will not allow
            # the evaluation of ScriptBlocks without an input. However it will evaluate strings
            # just fine so we perform the transformation before evaluation.
            $Operation = $Operation.ToString()
        }

        # Step 3: inject the operation and the variables into a new isolated scope and resolve
        # the operation there.
        & {
            param(
                $thisOperation,
                $inputVariables
            )

            $__thisOperation = $thisOperation
            $__inputVariables = $inputVariables

            Remove-Variable "thisOperation"
            Remove-Variable "inputVariables"

            $__ = $null

            foreach ( $__ in $__inputVariables ) {
                if ($null -ne $__) {
                    if ($v = Get-Variable -Name $__.Name -Scope 0 -ea SilentlyContinue) {
                        if (-not $v.Options.HasFlag([System.Management.Automation.ScopedItemOptions]::Constant)) {
                            Set-Variable $__.Name $__.Value
                        }
                    } else {
                        Set-Variable $__.Name $__.Value
                    }
                }
            }

            Remove-Variable "__"

            # Invoke-Expression allows us to receive
            # and handle output as it is generated,
            # rather than wait for the operation to finish
            # as opposed to <[scriptblock]>.invoke().
            Invoke-Expression $__thisOperation | ForEach-Object {
                if (!$LogErrorsOnly) {
                    shoutOut "`t| $_" "Result" -ContextLevel 1 @shoutOutArgs
                }
                return $_
            }
        } $Operation $variables

    } catch {
        "An error occured while executing the operation:" | shoutOUt -MsgType Error -ContextLevel 1 @shoutOutArgs
        $_ | shoutOut -MsgType Error -ContextLevel 1 @shoutOutArgs
        <#
        $_.Exception, $_.CategoryInfo, $_.InvocationInfo, $_.ScriptStackTrace | Out-string | ForEach-Object {
            $_.Split("`n`r", [System.StringSplitOptions]::RemoveEmptyEntries).TrimEnd("`n`r")
        } | ForEach-Object {
            "`t| $_"
        } | shoutOut -MsgType 'Error' -ContextLevel 1 @shoutOutArgs
        #>

        
        $_
    }

    if (!$NotStrict) {
        $ErrorActionPreference = $OldErrorActionPreference
    }

    if ($OutNull) {
        return
    }
    return $r
}
# END: source\Invoke-ShoutOut.ps1


# START: source\Set-ShoutOutConfig.ps1
function Set-ShoutOutConfig {
    param(
        [Parameter(HelpMessage="The default Message Type that ShoutOut should apply to messages.")]
        [string]$DefaultMsgType,
        [Parameter(HelpMessage="Default log handler to use for Messages Types without redirection.")]
        [Alias("LogFile")]
        $Log,
        [Parameter(HelpMessage="Enable/Disable Context logging.")]
        [Alias("LogContext")]
        [boolean]$EnableContextLogging,
        [Parameter(HelpMessage="Disable/Enable ShoutOut.")]
        [Alias("Disabled")]
        [boolean]$DisableLogging
    )

    if ($PSBoundParameters.ContainsKey("DefaultMsgType")) {
        $_shoutOutSettings.DefaultMsgType = $DefaultMsgType
    }

    if ($PSBoundParameters.ContainsKey("Log")) {
        Set-ShoutOutDefaultLog $Log | Out-Null
    }

    if ($PSBoundParameters.ContainsKey("LogContext")) {
        $_shoutOutSettings.LogContext = $LogContext
    }
    
    if ($PSBoundParameters.ContainsKey("Disabled")) {
        $_shoutOutSettings.Disabled = $Disabled
    }

}
# END: source\Set-ShoutOutConfig.ps1


# START: source\Set-ShoutOutDefaultLog.ps1
function Set-ShoutOutDefaultLog {
    param(
        [parameter(Mandatory=$true, ValueFromPipeline=$true, Position=1, ParameterSetName="LogFilePath")][String]$LogFilePath,
        [parameter(Mandatory=$true, ValueFromPipeline=$true, Position=1, ParameterSetName="LogFile")][System.IO.FileInfo]$LogFile,
        [parameter(Mandatory=$true, ValueFromPipeline=$true, Position=1, ParameterSetName="LogHandler")][scriptblock]$LogHandler
    )

    switch ($PSCmdlet.ParameterSetName) {
        "LogFilePath" {
            try {
                _ensureShoutOutLogFile $LogFilePath -ErrorAction Stop | Out-Null
                $LogHandler = _buildBasicFileLogger $LogFilePath
            } catch {
                return $_
            }
        }
        "LogFile" {
            try {
                _ensureShoutOutLogFile $LogFile.FullName -ErrorAction Stop | Out-Null
                $LogHandler = _buildBasicFileLogger $LogFile.FullName
            } catch {
                return $_
            }
        }
    }

    try {
        $_shoutOutSettings.DefaultLog = _validateShoutOutLogHandler $LogHandler -ErrorAction Stop
    } catch {
        return $_
    }
    
}
# END: source\Set-ShoutOutDefaultLog.ps1


# START: source\Set-ShoutOutRedirect.ps1
function Set-ShoutOutRedirect {
    [CmdletBinding()]
    param(
        [parameter(Mandatory=$true, Position=1, HelpMessage="Message type to redirect.")][string]$MsgType,
        [Parameter(ParameterSetName="FilePath", ValueFromPipeline=$true, Mandatory=$true, Position=2, HelpMessage="Path to log file.")][string]$LogFilePath,
        [Parameter(ParameterSetName="FileInfo", ValueFromPipeline=$true, Mandatory=$true, Position=2, HelpMessage="FileInfo object.")][System.IO.FileInfo]$LogFile,
        [Parameter(ParameterSetName="Scriptblock", ValueFromPipeline=$true, Mandatory=$true, Position=2, HelpMessage="ScriptBlock to use as log handler.")][scriptblock]$LogHandler
    )

    switch ($PSCmdlet.ParameterSetName) {
        "FileInfo" {
            try {
                _ensureShoutOutLogFile $LogFile.FullName $msgType | Out-Null
                $LogHandler = _buildBasicFileLogger $LogFile.FullName
            } catch {
                return $_
            }

            $log = $LogFile.FullName
        }
        "FilePath" {
            try {
                _ensureShoutOutLogFile $LogFilePath $msgType | Out-Null
                $LogHandler = _buildBasicFileLogger $LogFilePath
            } catch {
                return $_
            }
        }
    }

    try {
        $_ShoutOutSettings.LogFileRedirection[$msgType] = _validateShoutOutLogHandler $LogHandler $MsgType
    } catch {
        return $_
    }
}
# END: source\Set-ShoutOutRedirect.ps1


# START: source\ShoutOut.ps1
# ShoutOut.ps1

# First-things first: Logging function (is the realest, push the message and let the harddrive feel it.)

<#
.SYNOPSIS
Pushes a message of the given $MsgType to the given $Log file with attached invocation metadata.
.DESCRIPTION
Logging function, used to push a message to a corresponding log-file.
The message is prepended with meta data about the invocation to shoutOut as:
<MessageType>|<Computer name>|<PID>|<calling Context>|<Date & time>|$Message
 
The default values for the parameters can be set using the Set-ShoutOutConfig,
Set-ShoutOutDefaultLog, and Set-ShotOutRedirect functions.
 
#>

function shoutOut {
    [CmdletBinding()]
    param(
        [parameter(Mandatory=$false,  position=1, ValueFromPipeline=$true, ParameterSetName="Message")]
        [Object]$Message,
        [Alias("ForegroundColor")]
        [parameter(Mandatory=$false, position=2)][String]$MsgType=$null,
        [parameter(Mandatory=$false, position=3)]$Log=$null,
        [parameter(Mandatory=$false, position=4)][Int32]$ContextLevel=1, # The number of levels to proceed up the call
                                                                         # stack when reporting the calling script.
        [parameter(Mandatory=$false)] [bool] $LogContext=$true,
        [parameter(Mandatory=$false)] [Switch] $NoNewline,
        [parameter(Mandatory=$false)] [Switch] $Quiet
    )
    
    begin {

        $settings = $_ShoutOutSettings

        # If shoutOut is disabled, return to caller.
        if ($settings.ContainsKey("Disabled") -and ($settings.Disabled)) {
            Write-Debug "Call to Shoutout, but Shoutout is disabled. Turn back on with 'Set-ShoutOutConfig -Disabled `$false'."
            return
        }

        <# Applying global variables #>
        
        if (!$Log -and $settings.containsKey("DefaultLog")) {
            $Log = $settings.DefaultLog
        }

        if (!$PSBoundParameters.ContainsKey('LogContext') -and $_ShoutOutSettings.ContainsKey("LogContext")) {
            $LogContext = $_ShoutOutSettings.LogContext
        }
    }

    process {

        $details = @{
            Message    = $Message
            Computer    = $env:COMPUTERNAME
            LogTime     = [datetime]::Now
            PID         = $pid
        }

        $msgObjectType = if ($null -ne $Message) {
            $Message.GetType()
        } else {
            $null
        }

        $details.ObjectType = if ($null -ne $msgObjectType) {
            $msgObjectType.Name
        } else {
            "NULL"
        }
        
        if ( (-not $PSBoundParameters.ContainsKey("MsgType")) -or ($null -eq $PSBoundParameters["MsgType"]) ) {
            
            switch ($details.ObjectType) {

                "ErrorRecord" {
                    $MsgType = "Error"
                }

                default {
                    if ([System.Exception].IsAssignableFrom($msgObjectType)) {
                        $MsgType = "Exception"
                    } else {
                        $MsgType = $script:_ShoutOutSettings.DefaultMsgType
                    }
                }
            }
        }

        $details.MessageType = $MsgType

        if ($settings.LogFileRedirection.ContainsKey($details.MessageType)) {
            $Log = $settings.LogFileRedirection[$details.MessageType]
        }

        # Hard-coded defaults just in case.
        if (!$Log) {
            $Log = ".\shoutout.log"
        }
        
        # If the log is a string, assume that it is a file path:
        $logHandler = Switch ($log.GetType().NAme) {
            String {
                _buildBasicFileLogger $Log
            }
            ScriptBlock {
                $Log
            }
        }

        $recurseArgs = @{}
        $PSBoundParameters.Keys | Where-Object { $_ -notin "Message", "MsgType" } | ForEach-Object {
            $recurseArgs[$_] = $PSBoundParameters[$_]
        }
        if ($recurseArgs.ContainsKey('ContextLevel')) {
            $recurseArgs.ContextLevel += 1
        } else {
            $recurseArgs.ContextLevel = 2
        }

        $messageString = $null
        # Apply formatting to make output more readable.
        switch ($details.ObjectType) {

            "String" {
                # No transformation necessary.
                $messageString = $details.Message
            }

            "NULL" {
                # No Transformation necessary.
                $messageString = ""
            }

            "ErrorRecord" {
                if ($null -ne $details.Message.Exception) {
                    shoutOut -Message $details.Message.Exception -MsgType Exception @recurseArgs
                }

                $m = $details.Message
                $MessageString = 'Exception', 'CategoryInfo', 'InvocationInfo', 'ScriptStackTrace' | ForEach-Object { $m.$_ } | Out-string | ForEach-Object Split "`n`r" | Where-Object { $_ }
                $MessageString = $MessageString -join "`n"
            }

            default {
                $t = $details.Message.GetType()
                if ([System.Exception].IsAssignableFrom($t)) {
                    if ($null -ne $details.Message.InnerException) {
                        shoutOut $details.Message.InnerException @recurseArgs
                    }
                    $m = $details.Message
                    $MessageString = 'message', 'Source', 'Stacktrace', 'TargetSite' | ForEach-Object { $m.$_ } | Out-string | ForEach-Object Split "`n`r" | Where-Object { $_ }
                    $MessageString = $MessageString -join "`n`r"
                } else {
                    $messageString = $Message | Out-String | ForEach-Object TrimEnd "`n`r"
                }
            }
        }

        $details.MessageString = $MessageString

        # Print to console if necessary
        if ([Environment]::UserInteractive -and !$Quiet) {

            if ($settings.containsKey("MsgStyles") -and ($settings.MsgStyles -is [hashtable]) -and $settings.MsgStyles.containsKey($details.MessageType)) {
                $msgStyle = $settings.MsgStyles[$details.MessageType]
            }
            
            if (!$msgStyle) {
                if ($details.MessageType -in [enum]::GetNames([System.ConsoleColor])) {
                    $msgStyle = @{ ForegroundColor=$details.MessageType }
                } else {
                    $msgStyle = @{ ForegroundColor="White" }
                }
            }

            $p = @{
                Object = $details.MessageString
                NoNewline = $NoNewline
            }
            if ($msgStyle.ForegroundColor) { $p.ForegroundColor = $msgStyle.ForegroundColor }
            if ($msgStyle.BAckgroundColor) { $p.BackgroundColor = $msgStyle.BackgroundColor }

            Write-Host @p
        }
        
        # Calculate parent/calling context
        $details.Caller = if ($LogContext) {

            
            # Calculate the callstack.

            $cs = Get-PSCallStack
            # Adjust ContextLevel if it is greater than the total size of the callstack:
            if ($cs.Length -le $ContextLevel) {
                $ContextLevel = $cs.Length - 1
            }
            $cs = $cs[$ContextLevel..($cs.length - 1)]

            # Record the callstack on details:
            $details.CallStack = $cs

            # Calculate caller context:
            $l  = if ($null -eq $cs[0].ScriptName) {
                "<No file>"
            } else {
                '{0}:{1}' -f $cs[0].ScriptName, $cs[0].ScriptLineNumber
            }
            "[{0}]{1}" -f ($cs.length), $l
        } else {
            "[context logging disabled]"
        }

        $createRecord = {
            param($details)

            "{0}|{1}|{2}|{3}|{4}|{5}|{6}" -f @( 
                $details.MessageType,
                $details.Computer,
                $details.PID,
                $details.Caller,
                $details.LogTime.toString('o'),
                $details.ObjectType,
                $details.MessageString
            )
        }

        try {

            $handlerArgs = @{}

            $LogHandler.Ast.ParamBlock.Parameters | ForEach-Object {
                $n = $_.Name.Extent.Text.TrimStart('$')
                switch ($n) {
                    Message {
                        $handlerArgs.$n = $details.Message
                    }
                    Record {
                        $handlerArgs.$n = & $createRecord $details
                    }
                    Details {
                        $handlerArgs.$n = $details
                    }
                }
            }

            & $LogHandler @handlerArgs

        } catch {
            "Failed to log: {0}" -f ($handlerArgs | Out-String) | Write-Error
            "using log handler: {0}" -f $logHandler | Write-Error
            $_ | Out-string | Write-Error
        }
        
    }
}
# END: source\ShoutOut.ps1