PowerConfig.psm1

using namespace Microsoft.Extensions.Configuration
using namespace Microsoft.Extensions.Configuration.Memory
using namespace System.Collections
using namespace System.Collections.Generic
try {
    Update-TypeData -Erroraction Stop -TypeName Microsoft.Extensions.Configuration.ConfigurationBuilder -MemberName AddYamlFile -MemberType ScriptMethod -Value {
        param([String]$Path)
        [Microsoft.Extensions.Configuration.YamlConfigurationExtensions]::AddYamlFile($this, $Path)
    }
} catch {
    if ([String]$PSItem -match 'The member .+ is already present') {
        Write-Verbose "Extension Method already present"
        $return
    }
    #Write-Error $PSItem.exception
}

try {
    Update-TypeData -Erroraction Stop -TypeName Microsoft.Extensions.Configuration.ConfigurationBuilder -MemberName AddJsonFile -MemberType ScriptMethod -Value {
        param([String]$Path)
        [Microsoft.Extensions.Configuration.JsonConfigurationExtensions]::AddJsonFile($this, $Path)
    }
} catch {
    if ([String]$PSItem -match 'The member .+ is already present') {
        Write-Verbose "Extension Method already present"
        $return
    }
    #Write-Error $PSItem.exception
}

#Taken with love from https://github.com/austoonz/Convert/blob/master/src/Convert/Public/ConvertFrom-StringToMemoryStream.ps1


function ConvertFrom-StringToMemoryStream
{
<#
    .SYNOPSIS
        Converts a string to a MemoryStream object.
    .DESCRIPTION
        Converts a string to a MemoryStream object.
    .PARAMETER String
        A string object for conversion.
    .PARAMETER Encoding
        The encoding to use for conversion.
        Defaults to UTF8.
        Valid options are ASCII, BigEndianUnicode, Default, Unicode, UTF32, UTF7, and UTF8.
    .PARAMETER Compress
        If supplied, the output will be compressed using Gzip.
    .EXAMPLE
        $stream = ConvertFrom-StringToMemoryStream -String 'A string'
        $stream.GetType()
        IsPublic IsSerial Name BaseType
        -------- -------- ---- --------
        True True MemoryStream System.IO.Stream
    .EXAMPLE
        $stream = 'A string' | ConvertFrom-StringToMemoryStream
        $stream.GetType()
        IsPublic IsSerial Name BaseType
        -------- -------- ---- --------
        True True MemoryStream System.IO.Stream
    .EXAMPLE
        $streams = ConvertFrom-StringToMemoryStream -String 'A string','Another string'
        $streams.GetType()
        IsPublic IsSerial Name BaseType
        -------- -------- ---- --------
        True True Object[] System.Array
        $streams[0].GetType()
        IsPublic IsSerial Name BaseType
        -------- -------- ---- --------
        True True MemoryStream System.IO.Stream
    .EXAMPLE
        $streams = 'A string','Another string' | ConvertFrom-StringToMemoryStream
        $streams.GetType()
        IsPublic IsSerial Name BaseType
        -------- -------- ---- --------
        True True Object[] System.Array
        $streams[0].GetType()
        IsPublic IsSerial Name BaseType
        -------- -------- ---- --------
        True True MemoryStream System.IO.Stream
    .EXAMPLE
        $stream = ConvertFrom-StringToMemoryStream -String 'This string has two string values'
        $stream.Length
        33
        $stream = ConvertFrom-StringToMemoryStream -String 'This string has two string values' -Compress
        $stream.Length
        10
    .OUTPUTS
        [System.IO.MemoryStream[]]
    .LINK
        http://convert.readthedocs.io/en/latest/functions/ConvertFrom-StringToMemoryStream/
#>

    [CmdletBinding(HelpUri = 'http://convert.readthedocs.io/en/latest/functions/ConvertFrom-StringToMemoryStream/')]
    param
    (
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [String[]]
        $String,

        [ValidateSet('ASCII', 'BigEndianUnicode', 'Default', 'Unicode', 'UTF32', 'UTF7', 'UTF8')]
        [String]
        $Encoding = 'UTF8',

        [Switch]
        $Compress
    )

    begin
    {
        $userErrorActionPreference = $ErrorActionPreference
    }

    process
    {
        foreach ($s in $String)
        {
            try
            {
                [System.IO.MemoryStream]$stream = [System.IO.MemoryStream]::new()
                if ($Compress)
                {
                    $byteArray = [System.Text.Encoding]::$Encoding.GetBytes($s)
                    $gzipStream = [System.IO.Compression.GzipStream]::new($stream, ([IO.Compression.CompressionMode]::Compress))
                    $gzipStream.Write( $byteArray, 0, $byteArray.Length )
                }
                else
                {
                    $writer = [System.IO.StreamWriter]::new($stream)
                    $writer.Write($s)
                    $writer.Flush()
                }
                $stream
            }
            catch
            {
                Write-Error -ErrorRecord $_ -ErrorAction $userErrorActionPreference
            }
        }
    }
}
function ConvertTo-Dictionary {
    [CmdletBinding()]
    param (
        [System.Collections.HashTable]$Hashtable
    )
    #Make a string dictionary that the memorycollection requires
    $dictionary = [System.Collections.Generic.Dictionary[String,String]]::new()

    #Take the hashtable values and import them into the dictionary
    $hashtable.keys.foreach{
        $null = $Dictionary.Add($PSItem,$HashTable[$PSItem])
    }

    return $dictionary
}
#Big Thanks to IISResetMe: https://gist.github.com/IISResetMe/2fdb0c7097545b4c86ddf60fe7fb5056#file-flatten-ps1-L5
function ConvertTo-FlatDictionary {
    [CmdletBinding()]
    param(
        [IDictionary]$Dictionary,
        [string]$KeyDelimiter = ':'
    )

    $newDict = @{}

    $stackOfTrees = [Stack]::new()
    foreach($kvp in $Dictionary.GetEnumerator()){
        $stackOfTrees.Push(@($kvp.Key,$kvp.Value))
    }

    while($stackOfTrees.Count -gt 0)
    {
        $prefix,$next = $stackOfTrees.Pop()
        if($next -is [IDictionary]){
            foreach($kvp in $next.GetEnumerator())
            {
                $stackOfTrees.Push(@("${prefix}${KeyDelimiter}$($kvp.Key)", $kvp.Value))
            }
        }
        else {
            $newDict["${prefix}"] = $next
        }
    }

    return $newDict
}
<#
.SYNOPSIS
Takes an enumerable keyvaluepair from Microsoft.Extensions.Configuration and converts it to a nested hashtable
#>


