Private/AppSelection.ps1

# Choosing which applications a Run examines.
#
# A Customer's estate has more applications in it than a Technician wants to wait for, and
# the one being complained about is usually known before the Run starts. Selection happens
# before the first Check so that every question a Run asks - this one, and the UAC prompt
# in #8 - comes at the beginning, while the Technician is still watching.
#
# The decision is a pure function of the candidates and what was asked for. Only the
# asking is interactive, and it lives in its own function so the rule can be tested
# without a console.

function Get-AppCandidate {
    <#
    .SYNOPSIS
        The applications a Run could examine, with whether each is installed or running.
    .DESCRIPTION
        A Gatherer: it reads the machine and decides nothing beyond what is factually
        there. Whether a candidate is worth examining is Select-AppDefinition's call.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)][AllowEmptyCollection()]$Definition)

    $running = @(Get-Process -ErrorAction SilentlyContinue)

    @(foreach ($definition in @($Definition)) {
        $parameters = ConvertTo-ParameterHashtable (Get-DataProperty $definition 'Parameters')
        $pattern    = Get-Parameter $parameters 'Process' ''
        $name       = Get-Parameter $parameters 'App' (Get-DataProperty $definition 'Name')

        $processes = 0
        if ($pattern) { $processes = @($running | Where-Object { $_.ProcessName -match $pattern }).Count }
        $installed = @(Get-AppInstallation -Parameters $parameters).Count -gt 0

        [pscustomobject]@{
            PSTypeName = 'Gutcheck.AppCandidate'
            Definition = $definition
            Name       = $name
            CheckName  = Get-DataProperty $definition 'Name'
            Installed  = $installed
            Running    = $processes
            State      = (Get-AppCandidateState -Installed $installed -Running $processes)
        }
    })
}

function Get-AppCandidateState {
    <#
    .SYNOPSIS
        How a candidate reads in the selection list.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([bool]$Installed, [int]$Running)

    $parts = @()
    if ($Installed)     { $parts += Get-Text 'Console.Apps.State.Installed' }
    if ($Running -gt 0) { $parts += Get-Text 'Console.Apps.State.Running' $Running }
    if (-not $parts.Count) { return '-' }
    $parts -join ', '
}

function New-AdHocAppDefinition {
    <#
    .SYNOPSIS
        A Check Definition for an application nobody has written one for yet.
    .DESCRIPTION
        A Technician at a Customer Site meets software MERLIN has never seen. The generic
        half of the App Kind - processes, resources, crashes - works on anything with a
        process name, so an ad-hoc name buys all of that without a Definition. What it
        cannot buy is Servers or the application-specific Kinds, which need one.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)][string]$ProcessName)

    $name = $ProcessName -replace '\.exe$', ''

    [pscustomobject]@{
        PSTypeName           = 'Gutcheck.CheckDefinition'
        Name                 = "App: $name"
        Kind                 = 'App'
        Parameters           = @{
            App     = $name
            Process = '^' + [regex]::Escape($name) + '$'
            Match   = '^' + [regex]::Escape($name)
        }
        MinimumModuleVersion = $null
    }
}

