DSCResources/IniFile/IniFile.psm1

#Require -Version 5.0

using namespace System.Collections.Specialized

# Import CommonHelper
$script:dscResourcesFolderFilePath = Split-Path $PSScriptRoot -Parent
$script:commonHelperFilePath = Join-Path -Path $script:dscResourcesFolderFilePath -ChildPath 'CommonHelper.psm1'
Import-Module -Name $script:commonHelperFilePath


Enum Ensure {
    Absent
    Present
}

Enum Encoding {
    Default
    utf8
    utf8NoBOM
    utf8BOM
    utf32
    unicode
    bigendianunicode
    ascii
}


function Get-TargetResource {
    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable])]
    param
    (
        [Parameter()]
        [ValidateSet('Present', 'Absent')]
        [string]
        $Ensure = 'Present',

        [Parameter(Mandatory = $true)]
        [string]
        $Path,

        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]
        $Key,

        [Parameter()]
        [AllowEmptyString()]
        [string]
        $Value = '',

        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]
        $Section = '_ROOT_',

        [Parameter()]
        [string]
        [ValidateSet('utf8', 'utf8NoBOM', 'utf8BOM', 'utf32', 'unicode', 'bigendianunicode', 'ascii', 'Default')]
        $Encoding = 'utf8NoBOM',

        [Parameter()]
        [ValidateSet('CRLF', 'LF')]
        [string]
        $NewLine = 'CRLF'
    )

    if (-not $Section) { $Section = '_ROOT_' }
    [string]$tmpValue = ''

    # check file exists
    if (-not (Test-Path $Path -PathType Leaf)) {
        Write-Verbose ('File "{0}" not found.' -f $Path)
        $Ensure = [Ensure]::Absent
    }
    else {
        #Load ini file
        $Ini = Get-IniFile -Path $Path -Encoding $Encoding

        # if $key is empty, only check section
        if (-not $Key) {
            if ($Ini.$Section) {
                Write-Verbose ('Desired Section found ([{0}])' -f $Section)
                $Ensure = [Ensure]::Present
            }
            else {
                Write-Verbose ('Desired Section NOT found ([{0}])' -f $Section)
                $Ensure = [Ensure]::Absent
            }
        }
        # check section and key exists
        elseif ($Ini.$Section.Contains($Key)) {
            # check value
            Write-Verbose ('Current KVP (Key:"{0}"; Value:"{1}"; Section:"{2}")' -f $Key, $tmpValue, $Section)
            $Ensure = [Ensure]::Present
            $tmpValue = $Ini.$Section.$Key
        }
        else {
            Write-Verbose ('Desired Key or Section not found.')
            $Ensure = [Ensure]::Absent
        }
    }

    $returnValue = @{
        Ensure  = $Ensure
        Path    = $Path
        Key     = $Key
        Value   = $tmpValue
        Section = $PSBoundParameters.Section
    }

    $returnValue
} # end of Get-TargetResource


function Set-TargetResource {
    [CmdletBinding()]
    param
    (
        [Parameter()]
        [ValidateSet('Present', 'Absent')]
        [string]
        $Ensure = 'Present',

        [Parameter(Mandatory = $true)]
        [string]
        $Path,

        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]
        $Key,

        [Parameter()]
        [AllowEmptyString()]
        [string]
        $Value = '',

        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]
        $Section = '_ROOT_',

        [Parameter()]
        [ValidateSet('utf8', 'utf8NoBOM', 'utf8BOM', 'utf32', 'unicode', 'bigendianunicode', 'ascii', 'Default')]
        [string]
        $Encoding = 'utf8NoBOM',

        [Parameter()]
        [ValidateSet('CRLF', 'LF')]
        [string]
        $NewLine = 'CRLF'
    )

    $PSEncoder = Get-PSEncoding -Encoding $Encoding

    if (-not $Section) { $Section = '_ROOT_' }

    # Ensure = 'Absent'
    if ($Ensure -eq [Ensure]::Absent) {
        if (Test-Path $Path) {
            Write-Verbose ("Remove Key:{0}; Section:{1} from '{2}'" -f $Key, $Section, $Path)
            $content = Get-IniFile -Path $Path -Encoding $Encoding | Remove-IniKey -Key $Key -Section $Section -PassThru | ConvertTo-IniString

            #Output Ini file
            if (('utf8', 'utf8NoBOM') -eq $Encoding) {
                $content | Out-String | Convert-NewLine -NewLine $NewLine | ForEach-Object { [System.Text.Encoding]::UTF8.GetBytes($_) } | Set-Content -Path $Path -Encoding Byte -NoNewline -Force
            }
            else {
                $content | Out-String | Convert-NewLine -NewLine $NewLine | Set-Content -Path $Path -Encoding $PSEncoder -NoNewline -Force
            }
        }
    }
    else {
        # Ensure = 'Present'
        $Ini = [ordered]@{ }
        if (Test-Path $Path) {
            $Ini = Get-IniFile -Path $Path -Encoding $Encoding
        }
        else {
            Write-Verbose ("Create new file '{0}'" -f $Path)
            New-Item $Path -ItemType File -Force
        }
        $content = $Ini | Set-IniKey -Key $Key -Value $Value -Section $Section -PassThru | ConvertTo-IniString

        #Output Ini file
        if (('utf8', 'utf8NoBOM') -eq $Encoding) {
            $content | Out-String | Convert-NewLine -NewLine $NewLine | ForEach-Object { [System.Text.Encoding]::UTF8.GetBytes($_) } | Set-Content -Path $Path -Encoding Byte -NoNewline -Force
        }
        else {
            $content | Out-String | Convert-NewLine -NewLine $NewLine | Set-Content -Path $Path -Encoding $PSEncoder -NoNewline -Force
        }
    }
} # end of Set-TargetResource