function ConvertTo-NestedHashTable {
    [CmdletBinding()]
    param (
        [Collections.Generic.KeyValuePair[String,String][]]$InputObject
    )

    #First group the entries by hierarchy
    $depthGroups = $InputObject | Group-Object {
        $PSItem.key.split(':').count
    }
    $result = [ordered]@{}

    foreach ($DepthItem in ($DepthGroups | Sort-Object Name)) {
        foreach ($ConfigItem in ($DepthItem.Group)) {
            $ConfigItemLevels = $ConfigItem.key.split(':')
            #Iterate through the levels and create them if not already present
            $lastLevel = $result
            For ($i=0;$i -lt ($ConfigItemLevels.count -1);$i++) {
                if ($lastLevel[$ConfigItemLevels[$i]] -isnot [System.Collections.Specialized.OrderedDictionary]) {
                    $lastLevel[$ConfigItemLevels[$i]] = [ordered]@{}
                }
                #Step up to the new level for the next activity
                $lastLevel = $lastLevel[$ConfigItemLevels[$i]]
            }

            #Assign the value now that the levels have been created
            $valueKey = $ConfigItemLevels[($ConfigItemLevels.count -1)]
            $lastLevel.$valueKey = $ConfigItem.Value
        }
        #Emit the result before foreach cleanup
    }

    return $result
}
function Add-PowerConfigCommandLineSource {
    [CmdletBinding(PositionalBinding=$false)]
    param (
        #The PowerConfig object to operate on
        [Microsoft.Extensions.Configuration.ConfigurationBuilder][Parameter(Mandatory,ValueFromPipeline)]$InputObject,
        # A hashtable that remaps arguments to their intented destination, for instance @{'-f'='force'} remaps the shorthand -f to the force key
        [HashTable]$ArgumentMap,
        #The arguments that were passed to your script. You can pass the arguments directly to this script, or supply them as a variable similar to $args (an array of strings, one statement per string)
        [Parameter(Mandatory,ValueFromRemainingArguments)]$ArgumentList
    )

    #Couldn't cast a hashtable directly because it was seeing it as new properties, so here is a workaround
    $ArgumentMapDictionary = [Collections.Generic.Dictionary[String,String]]::new()
    $ArgumentMap.keys.foreach{
        $ArgumentMapDictionary[$PSItem] = $ArgumentMap[$PSItem]
    }

    [CommandLineConfigurationExtensions]::AddCommandLine($InputObject, $ArgumentList, $ArgumentMapDictionary)
}
function Add-PowerConfigEnvironmentVariableSource {
    [CmdletBinding()]
    param (
        #The PowerConfig object to operate on
        [Microsoft.Extensions.Configuration.ConfigurationBuilder][Parameter(Mandatory,ValueFromPipeline)]$InputObject,
        #The prefix for your environment variables. Default is no prefix
        [String]$Prefix = ''
    )

    [EnvironmentVariablesExtensions]::AddEnvironmentVariables($InputObject, $Prefix)
}

