Private/Console.ps1

# What a Technician sees while a Run is happening.
#
# The Report is the product of a Run; this is only the running commentary, captured into
# console.log by the transcript. Nothing here decides anything, and no Check calls it:
# Findings are returned as values and the Run displays them.

# One Severity, one colour, in one place. The Report renders the same decision as CSS
# classes; these are the console's half of it.
$script:SeverityColour = @{ OK = 'Green'; INFO = 'Gray'; WARN = 'Yellow'; FAIL = 'Red' }

# The console's own switches, as Windows numbers them (SetConsoleMode, on the input handle).
$script:ConsoleStdInputHandle = -10
$script:ConsoleQuickEditMode  = 0x0040
$script:ConsoleExtendedFlags  = 0x0080

function Initialize-ConsoleNativeMethod {
    <#
    .SYNOPSIS
        Loads the three Windows calls that read and set a console's mode. Once, on first use.
    .DESCRIPTION
        Not at import: importing this module must do nothing but define functions.
    #>

    [CmdletBinding()]
    param()

    if ('Gutcheck.ConsoleNative' -as [type]) { return }
    Add-Type -Namespace Gutcheck -Name ConsoleNative -MemberDefinition @'
[DllImport("kernel32.dll", SetLastError = true)] public static extern IntPtr GetStdHandle(int nStdHandle);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)] public static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)] public static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode);
'@

}

function Get-ConsoleModeWithoutQuickEdit {
    <#
    .SYNOPSIS
        A console mode with QuickEdit taken out, and nothing else about it changed. Pure.
    .DESCRIPTION
        Windows only honours a change to QuickEdit when the extended-flags bit is set in
        the same call, so that bit goes in with it.
    #>

    [CmdletBinding()]
    [OutputType([uint32])]
    param([Parameter(Mandatory)][uint32]$Mode)

    [uint32](($Mode -band (-bnot [uint32]$script:ConsoleQuickEditMode)) -bor $script:ConsoleExtendedFlags)
}

function Disable-ConsoleQuickEdit {
    <#
    .SYNOPSIS
        Stops a click in the console window from freezing the Run. Returns what the mode
        was, for Restore-ConsoleMode, or $null when there was nothing to change.
    .DESCRIPTION
        In a classic console window a click starts a text selection, and while one is
        active Windows blocks every program that writes to the window. The program stands
        still at its next line of output until somebody presses Enter. The Elevated Part
        runs in such a window, and one click on it - to bring it to the front, to see what
        it is - left the Main Part waiting out its deadline for a process that was fine.
 
        With QuickEdit off the window can still be read, scrolled and marked from its
        menu; it just no longer stops what is running in it. Windows Terminal never had
        the problem and ignores the switch.
 
        Never throws: no console, a redirected input, a host that is no console at all -
        each is a Run with no window to click on.
    #>

    [CmdletBinding()]
    param()

    try {
        Initialize-ConsoleNativeMethod
        $handle = [Gutcheck.ConsoleNative]::GetStdHandle($script:ConsoleStdInputHandle)
        [uint32]$mode = 0
        if (-not [Gutcheck.ConsoleNative]::GetConsoleMode($handle, [ref]$mode)) { return $null }

        $wanted = Get-ConsoleModeWithoutQuickEdit -Mode $mode
        if ($wanted -eq $mode) { return $null }
        if (-not [Gutcheck.ConsoleNative]::SetConsoleMode($handle, $wanted)) { return $null }
        $mode
    }
    catch { $null }
}

function Restore-ConsoleMode {
    <#
    .SYNOPSIS
        Puts a console back the way Disable-ConsoleQuickEdit found it. Never throws.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Mode)

    if ($null -eq $Mode) { return }
    try {
        Initialize-ConsoleNativeMethod
        $handle = [Gutcheck.ConsoleNative]::GetStdHandle($script:ConsoleStdInputHandle)
        $null = [Gutcheck.ConsoleNative]::SetConsoleMode($handle, [uint32]$Mode)
    }
    catch { }
}

function Initialize-Console {
    <#
    .SYNOPSIS
        Tells Lantern how a Run looks and what it says: German, the parts of a Run folding
        to a line each, and the banner brought in by a light running across it.
    .DESCRIPTION
        Lantern is what asks a Run's questions and draws its cards (ADR-0007). It keeps
        what it is told here in variables of this module and does nothing else: nothing
        is drawn, asked or written.
    #>

    [CmdletBinding()]
    param()

    Set-LanternOption -Card Accordion -Banner Sweep -Progress Hairline -Text @{
        Yes              = Get-Text 'Console.Ask.Yes'
        No               = Get-Text 'Console.Ask.No'
        None             = Get-Text 'Console.Ask.None'
        Cancelled        = Get-Text 'Console.Ask.Cancelled'
        Done             = Get-Text 'Console.Ask.Done'
        Selected         = Get-Text 'Console.Ask.Selected'
        Filter           = Get-Text 'Console.Ask.Filter'
        NoMatch          = Get-Text 'Console.Ask.NoMatch'
        Add              = Get-Text 'Console.Ask.Add'
        Range            = Get-Text 'Console.Ask.Range'
        HintChoice       = Get-Text 'Console.Ask.HintChoice'
        HintMultiChoice  = Get-Text 'Console.Ask.HintMultiChoice'
        PlainChoice      = Get-Text 'Console.Ask.PlainChoice'
        PlainMultiChoice = Get-Text 'Console.Ask.PlainMultiChoice'
        PlainSelection   = Get-Text 'Console.Ask.PlainSelection'
        PlainAdded       = Get-Text 'Console.Ask.PlainAdded'
        PlainDefault     = Get-Text 'Console.Ask.PlainDefault'
        PlainInvalid     = Get-Text 'Console.Ask.PlainInvalid'
    }
}