function Select-AppDefinition {
    <#
    .SYNOPSIS
        Which app Check Definitions this Run performs. Pure: it asks nothing and reads nothing.
    .PARAMETER Requested
        What the Technician asked for: application names, All, Auto, None, or proc:<name>.
        Empty means nothing was asked, and the caller decides whether to prompt.
    .PARAMETER Interactive
        Whether the Technician can be asked. A Run with no answer and nobody to ask selects
        automatically rather than stopping, because a Run started by a scheduled task or a
        remote session has to finish on its own.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()]$Candidate,
        [AllowNull()][AllowEmptyCollection()][string[]]$Requested = @(),
        [bool]$Interactive = $false
    )

    $candidates = @($Candidate)
    $asked      = @(@($Requested) | Where-Object { $_ -and "$_".Trim() })

    if (-not $asked.Count) {
        # Nothing asked for and nobody to ask: everything this machine actually has.
        if (-not $Interactive) { return Get-DetectedAppDefinition -Candidate $candidates }
        return $null   # the caller prompts; $null says "no answer yet", not "none"
    }

    $selected = New-Object System.Collections.Generic.List[psobject]

    foreach ($token in $asked) {
        $value = "$token".Trim()
        switch -Regex ($value) {
            '^none$' { return , @() }
            '^all$'  { foreach ($c in $candidates) { $selected.Add($c.Definition) }; continue }
            '^auto$' {
                foreach ($definition in (Get-DetectedAppDefinition -Candidate $candidates)) {
                    $selected.Add($definition)
                }
                continue
            }
            '^proc:(.+)$' { $selected.Add((New-AdHocAppDefinition -ProcessName $Matches[1])); continue }
            default {
                $match = @($candidates | Where-Object { $_.Name -eq $value }) | Select-Object -First 1
                if ($match) { $selected.Add($match.Definition) }
                else {
                    # An unknown name is far more likely to be a process the Technician can
                    # see in Task Manager than a mistake, so it is treated as one.
                    $selected.Add((New-AdHocAppDefinition -ProcessName $value))
                }
                continue
            }
        }
    }

    , @($selected | Sort-Object { $_.Name } -Unique)
}

function Test-NoAppsRequested {
    <#
    .SYNOPSIS
        Whether the Technician asked for no applications at all. Pure.
    .DESCRIPTION
        None wins over anything asked with it, the same way Select-AppDefinition resolves
        it, so that the two cannot disagree about what a request means.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Requested)

    [bool](@($Requested) | Where-Object { "$_".Trim() -eq 'None' })
}

function Get-DetectedAppDefinition {
    <#
    .SYNOPSIS
        The Definitions for applications this machine has, installed or running.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)][AllowEmptyCollection()]$Candidate)

    , @(@($Candidate) | Where-Object { $_.Installed -or $_.Running -gt 0 } |
        ForEach-Object { $_.Definition })
}

function Read-AppSelection {
    <#
    .SYNOPSIS
        Asks the Technician which applications to examine. Not tested: it is a prompt.
    .DESCRIPTION
        Enter takes everything detected, which is the answer nine times in ten and the
        reason the detected state is shown beside each entry.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param([Parameter(Mandatory)][AllowEmptyCollection()]$Candidate)

    $candidates = @($Candidate)
    if (-not $candidates.Count) { return @('None') }

    $width = 120
    try { $width = $Host.UI.RawUI.WindowSize.Width } catch { }

    Write-Host (Get-Text 'Console.Apps.Heading') -ForegroundColor Cyan
    foreach ($row in @(Get-AppSelectionLayout -Candidate $candidates -Width $width)) {
        $cells = @($row)
        for ($c = 0; $c -lt $cells.Count; $c++) {
            $colour = $(if ($cells[$c].Detected) { 'White' } else { 'DarkGray' })
            Write-Host $cells[$c].Text -ForegroundColor $colour -NoNewline:($c -lt $cells.Count - 1)
        }
    }

    $detected = @($candidates | Where-Object { $_.Installed -or $_.Running -gt 0 })
    Write-Host (Get-Text 'Console.Apps.Prompt')
    Write-Host ((Get-Text 'Console.Apps.Default') -f
        $(if ($detected.Count) { ($detected.Name) -join ', ' } else { (Get-Text 'Value.Shared.None') }))

    $answer = Read-Host (Get-Text 'Console.Apps.Selection')
    if (-not "$answer".Trim()) { return @('Auto') }

    ConvertFrom-AppSelectionAnswer -Answer $answer -Candidate $candidates
}