# Powershell 5.1 using namespace doesn't work with classes unfortunately

class HashTableConfigurationProvider : Microsoft.Extensions.Configuration.ConfigurationProvider {
    hidden [HashTableConfigurationSource]$source

    HashTableConfigurationProvider ($source) {
        $flatHashTable = ConvertTo-FlatDictionary $source.hashtable
        $flatHashTable.GetEnumerator().Foreach{
            $this.Set($PSItem.Name, $PSItem.Value)
        }
    }
}

class HashTableConfigurationSource : Microsoft.Extensions.Configuration.IConfigurationSource {
    #The hashtable reference that will be used for the memoryconfigsource
    [hashtable]$hashtable
    HashTableConfigurationSource ([hashtable]$hashtable) {
        $this.hashtable = $hashtable
    }

    [Microsoft.Extensions.Configuration.IConfigurationProvider] Build([Microsoft.Extensions.Configuration.IConfigurationBuilder]$builder) {
        return [HashTableConfigurationProvider]::new($this)
    }
}

function Add-PowerConfigHashTable {
    [CmdletBinding()]
    param (
        #The PowerConfig object to operate on
        [Microsoft.Extensions.Configuration.ConfigurationBuilder][Parameter(Mandatory,ValueFromPipeline)]$InputObject,
        #The hashtable to add to your configuration values. Use colons (:) to separate sections of configuration.
        [Parameter(Mandatory,Position=0)][Hashtable]$Object
    )

    $InputObject.Add(
        [HashTableConfigurationSource]::new($Object)
    )
}
function Add-PowerConfigJsonSource {
    [CmdletBinding()]
    param (
        #The PowerConfig object to operate on
        [Microsoft.Extensions.Configuration.ConfigurationBuilder][Parameter(Mandatory,ValueFromPipeline)]$InputObject,
        #The prefix for your environment variables. Default is no prefix
        [Parameter(Mandatory)]$Path,
        #Specify this parameter if the configuration file is mandatory. PowerConfig will show an error if this file is not present.
        [Switch]$Mandatory,
        #By default, if the file changes the configuration will automatically be updated. If you want to disable this behavior, specify this parameter.
        [Switch]$NoRefresh
    )

    [JsonConfigurationExtensions]::AddJsonFile($InputObject, $Path, !$Mandatory, !$NoRefresh)
}
function Add-PowerConfigObject {
    [CmdletBinding()]
    param (
        #The PowerConfig object to operate on
        [Microsoft.Extensions.Configuration.ConfigurationBuilder][Parameter(Mandatory,ValueFromPipeline)]$InputObject,
        #The hashtable to add to your configuration values. Use colons (:) to separate sections of configuration
        [Parameter(Mandatory)][Object]$Object,
        #How deep to go on nested properties. You should normally not touch this and instead filter your inputs first
        $Depth = 5,
        #Optional path to save the converted Json. This is normally a temporary file and you shouldn't need to change this.
        $JsonTempFile = [io.path]::GetTempFileName()
    )

    $WarningPreference = 'SilentlyContinue'
    $ObjectJson = $Object | ConvertTo-Json -Compress -ErrorAction Stop | Out-File -FilePath $JsonTempFile
    [JsonConfigurationExtensions]::AddJsonFile($InputObject,$JsonTempFile)

    #TODO: Use the stream method when we can bump to Configuration Extensions 3.0
    #$JsonStream = ConvertFrom-StringToMemoryStream $ObjectJson
    #[JsonConfigurationExtensions]::AddJsonStream($InputObject,$JsonStream)
}
function Add-PowerConfigTomlSource {
    [CmdletBinding()]
    param (
        #The PowerConfig object to operate on
        [Microsoft.Extensions.Configuration.ConfigurationBuilder][Parameter(Mandatory,ValueFromPipeline)]$InputObject,
        #The prefix for your environment variables. Default is no prefix
        [Parameter(Mandatory)]$Path,
        #Specify this parameter if the configuration file is mandatory. PowerConfig will show an error if this file is not present.
        [Switch]$Mandatory,
        #By default, if the file changes the configuration will automatically be updated. If you want to disable this behavior, specify this parameter.
        [Switch]$NoRefresh
    )

    [TomlConfigurationExtensions]::AddTomlFile($InputObject, $Path, !$Mandatory, !$NoRefresh)
}
function Add-PowerConfigYamlSource {
    [CmdletBinding()]
    param (
        #The PowerConfig object to operate on
        [Microsoft.Extensions.Configuration.ConfigurationBuilder][Parameter(Mandatory,ValueFromPipeline)]$InputObject,
        #The prefix for your environment variables. Default is no prefix
        [Parameter(Mandatory)]$Path,
        #Specify this parameter if the configuration file is mandatory. PowerConfig will show an error if this file is not present.
        [Switch]$Mandatory,
        #By default, if the file changes the configuration will automatically be updated. If you want to disable this behavior, specify this parameter.
        [Switch]$NoRefresh
    )

    [YamlConfigurationExtensions]::AddYamlFile($InputObject, $Path, !$Mandatory, !$NoRefresh)
}
function Get-PowerConfig {
    param (
        [Microsoft.Extensions.Configuration.ConfigurationBuilder][Parameter(Mandatory,ValueFromPipeline)]$InputObject
    )

    $RenderedPowerConfig = $InputObject.build()
    ConvertTo-NestedHashTable ([ConfigurationExtensions]::AsEnumerable($RenderedPowerConfig))
}

