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()

    # A card is as wide as the window: a row says what a Check found, and half of that is
    # no use to anybody. And two thirds of its height at most, so that the banner and the
    # parts that are done stay in sight above the Checks.
    Set-LanternOption -Card Accordion -Banner Sweep -Progress Hairline -CardWidth 0 -CardHeight 67 -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 Get-ConsoleCardRoom {
    <#
    .SYNOPSIS
        How many columns there are inside a card for what it holds, in this window.
    .DESCRIPTION
        For what must not be cut or wrapped, which is the QR code: it is drawn only where
        all of it fits. A card is as wide as the window, less the column every Frame
        leaves free, and has two columns of border and blank on either side. Like
        Test-ConsoleLive, this reads what Lantern's own functions read.
    #>

    [CmdletBinding()]
    [OutputType([int])]
    param()

    try { [int](Resolve-LanternTerminal -Terminal $null).Width - 1 - 4 } catch { 0 }
}

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-ConsoleHeading {
    <#
    .SYNOPSIS
        Says which part of a Run begins, where lines scroll past. A card has its title for
        that, and nothing is written.
    #>

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

    if ($script:ConsolePart) { return }
    Write-LanternLine -Text $Text -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 {
    <#
    .SYNOPSIS
        What a Run shows of itself before anything else: its face, its name and its version.
    .DESCRIPTION
        Two drawings of the same face. Where the window has the glyphs for it, a small
        screen in thin rounded lines with two eyes and a mouth, and the version beside
        the name. Where it has ASCII only - Windows PowerShell 5.1 in a classic console
        among them - the screen it always was. Lantern knows which, and this asks what
        its own functions ask.
 
        The glyphs are written as their numbers: this file has no byte order mark, and
        Windows PowerShell would read them as something else.
    #>

    [CmdletBinding()]
    param($Version)

    $drawn = $false
    try { $drawn = (Get-LanternLook -Terminal (Resolve-LanternTerminal -Terminal $null)).Glyph.EdgeDown -ne '|' } catch { }

    if ($drawn) {
        $across = [string][char]0x2500
        $down   = [string][char]0x2502
        $top    = [string][char]0x256D + ($across * 10) + [string][char]0x256E
        $bottom = [string][char]0x2570 + ($across * 10) + [string][char]0x256F
        # The face, and the face with its eyes shut: once it is there, it blinks.
        $face   = '{0} {1} {2} {1} {0}' -f $down, '{0}', [string][char]0x203F
        Show-LanternBanner -Framed -Art @($top, ($face -f [string][char]0x25CF), $bottom) `
            -Blink @($top, ($face -f $across), $bottom) -Text @(
            ('gutcheck v{0}' -f $Version)
            (Get-Text 'Console.Banner.Tagline1')
            (Get-Text 'Console.Banner.Tagline2')
        )
        Write-LanternLine ''
        return
    }

    # 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 ||'
        '|| ____ ||'
        '|| ||'
        "'================'"
    ) -Blink @(
        '.================.'
        '|| - - ||'
        '|| - - ||'
        '|| ____ ||'
        '|| ||'
        "'================'"
    ) -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: its start, the Check Definitions, the Scope,
        the Checks or the Result. Nothing where the window cannot draw.
    #>

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

    if (-not (Test-ConsoleLive)) { return }
    # Open already: the Result is opened when the Checks are done, and again by what
    # writes into it.
    if ($script:ConsolePart -eq $Part) { return }
    $script:ConsolePart = $Part
    $titles = @(
        (Get-Text 'Console.Part.Start'), (Get-Text 'Console.Part.Definitions'), (Get-Text 'Console.Part.Scope'),
        (Get-Text 'Console.Part.Checks'), (Get-Text 'Console.Part.Result')
    )
    Start-LanternCard -Title $titles[@('Start', 'Definitions', '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 Get-ConsoleRowName {
    <#
    .SYNOPSIS
        What the row of a Check is called in the Checks card: the Check's name, without
        the "App:" the Checks of an application begin with. Pure.
    .DESCRIPTION
        The names stand in one column, and "App: " took five characters of it in most
        rows without telling them apart. Every row of a Check goes through here, so that
        its progress and its result are said in the same row. The Report, the logs and a
        window that cannot draw keep the name as it is.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Name)

    $short = ("$Name" -replace '^\s*App:\s*', '').Trim()
    if ($short) { return $short }
    "$Name".Trim()
}

function Get-ResultPercent {
    <#
    .SYNOPSIS
        Where the bar of the Result stands at a step of making the Report. Pure.
    .DESCRIPTION
        Putting the evidence together and looking for Leads take no time to speak of, and
        have the first tenth. Writing the Report has the rest, by how far it says it is.
    #>

    [CmdletBinding()]
    [OutputType([int])]
    param(
        [Parameter(Mandatory)][ValidateSet('Evidence', 'Leads', 'Write')][string]$Step,
        [double]$Fraction = 0
    )

    switch ($Step) {
        'Evidence' { return 2 }
        'Leads'    { return 6 }
    }
    [int][math]::Floor(10 + 90 * [math]::Max([double]0, [math]::Min([double]1, $Fraction)))
}

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 (Get-ConsoleRowName -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 (Get-ConsoleRowName -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-ResultProgress {
    <#
    .SYNOPSIS
        The bar of the Result card: which of the steps that make the Report a Run is at.
        Nothing where the Result is no card.
    .DESCRIPTION
        Between the last Check and the Report there are some seconds in which the Findings
        are put together and written, and a window that shows nothing in them looks as if
        the Run had stopped. In steps where no more is known, and as a percentage where
        the step says how far it is: writing the Report does, and is most of the time.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Step')]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][string]$Status,
        [Parameter(Mandatory, ParameterSetName = 'Step')][int]$Step,
        [Parameter(ParameterSetName = 'Step')][int]$Of = 3,
        [Parameter(Mandatory, ParameterSetName = 'Percent')][double]$Percent
    )

    if ($script:ConsolePart -ne 'Result') { return }
    if ($PSCmdlet.ParameterSetName -eq 'Percent') {
        Write-LanternProgress (Get-Text 'Console.Result.Bar') -Status $Status `
            -PercentComplete ([int][math]::Floor([math]::Max([double]0, [math]::Min([double]100, $Percent))))
        return
    }
    Write-LanternProgress (Get-Text 'Console.Result.Bar') -Status $Status -Step $Step -Of $Of
}

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]
    }
    if ($script:ConsolePart) {
        # The bar is full, and what it leaves in the card when it goes is where the Report
        # is: the last line of the Run, as it is where lines scroll past.
        Write-LanternProgress (Get-Text 'Console.Result.Bar') -PercentComplete 100
        Complete-LanternProgress -Status $ReportPath
    }
    else { Write-ConsoleLine (Get-Text 'Console.Summary.Report' $ReportPath) -Color Cyan }
    Complete-RunPart -Open
}