# The card that is open, by its key, or nothing. Only ever set where the window can draw.
$script:ConsolePart = $null

function Test-ConsoleLive {
    <#
    .SYNOPSIS
        Whether the window of this Run can be drawn in: redrawn in place, as cards.
    .DESCRIPTION
        Where it can, a Run shows its parts as cards and each Check as a row of one. Where
        it cannot - Windows PowerShell 5.1, a redirected output, a host that is no
        console - a Run writes what it always wrote, line after line, and console.log
        has every Finding.
 
        Lantern decides this and has no question to ask it by. This reads what its own
        functions read, and is the one place Gutcheck looks inside it.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param()

    try { [bool](Resolve-LanternTerminal -Terminal $null).LiveOutput } catch { $false }
}

function Write-ConsoleLine {
    <#
    .SYNOPSIS
        Says one thing to the Technician: a row of the card that is open, or a line.
    .DESCRIPTION
        What Write-Host is everywhere else. Anything said while a card may be open goes
        through here, because a line written past Lantern lands in the middle of the card.
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Position = 0)][AllowEmptyString()][AllowNull()][string]$Text = '',
        [AllowEmptyString()][string]$Color = ''
    )

    if (-not $script:ConsolePart) {
        Write-LanternLine -Text $Text -Color $Color
        return
    }
    # The texts carry the blank lines and the indents that set them apart in a scrolling
    # window. A card has its border for that, and a line break inside a row would break
    # the border.
    foreach ($line in @("$Text" -split "\r?\n" | Where-Object { $_.Trim() })) {
        Write-LanternLine -Text $line.Trim() -Color $Color
    }
}

function Write-ConsoleNote {
    <#
    .SYNOPSIS
        Says what a Check is doing just now, and why the Technician is waiting: a line
        where lines scroll past, and in a card the text of the row that is going.
    .DESCRIPTION
        A line stays where it was written. In a card it would stay too, under every row,
        and still say "waiting for the admin part" when the last Check is done. There it
        is what the row says until the row says something else.
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Position = 0)][AllowEmptyString()][AllowNull()][string]$Text = '',
        [AllowEmptyString()][string]$Color = ''
    )

    if (-not $script:ConsolePart) { Write-LanternLine -Text $Text -Color $Color; return }
    if (-not $script:ProgressRow) { return }
    $said = @("$Text" -split "\r?\n" | Where-Object { $_.Trim() })
    if ($said.Count) { Write-LanternCardRow -Name $script:ProgressRow -Text $said[0].Trim().Trim('=', '.').Trim() }
}

function Show-Banner {
    [CmdletBinding()]
    param($Version)

    # The words stand beside the art, line for line, and are not written into it: the
    # art is fixed width, and a tagline and a version number are not.
    Show-LanternBanner -Framed -Art @(
        '.================.'
        '|| - - ||'
        '|| o o ||'
        '|| ____ ||'
        '|| ||'
        "'================'"
    ) -Text @(
        ''
        'gutcheck'
        ''
        (Get-Text 'Console.Banner.Tagline1')
        (Get-Text 'Console.Banner.Tagline2')
        ('v{0}' -f $Version)
    )
    Write-LanternLine ''
}

function Start-RunPart {
    <#
    .SYNOPSIS
        Opens the card of one part of a Run: Scope, Checks or Result. Nothing where the
        window cannot draw.
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param([Parameter(Mandatory)][ValidateSet('Scope', 'Checks', 'Result')][string]$Part)

    if (-not (Test-ConsoleLive)) { return }
    $script:ConsolePart = $Part
    $titles = @((Get-Text 'Console.Part.Scope'), (Get-Text 'Console.Part.Checks'), (Get-Text 'Console.Part.Result'))
    Start-LanternCard -Title $titles[@('Scope', 'Checks', 'Result').IndexOf($Part)] -Of $titles
}