<#
.SYNOPSIS
    Create a new Powerconfig Object
#>

function New-PowerConfig {
    [CmdletBinding()]
    param()

    [ConfigurationBuilder]::new()
}
#This is needed because assemblies must be loaded before classes that reference them in Windows Powershell 5.1
#It should be referenced from "ScriptsToProcess" in the manifest file

#PSES on Windows 5.1 is currently unsupported
try {
    if ([Microsoft.Extensions.Configuration.ConfigurationBuilder].Assembly.Location -match 'PowershellEditorServices') {
        throw [NotSupportedException]'Sorry, PowerConfig is currently not supported if Powershell Editor Services is loaded on Windows Powershell due to a conflict. See: https://github.com/PowerShell/PowerShellEditorServices/issues/1499'
    }
} catch {
    if ($PSItem.FullyQualifiedErrorId -ne 'TypeNotFound') {throw}
}

<#
.SYNOPSIS
Used to add automatic binding redirection to related modules for Powerconfig to redirect CompilerServices to the net5.0 assembly version
.NOTES
CompilerServices.Unsafe won't load without this
Reference: https://github.com/PowerShell/PowerShellStandard/issues/72
#>

if ($PSEdition -ne 'Desktop') {
    $bindingRedirectHandler = [ResolveEventHandler] {
        param($sender, $assembly)
        try {
            Write-Debug "BindingRedirectHandler: Resolving $($assembly.name)"
            #Skip Powershell Assemblies
            if ($assembly.name -like '*Management.Automation*') { return $null }
            $assemblyShortName = $assembly.name.split(',')[0]
            $matchingAssembly = [AppDomain]::CurrentDomain.GetAssemblies() |
                Where-Object fullname -Match ('^' + [Regex]::Escape($assemblyShortName))
            if ($matchingAssembly.count -eq 1) {
                Write-Debug "BindingRedirectHandler: Redirecting $($assembly.name) to $($matchingAssembly.Location)"
                return $MatchingAssembly
            }
        } catch {
            #Write-Error will blackhole, which is why write-host is required. This should never occur so it should be a red flag
            Write-Host -fore red "BindingRedirectHandler ERROR: $PSITEM"
            return $null
        }
        return $null
    }
    [Appdomain]::CurrentDomain.Add_AssemblyResolve($bindingRedirectHandler)
}

$libroot = "$PSScriptRoot/../lib"

#If this is a "debug build", use the assemblies from buildoutput
$debugLibPath = "$PSScriptRoot/../../BuildOutput/PowerConfig/lib"
if (Test-Path $debugLibPath) {
    $libroot = Resolve-Path $debugLibPath
}

$libPath = Resolve-Path $(
    if ($PSEdition -eq 'Desktop') {
        "$libroot/winps"
    } else {
        "$libroot/pwsh"
    }
)
Write-Verbose "Loading PowerConfig Assemblies from $libPath"
Add-Type -Path "$libPath/*.dll"


# if ('AddYamlFile' -notin (get-typedata "Microsoft.Extensions.Configuration.ConfigurationBuilder").members.keys) {
# Update-TypeData -TypeName Microsoft.Extensions.Configuration.ConfigurationBuilder -MemberName AddYamlFile -MemberType ScriptMethod -Value {
# param([String]$Path)
# [Microsoft.Extensions.Configuration.YamlConfigurationExtensions]::AddYamlFile($this, $Path)
# }
# }