Public/Import-HDTSequenceDocument.ps1

function Import-HDTSequenceDocument {
    <#
        .SYNOPSIS
            Reads, parses, validates and flattens a sequence.yaml into execution
            order.
 
        .DESCRIPTION
            The public front door to sequence.yaml. It reads the
            file through an injected IFileSystem - never Get-Content - so the
            whole authoring path is provable under Pester with no share, no media
            and no disk.
 
            Four steps, and a failure at any of them is a terminating
            HDTConfigurationError naming the file:
 
              1. read through IFileSystem;
              2. parse with ConvertFrom-HDTYaml, which turns a parser exception
                 into an error naming the file and the LINE;
              3. validate with Assert-HDTSequenceDocument, which names the file
                 and the STEP or GROUP;
              4. flatten into execution order.
 
            FLATTENING IS THE DESIGN DECISION HERE. A group is not an execution
            unit; it is a naming and condition device. Flattening nested groups
            into one linear, 1-based list means "skips completed
            steps by index" works unchanged with nesting, and a group whose
            condition is false produces one step.skip record per contained step
            naming the group - which is what a technician reading a log needs,
            rather than a single line that hides six steps.
 
            Each flattened step carries:
 
              Index 1-based position in execution order
              Name, Type from the document
              GroupPath [string[]], outermost first, @() at the root
              Condition the step's own condition, or $null
              GroupCondition [object[]] of Group/Condition, outermost first,
                               only for ancestors that declare one
              ContinueOnError [bool], default $false
              Disabled [bool], default $false - the step is skipped
                               without being removed. A step inside a disabled
                               group is disabled whatever it says about itself
              TimeoutMinutes [int], 0 = unbounded, default 0
              RunIn WinPE | FullOS | Any; a step with none inherits its
                               nearest ancestor group's runIn, default Any
              Retry Count / DelaySecond / Backoff, default 0/0/fixed
              Resumable [bool], default $false - an interrupted step is
                               re-run only if it declares resumable
              Log a per-step log file name, or $null
              Property ordered, case-insensitive: every key that is NOT a
                               common property, i.e. the step type's own arguments
 
            AN EMPTY GROUP CONTRIBUTES NO STEPS AND CONSUMES NO INDEX. A group
            an administrator has named but not yet filled is legal - it is what
            the editor's New Group button creates - and flattening it yields
            nothing at all, so the 1-based numbering runs straight through it. It
            is still recorded in Group, with its condition, because it is a real
            part of the document that an editor has to be able to show. Its
            condition therefore applies to no step, which is pointless rather
            than an error, and nothing here throws over it.
 
            A GROUP RECORDS HOW FAR INTO THE ORDER IT OPENED, in AfterStep: the
            number of steps flattened before it. Every other consumer finds a
            group through the GroupPath of a step inside it, which an empty group
            has none of - so without this an empty group could be listed but not
            placed, and a tree drawn from the step list alone would silently omit
            it.
 
            A NODE IS A GROUP WHEN IT DECLARES steps. The reference ApplyDrivers
            step carries `group: "%HDTDriverGroup%"` as a type-specific property,
            so `group` cannot be the discriminator - it stays in Property.
 
            STEP TYPES ARE NOT RESOLVED HERE. A sequence referencing a type this
            engine does not implement still imports, because a workspace's
            Modules\ may carry it and the authoring machine may not.
 
        .PARAMETER Path
            The sequence.yaml to read. Interpreted by the filesystem service, so
            it may be a share path, a media path or a fake's in-memory path.
 
        .PARAMETER FileSystem
            An IFileSystem - the real adapter in production,
            New-HDTFakeFileSystem in a test.
 
        .OUTPUTS
            System.Management.Automation.PSCustomObject:
 
              Path, SchemaVersion, Id, Name, Description
              Variable ordered, case-insensitive: the sequence defaults
              Step [object[]] flattened into execution order
              Group [object[]] one per group node: Path, Condition, RunIn,
                        Disabled, AfterStep
 
        .EXAMPLE
            $sequence = Import-HDTSequenceDocument -Path 'X:\Deploy\Sequences\STD-CLIENT\sequence.yaml' -FileSystem (New-HDTFileSystem)
            $sequence.Step | Format-Table Index, Name, Type, RunIn
 
        .EXAMPLE
            $fs = New-HDTFakeFileSystem -File @{ 'C:\ws\sequence.yaml' = $text }
            Import-HDTSequenceDocument -Path 'C:\ws\sequence.yaml' -FileSystem $fs
 
            The same call in a test, with no file on disk anywhere.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string] $Path,

        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [object] $FileSystem
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    if (-not $FileSystem.TestPath($Path)) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'the sequence file does not exist. Every sequence is a sequence.yaml under the workspace Sequences directory.' `
                    -Category ObjectNotFound))
    }

    $text = $FileSystem.ReadAllText($Path)

    $document = ConvertFrom-HDTYaml -Yaml $text -Path $Path
    Assert-HDTSequenceDocument -Document $document -Path $Path

    # The common properties. Everything else on a step node is that step type's
    # own argument and goes into Property untouched.
    $commonKey = @('name', 'type', 'condition', 'continueOnError', 'disabled', 'timeoutMinutes', 'runIn', 'retry', 'resumable', 'log')

    $variable = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::OrdinalIgnoreCase)
    if ($document.Contains('variables')) {
        foreach ($key in @($document['variables'].Keys)) {
            $variable[[string] $key] = $document['variables'][$key]
        }
    }

    $description = $null
    if ($document.Contains('description')) {
        $description = [string] $document['description']
    }

    # WHICH FOLDER THE CONSOLE DRAWS IT UNDER, and nothing else reads this.
    # Deployment Workbench's folders are real directories under Control\; HDT's
    # cannot be, because the folder would then be part of the path the engine
    # resolves an id from - and moving a sequence between folders would break
    # every rule, boot image and half-finished deployment that names it.
    #
    # SO IT IS A LABEL ON THE DOCUMENT, not a place it lives. '' rather than
    # $null: every sequence ever written has no folder, and the absence has to
    # be ordinary rather than something each caller guards against.
    $folder = ''
    if ($document.Contains('folder')) {
        $folder = [string] $document['folder']
    }

    $step = New-Object -TypeName System.Collections.ArrayList
    $group = New-Object -TypeName System.Collections.ArrayList

    # A depth-first preorder walk with an explicit stack. Children are pushed in
    # reverse so they pop in document order, which is what makes Index the
    # execution order rather than an arbitrary traversal order.
    $stack = New-Object -TypeName System.Collections.Stack
    $rootNode = @($document['steps'])
    for ($position = $rootNode.Count - 1; $position -ge 0; $position--) {
        $stack.Push([pscustomobject] @{
                Node           = $rootNode[$position]
                GroupPath      = [string[]] @()
                GroupCondition = [object[]] @()
                RunIn          = 'Any'
                Disabled       = $false
            })
    }

    $index = 0

    while ($stack.Count -gt 0) {
        $frame = $stack.Pop()
        $node = $frame.Node

        if ($node.Contains('steps')) {
            $groupName = [string] $node['group']
            $groupPath = [string[]] (@($frame.GroupPath) + $groupName)

            $groupCondition = $null
            if ($node.Contains('condition')) {
                $groupCondition = [string] $node['condition']
            }

            $inheritedCondition = [object[]] @($frame.GroupCondition)
            if (-not [string]::IsNullOrWhiteSpace($groupCondition)) {
                $inheritedCondition = [object[]] (@($frame.GroupCondition) + [pscustomobject] @{
                        Group     = $groupName
                        Condition = $groupCondition
                    })
            }

            $groupRunIn = [string] $frame.RunIn
            if ($node.Contains('runIn')) {
                $groupRunIn = [string] $node['runIn']
            }

            # A DISABLED GROUP DISABLES EVERYTHING UNDER IT, and a group inside a
            # disabled group cannot switch itself back on. Turning off a group of
            # six is one edit, and re-enabling one step inside it by hand would be
            # a document that says two contradictory things.
            $groupDisabled = [bool] $frame.Disabled
            if (-not $groupDisabled -and $node.Contains('disabled')) {
                $groupDisabled = [bool] $node['disabled']
            }

            [void] $group.Add([pscustomobject] @{
                    Path      = $groupPath
                    Condition = $groupCondition
                    RunIn     = $groupRunIn
                    Disabled  = $groupDisabled

                    # Where this group opened, measured in steps already
                    # flattened. Non-decreasing down the list because the walk is
                    # preorder, which is what lets a caller merge Group and Step
                    # back into one ordered tree.
                    AfterStep = $index
                })

            # `steps:` WITH NOTHING UNDER IT PARSES TO NULL, not to an empty
            # list, and @($null) is a one-element array holding a null - which
            # would be walked as if it were a node. An empty group has no
            # children whichever way it was spelled.
            $childNode = @()
            if ($null -ne $node['steps']) {
                $childNode = @($node['steps'])
            }

            for ($position = $childNode.Count - 1; $position -ge 0; $position--) {
                $stack.Push([pscustomobject] @{
                        Node           = $childNode[$position]
                        GroupPath      = $groupPath
                        GroupCondition = $inheritedCondition
                        RunIn          = $groupRunIn
                        Disabled       = $groupDisabled
                    })
            }

            continue
        }

        $index++

        $condition = $null
        if ($node.Contains('condition')) {
            $condition = [string] $node['condition']
        }

        $continueOnError = $false
        if ($node.Contains('continueOnError')) {
            $continueOnError = [bool] $node['continueOnError']
        }

        # ABSENT MEANS ENABLED, and a step inside a disabled group is disabled
        # whatever it says about itself. Every sequence written before this key
        # existed has no 'disabled' anywhere in it, and all of their steps must
        # still run.
        $disabled = [bool] $frame.Disabled
        if (-not $disabled -and $node.Contains('disabled')) {
            $disabled = [bool] $node['disabled']
        }

        $resumable = $false
        if ($node.Contains('resumable')) {
            $resumable = [bool] $node['resumable']
        }

        $timeoutMinutes = 0
        if ($node.Contains('timeoutMinutes')) {
            $timeoutMinutes = [int] $node['timeoutMinutes']
        }

        # A step with no runIn inherits its nearest ancestor group's, which the
        # frame already carries; declaring one overrides that inheritance.
        $runIn = [string] $frame.RunIn
        if ($node.Contains('runIn')) {
            $runIn = [string] $node['runIn']
        }

        $log = $null
        if ($node.Contains('log')) {
            $log = [string] $node['log']
        }

        # A pscustomobject rather than a hashtable: on a hashtable, .Count is
        # ICollection.Count - the number of keys - and would silently shadow the
        # retry count the loop reads.
        $retryCount = 0
        $retryDelay = 0
        $retryBackoff = 'fixed'
        if ($node.Contains('retry')) {
            $retry = $node['retry']
            if ($retry.Contains('count')) { $retryCount = [int] $retry['count'] }
            if ($retry.Contains('delaySeconds')) { $retryDelay = [int] $retry['delaySeconds'] }
            if ($retry.Contains('backoff')) { $retryBackoff = [string] $retry['backoff'] }
        }

        $property = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($key in @($node.Keys)) {
            if ($commonKey -notcontains [string] $key) {
                $property[[string] $key] = $node[$key]
            }
        }

        [void] $step.Add([pscustomobject] @{
                Index           = $index
                Name            = [string] $node['name']
                Type            = [string] $node['type']
                GroupPath       = [string[]] @($frame.GroupPath)
                Condition       = $condition
                GroupCondition  = [object[]] @($frame.GroupCondition)
                ContinueOnError = $continueOnError
                Disabled        = $disabled
                TimeoutMinutes  = $timeoutMinutes
                RunIn           = $runIn
                Retry           = [pscustomobject] @{
                    Count       = $retryCount
                    DelaySecond = $retryDelay
                    Backoff     = $retryBackoff
                }
                Resumable       = $resumable
                Log             = $log
                Property        = $property
            })
    }

    return [pscustomobject] @{
        Path          = $Path
        SchemaVersion = [int] $document['schemaVersion']
        Id            = [string] $document['id']
        Name          = [string] $document['name']
        Description   = $description
        Folder        = $folder
        Variable      = $variable
        Step          = [object[]] @($step)
        Group         = [object[]] @($group)
    }
}