function Test-TargetResource {
    [CmdletBinding()]
    [OutputType([bool])]
    param
    (
        [Parameter()]
        [ValidateSet('Present', 'Absent')]
        [string]
        $Ensure = 'Present',

        [Parameter(Mandatory = $true)]
        [string]
        $Path,

        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]
        $Key,

        [Parameter()]
        [AllowEmptyString()]
        [string]
        $Value = '',

        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]
        $Section = '_ROOT_',

        [Parameter()]
        [ValidateSet('utf8', 'utf8NoBOM', 'utf8BOM', 'utf32', 'unicode', 'bigendianunicode', 'ascii', 'Default')]
        [string]
        $Encoding = 'utf8NoBOM',

        [Parameter()]
        [ValidateSet('CRLF', 'LF')]
        [string]
        $NewLine = 'CRLF'
    )

    if (-not $Section) { $Section = '_ROOT_' }

    $Ret = ($Ensure -eq [Ensure]::Present)


    if (-not (Test-Path -Path $Path -PathType Leaf)) {
        $Ret = !$Ret
    }
    else {
        $ini = Get-IniFile -Path $Path -Encoding $Encoding

        if ($ini.$Section) {
            # if $key is empty, only check whether section is exist or not
            if (-not $Key) {
                $Ret = $Ret
            }
            elseif ($ini.$Section.Contains($Key)) {
                if ($Value -ceq $ini.$Section.$Key) {
                    $Ret = $Ret
                }
                else {
                    $Ret = $false
                }
            }
            else {
                $Ret = !$Ret
            }
        }
        else {
            $Ret = !$Ret
        }
    }

    if ($Ret) {
        Write-Verbose ('Test Passed. Nothing needs to do')
    }
    else {
        Write-Verbose 'Test NOT Passed.'
    }

    return $Ret
} # end of Test-TargetResource


<#
.SYNOPSIS
Load ini file and convert to the dictionary object

.PARAMETER Path
The path of the ini file.

.PARAMETER Encoding
You can specify the encoding of the ini file.

.OUTPUTS
[System.Collections.Specialized.OrderedDictionary]

.EXAMPLE
PS> Get-IniFile -Path C:\sample.ini

.NOTES
General notes
#>

function Get-IniFile {
    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param
    (
        # Set Target full path to INI
        [Parameter(Position = 0, Mandatory = $true, ValueFromPipeline)]
        [validateScript( { Test-Path $_ })]
        [Alias('File')]
        [string]
        $Path,

        # specify file encoding
        [Parameter()]
        [Encoding]
        $Encoding = 'utf8NoBOM'
    )

    process {
        # Write-Verbose ('Loading file from {0}' -f $Path)
        $PSEncoder = Get-PSEncoding -Encoding $Encoding
        $Content = Get-Content -Path $Path -Encoding $PSEncoder
        $CurrentSection = '_ROOT_'
        [OrderedDictionary]$IniHash = [ordered]@{ }
        $IniHash.Add($CurrentSection, [ordered]@{ })

        foreach ($line in $Content) {
            $line = $line.Trim()
            if ($line -match '^;') {
                # Write-Verbose ('Comment')
                $line = ($line.split(';')[0]).Trim()
            }

            if ($line -match '^\[(.+)\]') {
                # Section
                $CurrentSection = $Matches[1]
                if (-not $IniHash.Contains($CurrentSection)) {
                    # Write-Verbose ('Add Section. Section: {0}' -f $Matches[1])
                    $IniHash.Add($CurrentSection, [ordered]@{ })
                }
            }
            elseif ($line -match '=') {
                #KeyValuePair
                $idx = $line.IndexOf('=')
                [string]$key = $line.Substring(0, $idx)
                [string]$value = $line.Substring($idx + 1)
                # Write-Verbose ('Add KVP. Key: {0}, Value: {1}, Section: {2}' -f $key,$value,$CurrentSection)
                $IniHash.$CurrentSection.$key = $value
            }
        }
        $IniHash
    }
}