function Get-AppSelectionLayout {
    <#
    .SYNOPSIS
        The selection list as rows of cells, in two columns when the window allows. Pure.
    .DESCRIPTION
        Forty-odd applications in one column push the first of them off the top of the
        window before the Technician is asked to choose. Two columns fit on one screen.
 
        Numbered down the left column, then down the right, so a range a Technician types
        - 5-7 - is three lines read downwards rather than a zigzag across both columns.
        A window too narrow for two columns gets one, rather than lines that wrap and
        break both.
 
        Each cell carries its padded text and whether the application was detected; the
        caller only writes them. Separate from the prompt so the arithmetic is tested.
    #>

    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()]$Candidate,
        [int]$Width = 120
    )

    $candidates = @($Candidate)
    if (-not $candidates.Count) { return }

    $nameWidth = (@($candidates | ForEach-Object { "$($_.Name)".Length }) + 12 | Measure-Object -Maximum).Maximum
    $texts = @(for ($i = 0; $i -lt $candidates.Count; $i++) {
        ('[{0,2}] {1} {2}' -f ($i + 1), "$($candidates[$i].Name)".PadRight($nameWidth), $candidates[$i].State)
    })
    $cellWidth = ($texts | ForEach-Object { $_.Length } | Measure-Object -Maximum).Maximum

    $indent = ' '
    $gap    = ' '
    $columns = 1
    if ($indent.Length + 2 * $cellWidth + $gap.Length -le $Width - 1) { $columns = 2 }

    $rows = [int][math]::Ceiling($candidates.Count / $columns)
    for ($r = 0; $r -lt $rows; $r++) {
        , @(for ($c = 0; $c -lt $columns; $c++) {
            $i = $c * $rows + $r
            if ($i -ge $candidates.Count) { continue }
            $text = $texts[$i]
            # Padded only when something follows it on the line, so no row ends in spaces.
            $last = ($c -eq $columns - 1) -or (($c + 1) * $rows + $r -ge $candidates.Count)
            if (-not $last) { $text = $text.PadRight($cellWidth) }
            $prefix = $(if ($c -eq 0) { $indent } else { $gap })
            [pscustomobject]@{
                Text     = $prefix + $text
                Detected = [bool]($candidates[$i].Installed -or $candidates[$i].Running -gt 0)
            }
        })
    }
}

function ConvertFrom-AppSelectionAnswer {
    <#
    .SYNOPSIS
        Turns what the Technician typed into the tokens Select-AppDefinition understands.
    .DESCRIPTION
        Pure, and separate from the prompt, because index arithmetic off by one is exactly
        the kind of mistake that silently examines the wrong application.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Answer,
        [Parameter(Mandatory)][AllowEmptyCollection()]$Candidate
    )

    $candidates = @($Candidate)
    $tokens     = @("$Answer" -split '[,;\s]+' | Where-Object { $_ })
    if (-not $tokens.Count) { return @('Auto') }

    $names = New-Object System.Collections.Generic.List[string]
    foreach ($token in $tokens) {
        if ($token -eq '0') { return @('None') }

        if ($token -match '^(\d+)-(\d+)$') {
            foreach ($number in [int]$Matches[1]..[int]$Matches[2]) {
                if ($number -ge 1 -and $number -le $candidates.Count) { $names.Add($candidates[$number - 1].Name) }
            }
            continue
        }
        if ($token -match '^\d+$') {
            $number = [int]$token
            if ($number -ge 1 -and $number -le $candidates.Count) { $names.Add($candidates[$number - 1].Name) }
            continue
        }
        $names.Add($token)
    }

    if (-not $names.Count) { return @('None') }
    # No unary comma: every path out of here returns at least one token, so there is no
    # empty array to protect, and the extra wrapping only confuses what the caller gets.
    @($names)
}

function Test-RunInteractive {
    <#
    .SYNOPSIS
        Whether this Run has a Technician in front of it to ask.
    #>

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

    try { [Environment]::UserInteractive -and -not [Console]::IsInputRedirected }
    catch { $false }
}

