Import-BuildStep.ps1

function Import-BuildStep
{
    <#
    .Synopsis
        Imports Build Steps
    .Description
        Imports Build Steps defined in a module.
    .Example
        Import-BuildStep -ModuleName PSDevOps
    .Link
        Convert-BuildStep
    .Link
        Expand-BuildStep
    #>

    [OutputType([Nullable])]
    [CmdletBinding(DefaultParameterSetName='Module')]
    param(
    # The name of the module containing build steps.
    [Parameter(Mandatory,ParameterSetName='Module',ValueFromPipelineByPropertyName)]
    [Alias('Name')]
    [string]
    $ModuleName,

    # The source path. This path contains definitions for a given single build system.
    [Parameter(Mandatory,ParameterSetName='SourcePath',ValueFromPipelineByPropertyName)]
    [Alias('Fullname')]
    [string]
    $SourcePath,

    # A list of commands to include.
    [Parameter(ParameterSetName='Module',ValueFromPipelineByPropertyName)]
    [string[]]
    $IncludeCommand = '*',


    # A list of commands to exclude
    [Parameter(ParameterSetName='Module',ValueFromPipelineByPropertyName)]
    [string[]]
    $ExcludeCommand,

    # The different build systems supported.
    # Each buildsystem is the name of a subdirectory that can contain steps or other components.
    [ValidateSet('ADOPipeline', 'GitHubWorkflow')]
    [string[]]
    $BuildSystem = @('ADOPipeline', 'GitHubWorkflow'),

    # A list of valid directory aliases for a given build system.
    # By default, ADOPipelines can exist within a directory named ADOPipeline, ADO, AzDO, or AzureDevOps.
    # By default, GitHubWorkflows can exist within a directory named GitHubWorkflow, GitHubWorkflows, or GitHub.
    [Alias('BuildSystemAliases')]
    [Collections.IDictionary]
    $BuildSystemAlias = $(@{
        ADOPipeline = 'ADO', 'AzDO', 'AzureDevOps'
        GitHubWorkflow = 'GitHub', 'GitHubWorkflows'
    })
    )

    begin {
        # In order to generically import steps for any given buildstep,
        # we need two collection caches, and we need them to be fast.
        if (-not $script:ComponentMetaData) {
            # The ComponentMetaData maps the name of component
            # to the metadata extracted from a file or function.
            $script:ComponentMetaData = [Collections.Generic.Dictionary[ string, Collections.Generic.Dictionary[string,PSObject] ]]::new([StringComparer]::OrdinalIgnoreCase)
            # For usability, this should be a case-insensitive map.
        }

        if (-not $script:ComponentNames) {
            # The Component names maps the name of each type of component (represented by a directory name)
            # to the full name in the component metadata.
            $script:ComponentNames = [Collections.Generic.Dictionary[ string, Collections.Generic.Dictionary[ string, Collections.Generic.List[string] ] ]]::new([StringComparer]::OrdinalIgnoreCase)
            # For usability, this should also be a case-insensitive map.
        }
    }
    process {
        if ($PSCmdlet.ParameterSetName -eq 'Module') {
            $module = # If the piped in object is a module,
                if ($_ -is [Management.Automation.PSModuleInfo]) {
                    $_ # use it directly.
                } else {
                    Get-Module $ModuleName |  # otherwise, get the loaded copies of the module
                        Select-Object -First 1 # and pick the first one.
                }
            if (-not $module) { return } # If we could not resolve the module, return.

            #region Import Module Files
            $d = [IO.DirectoryInfo][IO.Path]::GetDirectoryName($Module.Path) # Get the module root
            foreach ($id in $d.GetDirectories()) {
                $componentTypeName = $id.Name
                $resolvedBuildSystems = # Find any subdirectories that are named with a -BuildSystem
                    @(if ($BuildSystem -contains $id.Name) {
                        $id.Name
                    } else {
                        # Or named with a -BuildSystemAlias.
                        foreach ($kv in $BuildSystemAlias.GetEnumerator()) {
                            if ($kv.Value -contains $id.Name -and
                                $BuildSystem -contains $kv.Key) {
                                $kv.Key
                            }
                        }
                    })
                if ($resolvedBuildSystems) { # If any build systems resolved,
                    # Import steps from that directory
                    foreach ($rbs in $resolvedBuildSystems) {
                        # for that build system.
                        Import-BuildStep -SourcePath $id.Fullname -BuildSystem $rbs
                    }
                }
            }
            #endregion Import Module Files

            #region Import Module Commands
            # Next walk over the list of exported commands
            :nextCmd foreach ($exCmd in $Module.ExportedCommands.Values) {
                $shouldInclude = $false
                # Check to see if each should be included.
                foreach ($Inclusion in $IncludeCommand) {
                    $shouldInclude = $exCmd -like $Inclusion
                    if ($shouldInclude) { break }
                }
                if (-not $shouldInclude)  { continue }
                # Then check to see if each should be excluded.
                foreach ($exclusion in $ExcludeCommand) {
                    if ($exCmd -like $exclusion) {
                        continue nextCmd
                    }
                }
                # Then import the command into each build system as a 'Step'
                foreach ($componentTypeName in $BuildSystem) {
                    if (-not $script:ComponentNames.ContainsKey($componentTypeName)) {
                        $script:ComponentNames[$componentTypeName] =
                            [Collections.Generic.Dictionary[ string, Collections.Generic.List[string] ]]::new([StringComparer]::OrdinalIgnoreCase)

                        $script:ComponentMetaData[$componentTypeName] =
                            [Collections.Generic.Dictionary[string,PSObject]]::new([StringComparer]::OrdinalIgnoreCase)
                    }

                    $ThingNames = $ComponentNames[   $componentTypeName]
                    $ThingData  = $ComponentMetaData[$componentTypeName]
                    $t = 'Step'
                    if (-not $ThingNames.ContainsKey($t)) {
                        $ThingNames[$t] = [Collections.Generic.List[string]]::new()
                    }
                    $n = $exCmd.Name
                    if (-not $ThingNames[$t].Contains($n)) {
                        $ThingNames[$t].Add($n)
                    }

                    $ThingData["$($t).$($n)"] = [PSCustomObject][Ordered]@{
                        Name      = $n
                        Type      = $t
                        ScriptBlock = $exCmd.ScriptBlock
                        Module    = $Module
                    }
                }
            }
            #endregion Import Module Commands
        }
        elseif ($PSCmdlet.ParameterSetName -eq 'SourcePath') {
            # If we've been provided a -SourcePath, start by making sure we only have one -BuildSystem.
            if ($BuildSystem.Length -gt 1) {
                Write-Error "Can only import from a -SourcePath for one -BuildSystem at a time."
                return
            }
            $bs = $BuildSystem[0]
            $sourceItem = Get-Item $SourcePath
            if ($sourceItem -isnot [IO.DirectoryInfo]) { # If the source path isn't a directory, error out.
                Write-Error "-SourcePath must be a directory."
                return
            }
            if ($sourceItem.Name -ne $bs -and
                $sourceItem.Name -notin $BuildSystemAlias[$bs]) {
                # If the source path wasn't a _valid_ directory name, error out.
                Write-Error (@(
                    "-SourcePath must match -BuildSystem '$BuildSystem'.
                    Must be '$BuildSystem' or '$($BuildSystemAlias[$bs] -join "','")'"

                ) -join ([Environment]::NewLine))
                return
            }

            #region Import Files for a BuildSystem
            if (-not $script:ComponentNames.ContainsKey($bs)) { # Create a cash of data for this buildsystem
                $script:ComponentNames[$bs] =
                    [Collections.Generic.Dictionary[ string, Collections.Generic.List[string] ]]::new([StringComparer]::OrdinalIgnoreCase)

                $script:ComponentMetaData[$bs] =
                    [Collections.Generic.Dictionary[string,PSObject]]::new([StringComparer]::OrdinalIgnoreCase)
            }

            # Get all of the files beneath this point
            $fileList = Get-ChildItem -Filter * -Recurse -LiteralPath $sourceItem.FullName
            $ThingNames = $script:ComponentNames[   $bs]
            $ThingData  = $script:ComponentMetaData[$bs]
            foreach ($f in $fileList) {
                if ($f.Directory -eq $rootDir) { continue } # Skip all files more than one level down.
                if ($f -is [IO.DirectoryInfo]) { continue } # Skip all directories.
                $n = $f.Name.Substring(0, $f.Name.Length - $f.Extension.Length)
                $t = $f.Directory.Name.TrimEnd('s') # Depluralize the directory name.
                if (-not $ThingNames.ContainsKey($t)) {
                    $ThingNames[$t] = [Collections.Generic.List[string]]::new()
                }
                if (-not $ThingNames[$t].Contains($n)) {
                    $ThingNames[$t].Add($n)
                }

                # Make a collection of metadata for the thing, and store it by it's disambiguated name.
                $ThingData["$($t).$($n)"] = [PSCustomObject][Ordered]@{
                    Name      = $n
                    Type      = $t
                    Extension = $f.Extension
                    Path      = $f.FullName
                }
            }
            #endregion Import Files for a BuildSystem
        }

    }
}