<#
.SYNOPSIS
Convert dictionary to ini expression string

.PARAMETER InputObject
[System.Collections.Specialized.OrderedDictionary]
The Ordered Dictionary you wish to convert to a string.

.OUTPUTS
[string[]]

.EXAMPLE
PS> $Dictionary = [ordered]@{ Section1 = @{ Key1 = 'Value1'; Key2 = 'Value2' } }
PS> ConvertTo-IniString -InputObject $Dictionary
[Section1]
Key1=Value1
Key2=Value2
#>

function ConvertTo-IniString {
    [CmdletBinding()]
    [OutputType([string[]])]
    param
    (
        [Parameter(Position = 0, Mandatory = $true, ValueFromPipeline)]
        [ValidateScript( {
                if (Test-IsOrderedOrHash $_) {
                    $true
                }
                else {
                    throw 'The value type of InputObject should be "System.Collections.Specialized.OrderedDictionary" or "System.Collections.Hashtable"'
                }
            })]
        $InputObject
    )

    Process {
        $IniString = New-Object 'System.Collections.Generic.List[System.String]'
        $RootKey = '_ROOT_'

        if (-not ($InputObject -as [OrderedDictionary])) {
            $InputObject = ConvertTo-OrderedDictionary -HashTable $InputObject
        }

        if ($InputObject.Contains($RootKey)) {
            if ((Test-IsOrderedOrHash $InputObject.$RootKey) -and ($InputObject.$RootKey.Count -gt 0)) {
                $private:Keys = $InputObject.$RootKey
                $Keys.Keys.ForEach( {
                        $IniString.Add(('{0}={1}' -f $_, $Keys.$_))
                    })
                $IniString.Add([string]::Empty)
            }
        }

        foreach ($Section in $InputObject.keys) {
            if (-not ($Section -eq $RootKey)) {
                if (Test-IsOrderedOrHash $InputObject.$Section) {
                    $IniString.Add(('[{0}]' -f $Section))
                    $private:Keys = $InputObject.$Section
                    $Keys.Keys.ForEach( {
                            $IniString.Add(('{0}={1}' -f $_, $Keys.$_))
                        })
                    $IniString.Add([string]::Empty)
                }
            }
        }
        $IniString.ToArray()
    }
}


<#
.SYNOPSIS
Set a key value pair to the dictionary.

.PARAMETER InputObject
[System.Collections.Specialized.OrderedDictionary]

.PARAMETER Key
[string]
The key name

.PARAMETER Value
[string]
The value of the key

.PARAMETER Section
[string]
The name of the section to which the key belongs.
If the key doesn't need to belong section, you don't need specify this parameter.

.PARAMETER PassThru
[switch]
If specified, This function will output modified dictionary.

.OUTPUTS
[System.Collections.Specialized.OrderedDictionary]

.EXAMPLE
PS> $Dictionary = [ordered]@{ Section1 = @{ Key1 = 'Value1'; Key2 = 'Value2' } }
PS> $Dictionary | Set-IniKey -Key 'Key2' -Value 'ModValue2' -Section 'Section1' -PassThru | ConvertTo-IniString
[Section1]
Key1=Value1
Key2=ModValue2
#>

