Private/Write-TripwireLog.ps1

<#
.SYNOPSIS
    Structured internal logging, and the narration the guided run reads out.

.DESCRIPTION
    Detection tooling has one logging rule that matters: never write a message on a
    channel the caller cannot see and then treat it as if the caller was told. Warnings
    go to the warning stream (visible by default), everything else to verbose. Nothing
    here writes to the host, so output redirection and CI capture behave predictably.

    Write-TripwireNotice is the operator-facing half of the same rule. A guided run has to
    say what it is doing while it does it, and the obvious way to do that -- Write-Host --
    writes to a stream nothing can capture, redirect or silence. Narration goes to the
    INFORMATION stream instead: visible by default inside Start-EntraTripwire, silenced
    with -InformationAction SilentlyContinue, and captured by -InformationVariable, which
    is also how the tests assert on what the operator was told.
#>

function Write-TripwireLog {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $Message,

        [ValidateSet('Info', 'Warn', 'Debug')]
        [string] $Level = 'Info'
    )

    $stamp = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
    $line = "[$stamp] [EntraTripwire] $Message"

    switch ($Level) {
        'Warn' { Write-Warning $line }
        'Debug' { Write-Debug $line }
        default { Write-Verbose $line }
    }
}

<#
.SYNOPSIS
    The ANSI escapes the narration uses, or empty strings when colour is not wanted.

.DESCRIPTION
    Colour is decoration, never information: every line this module writes says what it
    means in words, so a terminal that strips the escapes loses nothing. The palette is
    deliberately narrow -- bold for structure, one colour each for "needs attention",
    "act on this" and "paste this" -- because a report that colours everything has told
    the reader nothing about what to look at first.

    Three ways to turn it off, all honoured: NO_COLOR (the de facto convention),
    $PSStyle.OutputRendering set to PlainText, and a host too old to have $PSStyle.
#>

function Get-TripwirePalette {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param()

    $plain = @{ Head = ''; Good = ''; Warn = ''; Bad = ''; Dim = ''; Cmd = ''; Off = '' }

    if (-not [string]::IsNullOrEmpty($env:NO_COLOR)) { return $plain }

    # Wrapped because the whole feature is cosmetic. A host that answers any of this
    # unexpectedly must degrade to plain text, never fail the run that is reporting an
    # intrusion.
    try {
        if ($null -eq $PSStyle -or $PSStyle.OutputRendering -eq 'PlainText') { return $plain }
        return @{
            Head = [string]$PSStyle.Bold
            Good = [string]$PSStyle.Foreground.Green
            Warn = [string]$PSStyle.Foreground.Yellow
            Bad  = [string]$PSStyle.Foreground.Red
            Dim  = [string]$PSStyle.Foreground.BrightBlack
            Cmd  = [string]$PSStyle.Foreground.Cyan
            Off  = [string]$PSStyle.Reset
        }
    }
    catch { return $plain }
}

<#
.SYNOPSIS
    One line of operator-facing narration, on the information stream.

.PARAMETER Kind
    Heading a section title
    Ok a check that passed
    Neutral a check that is neither pass nor problem, aligned with the markers above
    Attention something the operator should decide about
    Alert something that needs action now
    Detail supporting text, dimmed
    Command a command to copy, indented and coloured as such
    Plain no marker, no colour

.PARAMETER Indent
    Levels of two-space indentation.
#>

function Write-TripwireNotice {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [AllowEmptyString()]
        [string] $Message,

        [ValidateSet('Plain', 'Heading', 'Ok', 'Neutral', 'Attention', 'Alert', 'Detail', 'Command')]
        [string] $Kind = 'Plain',

        [ValidateRange(0, 6)]
        [int] $Indent = 0
    )

    $p = Get-TripwirePalette
    $pad = ' ' * (2 * $Indent)

    # ASCII markers on purpose. A Windows console still running a legacy code page renders
    # a tick or a cross as mojibake, and a status marker nobody can read is worse than a
    # plain one.
    $line = switch ($Kind) {
        'Heading' { "$($p.Head)$pad$Message$($p.Off)" }
        'Ok' { "$pad$($p.Good)[ ok ]$($p.Off) $Message" }
        'Neutral' { "$pad$($p.Dim)[ -- ]$($p.Off) $Message" }
        'Attention' { "$pad$($p.Warn)[ ! ]$($p.Off) $Message" }
        'Alert' { "$pad$($p.Bad)[ !! ]$($p.Off) $Message" }
        'Detail' { "$($p.Dim)$pad$Message$($p.Off)" }
        'Command' { "$pad$($p.Cmd)$Message$($p.Off)" }
        default { "$pad$Message" }
    }

    Write-Information -MessageData $line -Tags 'EntraTripwire'
}