function Complete-RunPart {
    <#
    .SYNOPSIS
        Closes the card that is open, with what came of that part. Never throws, and does
        nothing when none is open.
    .PARAMETER Open
        Leaves the card as it is instead of folding it to a line: for the Result, whose
        rows are what the Technician is left with.
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [AllowEmptyString()][string]$Status = '',
        [ValidateSet('OK', 'INFO', 'WARN', 'FAIL')][string]$Severity = 'OK',
        [switch]$Open
    )

    if (-not $script:ConsolePart) { return }
    $script:ConsolePart = $null
    try { Complete-LanternCard -Status $Status -Mark $script:SeverityMark[$Severity] -Open:$Open } catch { }
}

# One Severity, one mark of a row, the same way as one colour.
$script:SeverityMark = @{ OK = 'Ok'; INFO = 'Info'; WARN = 'Warn'; FAIL = 'Fail' }

function Show-CheckRow {
    <#
    .SYNOPSIS
        Lists what the Checks card is going to do, each as a row still to come, so that a
        Technician sees how much there is. Nothing where no card is open.
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Name)

    if (-not $script:ConsolePart) { return }
    foreach ($one in @($Name | Where-Object { $_ })) { Write-LanternCardRow -Name $one -Mark Todo }
}

function Complete-RunRow {
    <#
    .SYNOPSIS
        Says how a row of the open card ended. Nothing where no card is open.
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][string]$Name,
        [ValidateSet('OK', 'INFO', 'WARN', 'FAIL')][string]$Severity = 'OK',
        [AllowEmptyString()][AllowNull()][string]$Text = ''
    )

    if (-not $script:ConsolePart) { return }
    Write-LanternCardRow -Name $Name -Mark $script:SeverityMark[$Severity] -Text (("$Text" -split "\r?\n")[0])
}

function Write-CheckHeading {
    <#
    .SYNOPSIS
        Says which Check begins. In a card the Check has its row, and nothing is written.
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param([Parameter(Mandatory)][string]$Name)

    if ($script:ConsolePart) { return }
    Write-Host ("`n== {0} ==" -f $Name) -ForegroundColor Cyan
}

function Write-CheckResult {
    <#
    .SYNOPSIS
        Shows what a Check found: as its row in the Checks card, or as a line for every
        Finding where the window cannot draw.
    .DESCRIPTION
        A row has room for one thing, so it says the worst of what the Check found, and
        how many Findings there are beside it. The others are in the Report, and those
        that matter are in the Result card. Where nothing is drawn every Finding is a
        line, as it always was, and console.log has them all.
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][string]$Name,
        [AllowNull()][AllowEmptyCollection()]$Finding
    )

    $found = @($Finding | Where-Object { $_ })
    if (-not $script:ConsolePart) {
        foreach ($one in $found) { Write-FindingToHost -Finding $one }
        return
    }
    if (-not $found.Count) { Complete-RunRow -Name $Name -Severity INFO; return }

    # A Finding has a name of its own beside the Check's, and its value says little
    # without it.
    $worst = @(Sort-Finding -Finding $found)[0]
    $text  = '{0}: {1}' -f $worst.Check, $worst.Value
    if ($found.Count -gt 1) { $text = (Get-Text 'Console.Part.More') -f $text, ($found.Count - 1) }
    Complete-RunRow -Name $Name -Severity $worst.Severity -Text $text
}

function Write-FindingToHost {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Finding)

    Write-Host (' [{0,-4}] {1}: {2}' -f $Finding.Severity, $Finding.Check, $Finding.Value) `
        -ForegroundColor $script:SeverityColour[$Finding.Severity]
}

function Write-WorstFindingsToHost {
    <#
    .SYNOPSIS
        Repeats the Findings that matter, worst first, once a Run has finished.
    .DESCRIPTION
        A Technician watching a Run needs the FAILs and WARNs gathered in one place at the
        end. This is not the Report; it is a pointer to it.
 
        Where the window can draw it is the Result card, and the one card that stays open:
        the Checks have folded to a line by then, and this is where what they found is
        left to read.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()][AllowEmptyCollection()]$Finding,
        [Parameter(Mandatory)][string]$ReportPath
    )

    $failed = @($Finding | Where-Object { $_.Severity -eq 'FAIL' }).Count
    $warned = @($Finding | Where-Object { $_.Severity -eq 'WARN' }).Count

    Start-RunPart -Part Result
    # In the card its title says what this is, and the Checks card above it the counts.
    if (-not $script:ConsolePart) { Write-Host (Get-Text 'Console.Summary.Heading' $failed $warned) -ForegroundColor Cyan }
    Sort-Finding -Finding $Finding | Where-Object { $_.Severity -eq 'FAIL' -or $_.Severity -eq 'WARN' } | ForEach-Object {
        Write-ConsoleLine (' [{0}] {1}: {2}' -f $_.Severity, $_.Check, $_.Value) -Color $script:SeverityColour[$_.Severity]
    }
    Write-ConsoleLine (Get-Text 'Console.Summary.Report' $ReportPath) -Color Cyan
    Complete-RunPart -Open
}