function Set-IniKey {
    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param
    (
        [Parameter(Position = 0, Mandatory = $true, ValueFromPipeline)]
        [ValidateScript( {
                if (Test-IsOrderedOrHash $_) {
                    $true
                }
                else {
                    throw 'The value type of InputObject should be "System.Collections.Specialized.OrderedDictionary" or "System.Collections.Hashtable"'
                }
            })]
        $InputObject,

        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Key,

        [Parameter()]
        [AllowEmptyString()]
        [string]$Value = '',

        [Parameter()]
        [string]$Section = '_ROOT_',

        [Parameter()]
        [switch]$PassThru
    )

    Process {
        if (-not ($InputObject -as [OrderedDictionary])) {
            $InputObject = ConvertTo-OrderedDictionary -HashTable $InputObject
        }

        if ($InputObject.Contains($Section)) {
            if ($Key) {
                if (Test-IsOrderedOrHash $InputObject.$Section) {
                    if (-not ($InputObject.$Section -as [OrderedDictionary])) {
                        $InputObject.$Section = ConvertTo-OrderedDictionary -HashTable $InputObject.$Section
                    }

                    if ($InputObject.$Section.Contains($Key)) {
                        Write-Verbose ("Update value. Key:'{0}'; Value:'{1}'; Section:'{2}'" -f $key, $Value, $Section)
                        $InputObject.$Section.$Key = $Value
                    }
                    else {
                        Write-Verbose ("Set value. Key:'{0}'; Value:'{1}'; Section:'{2}'" -f $key, $Value, $Section)
                        $InputObject.$Section.Add($Key, $Value)
                    }
                }
            }
        }
        else {
            $InputObject.Add($Section, [OrderedDictionary]@{ })
            if ($Key) {
                Write-Verbose ("Set value. Key:'{0}'; Value:'{1}'; Section:'{2}'" -f $key, $Value, $Section)
                $InputObject.$Section.Add($Key, $Value)
            }
        }

        if ($PassThru) {
            $InputObject
        }
    }
}


<#
.SYNOPSIS
Remove a key value pair from dictionary.

.PARAMETER InputObject
[System.Collections.Specialized.OrderedDictionary]

.PARAMETER Key
[string]
The key name

.PARAMETER Section
[string]
The name of the section to which the key belongs.
If the key doesn't need to belong section, you don't need specify this parameter.

.PARAMETER PassThru
[switch]
If specified, This function will output modified dictionary.

.OUTPUTS
[System.Collections.Specialized.OrderedDictionary]

.EXAMPLE
PS> $Dictionary = [ordered]@{ Section1 = @{ Key1 = 'Value1'; Key2 = 'Value2' } }
PS> $Dictionary | Remove-IniKey -Key 'Key2' -Section 'Section1' -PassThru | ConvertTo-IniString
[Section1]
Key1=Value1
#>

function Remove-IniKey {
    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param
    (
        [Parameter(Position = 0, Mandatory = $true, ValueFromPipeline)]
        [ValidateScript( {
                if (Test-IsOrderedOrHash $_) {
                    $true
                }
                else {
                    throw 'The value type of InputObject should be "System.Collections.Specialized.OrderedDictionary" or "System.Collections.Hashtable"'
                }
            })]
        $InputObject,

        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Key,

        [Parameter()]
        [string]$Section = '_ROOT_',

        [Parameter()]
        [switch]$PassThru
    )

    Process {
        if (-not ($InputObject -as [OrderedDictionary])) {
            $InputObject = ConvertTo-OrderedDictionary -HashTable $InputObject
        }

        if ($InputObject.Contains($Section)) {
            if ($Key) {
                if (Test-IsOrderedOrHash $InputObject.$Section) {
                    if (-not ($InputObject.$Section -as [OrderedDictionary])) {
                        $InputObject.$Section = ConvertTo-OrderedDictionary -HashTable $InputObject.$Section
                    }

                    if ($InputObject.$Section.Contains($Key)) {
                        $InputObject.$Section.Remove($key)

                        # when all key is removed, also remove section
                        if ($InputObject.$Section.Count -le 0) {
                            $InputObject.Remove($Section)
                        }
                    }
                }
            }

            # if key is empty, remove section and all of child keys
            else {
                $InputObject.Remove($Section)
            }
        }

        if ($PassThru) {
            $InputObject
        }
    }
}


function ConvertTo-OrderedDictionary {
    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    Param
    (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [hashtable]
        $HashTable
    )

    $ordered = [ordered]@{ }
    foreach ($key in $HashTable.keys) {
        $ordered.Add($key, $HashTable[$key])
    }
    $ordered
}

function Test-IsOrderedOrHash {
    [CmdletBinding()]
    [OutputType([bool])]
    Param(
        [Parameter(Mandatory = $true, Position = 0)]
        [Object]$InputObject
    )

    ($InputObject -as [OrderedDictionary]) -or ($InputObject -as [hashtable])
}


Export-ModuleMember -Function @(
    'Get-TargetResource',
    'Set-TargetResource',
    'Test-TargetResource',
    'Get-IniFile',
    'ConvertTo-IniString',
    'Set-IniKey',
    'Remove-IniKey'
)