function Resolve-AppCheck {
    <#
    .SYNOPSIS
        Splits a Run's Check Definitions into the app Checks it will perform and the rest.
    .DESCRIPTION
        The app Checks are the only ones a Technician chooses between. Everything else a
        Run performs is performed, which is why selection is a filter here rather than
        something each Kind has to know about.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()]$Definition,
        [AllowNull()][AllowEmptyCollection()][string[]]$Requested = @()
    )

    $all     = @($Definition)
    $appKind = @($all | Where-Object { (Get-DataProperty $_ 'Kind') -eq 'App' })

    # A Check of any Kind may say which application it belongs to, by carrying an App
    # parameter. Outlook's mail files, its add-ins, an application's database and its
    # Servers are all Checks about one application, and a Technician who did not select
    # that application did not ask for them - the script only ever ran them from inside
    # its per-app loop. A Check with no App parameter belongs to the machine and always
    # runs, which is what keeps a Customer-wide Server Check independent of app selection.
    $companion = @($all | Where-Object {
        (Get-DataProperty $_ 'Kind') -ne 'App' -and (Get-DefinitionApp -Definition $_)
    })
    $other = @($all | Where-Object {
        (Get-DataProperty $_ 'Kind') -ne 'App' -and -not (Get-DefinitionApp -Definition $_)
    })

    # Detecting which applications are present means walking three registry hives, the
    # Start menu and the Store package list. A Technician who said None has asked for
    # none of that, and doing it anyway would be a Run spending time to build a list
    # nothing reads.
    $candidates = @()
    if (-not (Test-NoAppsRequested -Requested $Requested)) {
        $candidates = @(Get-AppCandidate -Definition $appKind)
    }

    # A Run with no app Definitions at all can still be asked for an ad-hoc process, so
    # selection runs either way.
    $selected = Select-AppDefinition -Candidate $candidates -Requested $Requested `
        -Interactive (Test-RunInteractive)

    if ($null -eq $selected) {
        $answer   = Read-AppSelection -Candidate $candidates
        $selected = Select-AppDefinition -Candidate $candidates -Requested $answer -Interactive $false
    }

    # The Checks belonging to an application the Technician did choose.
    $chosen = @(@($selected) | ForEach-Object {
        Get-Parameter (ConvertTo-ParameterHashtable (Get-DataProperty $_ 'Parameters')) 'App' ''
    } | Where-Object { $_ })

    $companions = @($companion | Where-Object { $chosen -contains (Get-DefinitionApp -Definition $_) })

    [pscustomobject]@{
        PSTypeName = 'Gutcheck.AppSelection'
        Candidate  = $candidates
        Selected   = @($selected)
        Companion  = $companions
        # Definitions in Run order, with the unselected app Checks and their companions
        # dropped. Order matters: the App Kind consumes what the Stability and System
        # Checks gathered, so it has to come after them, and a companion comes after the
        # application it belongs to.
        Check      = @($other) + @($selected) + @($companions)
    }
}

function Get-DefinitionApp {
    <#
    .SYNOPSIS
        The application a Check Definition says it belongs to, or nothing. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Definition)

    $parameters = ConvertTo-ParameterHashtable (Get-DataProperty $Definition 'Parameters')
    "$(Get-Parameter $parameters 'App' '')".Trim()
}

function New-AppSelectionFinding {
    <#
    .SYNOPSIS
        Which applications this Run examined, as a Finding.
    .DESCRIPTION
        The script put this in the Report header. It is a Finding here because a Report
        read weeks later has to answer "was Outlook looked at?" without the Technician who
        ran it, and because an application that was on the machine and not selected is
        exactly the gap a second-level Technician needs to see.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)]$Selection)

    $selected = @(@($Selection.Selected) | ForEach-Object { Get-DataProperty $_ 'Name' } | Where-Object { $_ })

    $value = (Get-Text 'Value.Shared.None')
    if ($selected.Count) { $value = ($selected | Sort-Object) -join ', ' }

    # Applications this machine has that the Technician did not pick. Not a fault, but the
    # difference between "Outlook is fine" and "Outlook was never looked at".
    $selectedNames = @(@($Selection.Selected) | ForEach-Object { Get-DataProperty $_ 'Name' })
    $skipped = @(@($Selection.Candidate) |
        Where-Object { $_.Installed -or $_.Running -gt 0 } |
        Where-Object { $selectedNames -notcontains $_.CheckName } |
        ForEach-Object { $_.Name })

    $hint = ''
    if ($skipped.Count) {
        $hint = (Get-Text 'Hint.AppSelection.PresentButNotExamined') -f
            (($skipped | Sort-Object) -join ', ')
    }

    New-Finding -Category Apps -Check (Get-Text 'Check.AppSelection.ApplicationsExamined') -Severity INFO -Value $value -Hint $hint
}