Private/Classes.ps1

<#
.DESCRIPTION
    Special private classes for the module.
    Previously the module used to have a lot of private classes spread across various files
    this is an attempt to consolidate them into one file.
    It's important that classes remain in one file as when they are in individual files they can be loaded in the wrong
    order which can cause 'type not found' errors.
    It also breaks IDE's as they can't find the type definition.
#>



## Common classes

<#
.SYNOPSIS
    This enum performs some simple validation for line endings.
#>

enum BrownserveLineEnding
{
    LF
    CRLF
    CR
}

<#
.SYNOPSIS
    The BrownserveContent class is used to aid in formatting file content in a consistent manner.
    It is used with the *-BrownserveContent cmdlets.
#>

class BrownserveContent
{
    [string[]]$Content
    hidden [string]$Path
    hidden [BrownserveLineEnding]$LineEnding

    BrownserveContent([string[]]$Content, [string]$Path, [string]$LineEnding)
    {
        $this.Content = $Content
        $this.Path = $Path
        $this.LineEnding = $LineEnding
    }

    BrownserveContent([pscustomobject]$Content)
    {
        # Compare against $null, content may end up as an empty string in certain cases which is expected
        if ($null -eq $Content.Content)
        {
            throw 'Cannot create BrownserveContent object without Content'
        }
        if (!$Content.Path)
        {
            throw 'Cannot create BrownserveContent object without Path'
        }
        if ($null -eq $Content.LineEnding)
        {
            throw 'Cannot create BrownserveContent object without LineEnding'
        }
        $this.Content = $Content.Content
        $this.Path = $Content.Path
        $this.LineEnding = $Content.LineEnding
    }

    BrownserveContent([hashtable]$Content)
    {
        # Compare against $null, content may end up as an empty string in certain cases which is expected
        if ($null -eq $Content.Content)
        {
            throw 'Cannot create BrownserveContent object without Content'
        }
        if (!$Content.Path)
        {
            throw 'Cannot create BrownserveContent object without Path'
        }
        if ($null -eq $Content.LineEnding)
        {
            throw 'Cannot create BrownserveContent object without LineEnding'
        }
        $this.Content = $Content.Content
        $this.Path = $Content.Path
        $this.LineEnding = $Content.LineEnding
    }

    [string] ToString()
    {
        return $this.Content -join $this.NewLine()
    }

    # We can call this method to easily get the line ending for the file
    [string] NewLine()
    {
        switch ($this.LineEnding)
        {
            'LF'
            {
                return "`n"
            }
            'CRLF'
            {
                return "`r`n"
            }
            'CR'
            {
                return "`r"
            }
            default
            {
                throw "Unsupported line ending '$($this.LineEnding)'"
            }
        }
        throw "Unsupported line ending '$($this.LineEnding)'"
    }
}

## Git related classes

<#
    This class converts shorthand git diffs into human readable status
#>

class GitDiff
{
    [string] $Value

    GitDiff([char] $Value)
    {
        $StatusMap = @{
            '?' = 'Untracked'
            '!' = 'Ignored'
            'A' = 'Added'
            'C' = 'Copied'
            'D' = 'Deleted'
            'M' = 'Modified'
            'R' = 'Renamed'
            'T' = 'Type Changed'
            'U' = 'Unmerged'
            ' ' = 'Unmodified'
        }
        if ($StatusMap.ContainsKey($Value))
        {
            $this.Value = $StatusMap[$Value]
        }
        else
        {
            throw "Invalid git status: '$Value'"
        }
    }

    GitDiff([string] $Value)
    {
        $StatusMap = @{
            '?' = 'Untracked'
            '!' = 'Ignored'
            'A' = 'Added'
            'C' = 'Copied'
            'D' = 'Deleted'
            'M' = 'Modified'
            'R' = 'Renamed'
            'T' = 'Type Changed'
            'U' = 'Unmerged'
            ' ' = 'Unmodified'
        }
        if ($StatusMap.ContainsKey($Value))
        {
            $this.Value = $StatusMap[$Value]
        }
        else
        {
            throw "Invalid git status: '$Value'"
        }
    }

    [string] ToString()
    {
        return "$($this.Value)"
    }
}

<#
    This class helps us format git status objects
#>

class GitStatus
{
    [GitDiff]$Staged
    [GitDiff]$Unstaged
    [string]$Source
    hidden [string]$Destination

    GitStatus([string]$Staged, [string]$Unstaged, [string]$Source, [string]$Destination)
    {
        $this.Staged = $Staged
        $this.Unstaged = $Unstaged
        $this.Source = $Source
        $this.Destination = $Destination
    }

    GitStatus([pscustomobject]$Status)
    {
        if (!$Status.Staged -and !$Status.Unstaged)
        {
            throw 'Cannot create GitStatus object without a Staged or Unstaged change'
        }
        if (!$Status.Source)
        {
            throw 'Cannot create GitStatus object without a Source'
        }
        $this.Staged = $Status.Staged
        $this.Unstaged = $Status.Unstaged
        $this.Source = $Status.Source
        $this.Destination = $Status.Destination
    }

    GitStatus([hashtable]$Status)
    {
        if (!$Status.Staged -and !$Status.Unstaged)
        {
            throw 'Cannot create GitStatus object without a Staged or Unstaged change'
        }
        if (!$Status.Source)
        {
            throw 'Cannot create GitStatus object without a Source'
        }
        $this.Staged = $Status.Staged
        $this.Unstaged = $Status.Unstaged
        $this.Source = $Status.Source
        $this.Destination = $Status.Destination
    }
}

## GitHub related classes

<#
    Simple enum for GitHub issue/PR states
#>

enum GitHubIssueState
{
    Open
    Closed
    All
}

## Type validation classes

<#
    Simple class to ensure datetime objects are displayed as short dates in output but retain their date time attribute
#>

class BrownserveShortDate
{
    [datetime]$Date

    BrownserveShortDate([datetime]$Date)
    {
        $this.Date = $Date
    }

    BrownserveShortDate([string]$Date)
    {
        $this.Date = $Date
    }

    [string] ToString()
    {
        return "$(Get-Date $this.Date -Format 'yyyy/MM/dd')"
    }
}

<#
    This class helps us to format version history entries from a changelog
#>

class BrownserveVersionHistory
{
    [semver]$Version
    [BrownserveShortDate]$ReleaseDate
    [string]$URL
    [string[]]$ReleaseNotes
    [bool]$PreRelease = $false

    BrownserveVersionHistory([semver]$Version, [datetime]$ReleaseDate, [string]$URL, [string]$ReleaseNotes)
    {
        $this.Version = $Version
        $this.ReleaseDate = $ReleaseDate
        $this.URL = $URL
        $this.ReleaseNotes = $ReleaseNotes
        if ($this.Version.PreReleaseLabel)
        {
            $this.PreRelease = $true
        }
    }

    BrownserveVersionHistory([pscustomobject]$VersionHistory)
    {
        if (!$VersionHistory.Version)
        {
            throw 'Cannot create BrownserveVersionHistory object without a Version'
        }
        if (!$VersionHistory.ReleaseDate)
        {
            throw 'Cannot create BrownserveVersionHistory object without a ReleaseDate'
        }
        if (!$VersionHistory.URL)
        {
            throw 'Cannot create BrownserveVersionHistory object without a URL'
        }
        if (!$VersionHistory.ReleaseNotes)
        {
            throw 'Cannot create BrownserveVersionHistory object without ReleaseNotes'
        }
        $this.Version = $VersionHistory.Version
        $this.ReleaseDate = $VersionHistory.ReleaseDate
        $this.URL = $VersionHistory.URL
        $this.ReleaseNotes = $VersionHistory.ReleaseNotes
        if ($this.Version.PreReleaseLabel)
        {
            $this.PreRelease = $true
        }
    }

    BrownserveVersionHistory([hashtable]$VersionHistory)
    {
        if (!$VersionHistory.Version)
        {
            throw 'Cannot create BrownserveVersionHistory object without a Version'
        }
        if (!$VersionHistory.ReleaseDate)
        {
            throw 'Cannot create BrownserveVersionHistory object without a ReleaseDate'
        }
        if (!$VersionHistory.URL)
        {
            throw 'Cannot create BrownserveVersionHistory object without a URL'
        }
        if (!$VersionHistory.ReleaseNotes)
        {
            throw 'Cannot create BrownserveVersionHistory object without ReleaseNotes'
        }
        $this.Version = $VersionHistory.Version
        $this.ReleaseDate = $VersionHistory.ReleaseDate
        $this.URL = $VersionHistory.URL
        $this.ReleaseNotes = $VersionHistory.ReleaseNotes
        if ($this.Version.PreReleaseLabel)
        {
            $this.PreRelease = $true
        }
    }

    [string] ToString()
    {
        return "$($this.Version) - $($this.ReleaseDate)"
    }
}

<#
    Class for storing Brownserve Changelog data
#>

class BrownserveChangelog
{
    [BrownserveVersionHistory[]]$VersionHistory
    [int]$NewEntryInsertLine
    [BrownserveVersionHistory]$LatestVersion
    hidden [string]$ChangelogPath
    hidden [string[]]$Content

    BrownserveChangelog([BrownserveVersionHistory[]]$VersionHistory, [int]$NewEntryInsertLine, [string]$ChangelogPath, [string[]]$Content)
    {
        $this.VersionHistory = $VersionHistory | Sort-Object -Property ReleaseDate -Descending
        $this.NewEntryInsertLine = $NewEntryInsertLine
        $this.LatestVersion = $this.VersionHistory[0]
        $this.ChangelogPath = $ChangelogPath
        $this.Content = $Content
    }

    BrownserveChangelog([pscustomobject]$Changelog)
    {
        if (!$Changelog.VersionHistory)
        {
            throw 'Cannot create BrownserveChangelog object without VersionHistory'
        }
        if (!$Changelog.NewEntryInsertLine)
        {
            throw 'Cannot create BrownserveChangelog object without NewEntryInsertLine'
        }
        if (!$Changelog.ChangelogPath)
        {
            throw 'Cannot create BrownserveChangelog object without ChangelogPath'
        }
        if (!$Changelog.Content)
        {
            throw 'Cannot create BrownserveChangelog object without Content'
        }
        $this.VersionHistory = $Changelog.VersionHistory | Sort-Object -Property ReleaseDate -Descending
        $this.NewEntryInsertLine = $Changelog.NewEntryInsertLine
        $this.LatestVersion = $this.VersionHistory[0]
        $this.ChangelogPath = $Changelog.ChangelogPath
        $this.Content = $Changelog.Content
    }

    BrownserveChangelog([hashtable]$Changelog)
    {
        if (!$Changelog.VersionHistory)
        {
            throw 'Cannot create BrownserveChangelog object without VersionHistory'
        }
        if (!$Changelog.NewEntryInsertLine)
        {
            throw 'Cannot create BrownserveChangelog object without NewEntryInsertLine'
        }
        if (!$Changelog.ChangelogPath)
        {
            throw 'Cannot create BrownserveChangelog object without ChangelogPath'
        }
        if (!$Changelog.Content)
        {
            throw 'Cannot create BrownserveChangelog object without Content'
        }
        $this.VersionHistory = $Changelog.VersionHistory | Sort-Object -Property ReleaseDate -Descending
        $this.NewEntryInsertLine = $Changelog.NewEntryInsertLine
        $this.LatestVersion = $this.VersionHistory[0]
        $this.ChangelogPath = $Changelog.ChangelogPath
        $this.Content = $Changelog.Content
    }
}

## IDE related classes

<#
    This class helps us format editorconfig properties
#>

class EditorConfigProperty
{
    [string]$Name
    $Value

    EditorConfigProperty([string]$Name, $Value)
    {
        $this.Name = $Name
        $this.Value = $Value
        $this.ValidityCheck()
    }

    EditorConfigProperty([hashtable]$Property)
    {
        $this.Value = $Property.Value
        $this.Name = $Property.Name
        $this.ValidityCheck()
    }

    EditorConfigProperty([System.Collections.DictionaryEntry]$Property)
    {
        $this.Value = $Property.Value
        $this.Name = $Property.Name
        $this.ValidityCheck()
    }

    [string] ToString()
    {
        return ("$($this.Name) = $($this.Value)").ToLower()
    }

    hidden ValidityCheck()
    {
        # Ensure that the property name is valid as per the editorconfig spec (https://github.com/editorconfig/editorconfig/wiki/EditorConfig-Properties)
        $ValidPropertyNames = @(
            'indent_style',
            'indent_size',
            'tab_width',
            'end_of_line',
            'charset',
            'trim_trailing_whitespace',
            'insert_final_newline',
            'max_line_length'
        )
        if ($this.Name -notin $ValidPropertyNames)
        {
            throw "Invalid editorconfig property name: '$($this.Name)'"
        }

        # Ensure that the property value is valid as per the editorconfig spec
        switch ($this.Name)
        {
            'indent_style'
            {
                $ValidValues = @('tab', 'space')
                if ($this.Value -notin $ValidValues)
                {
                    throw "Invalid indent_style value: '$($this.Value)'"
                }
            }
            'indent_size'
            {
                (($this.Value -isnot [int]) -or ($this.Value -isnot [Int64]))
                {
                    if ($this.Value -ne 'tab')
                    {
                        throw "Invalid indent_size value: '$($this.Value)'"
                    }
                }
            }
            'tab_width'
            {
                (($this.Value -isnot [int]) -or ($this.Value -isnot [Int64]))
                {
                    throw "Invalid tab_width value: '$($this.Value)'"
                }
            }
            'end_of_line'
            {
                $ValidValues = @('lf', 'cr', 'crlf')
                if ($this.Value -notin $ValidValues)
                {
                    throw "Invalid end_of_line value: '$($this.Value)'"
                }
            }
            'charset'
            {
                $ValidValues = @('latin1', 'utf-8', 'utf-8-bom', 'utf-16be', 'utf-16le')
                if ($this.Value -notin $ValidValues)
                {
                    throw "Invalid charset value: '$($this.Value)'"
                }
            }
            'trim_trailing_whitespace'
            {
                if ($this.Value -isnot [bool])
                {
                    throw "Invalid trim_trailing_whitespace value: '$($this.Value)'"
                }
            }
            'insert_final_newline'
            {
                if ($this.Value -isnot [bool])
                {
                    throw "Invalid insert_final_newline value: '$($this.Value)'"
                }
            }
            'max_line_length'
            {
                if (($this.Value -isnot [int]) -or ($this.Value -isnot [Int64]))
                {
                    if ($this.Value -ne 'off')
                    {
                        throw "Invalid max_line_length value: '$($this.Value)'"
                    }
                }
            }
        }
    }
}

<#
    This class helps us format editorconfig sections
#>

class EditorConfigSection
{
    [string]$FilePath
    [EditorConfigProperty[]]$Properties
    [string[]]$Comment

    EditorConfigSection([string]$FilePath, [EditorConfigProperty[]]$Properties)
    {
        $this.FilePath = $FilePath
        $this.Properties = $Properties
    }

    EditorConfigSection([string]$FilePath, [EditorConfigProperty[]]$Properties, [string[]]$Comment)
    {
        $this.FilePath = $FilePath
        $this.Properties = $Properties
        $this.Comment = $Comment
    }

    EditorConfigSection([hashtable]$Section)
    {
        if (!$Section.FilePath)
        {
            throw 'Cannot create EditorConfigSection object without FilePath'
        }
        if (!$Section.Properties)
        {
            throw 'Cannot create EditorConfigSection object without Properties'
        }
        if ($Section.Comment)
        {
            $this.Comment = $Section.Comment
        }
        $this.FilePath = $Section.FilePath
        if ($Section.Properties -is [hashtable])
        {
            $this.ExpandProperties($Section.Properties)
        }
        else
        {
            $this.Properties = $Section.Properties
        }
    }

    # It's much more convenient to be able to pass in a hash of all the properties
    # but we need to expand them all first
    hidden ExpandProperties([hashtable]$Properties)
    {
        $ExpandedProps = @()
        $Properties.GetEnumerator() | ForEach-Object {
            $ExpandedProps += [EditorConfigProperty]$_
        }
        $this.Properties = $ExpandedProps
    }

    # Override the default ToString method, let's output exactly what we want!
    [string] ToString()
    {
        $Return = ''
        if ($this.Comment)
        {
            $this.Comment | ForEach-Object {
                if ($_.StartsWith('#'))
                {
                    $Return += "$_`n"
                }
                else
                {
                    $Return += "# $_`n"
                }
            }
        }
        if ($this.FilePath -notmatch '^\[.*\]$')
        {
            $Return += "[$($this.FilePath)]`n"
        }
        else
        {
            $Return += "$($this.FilePath)`n"
        }
        $this.Properties | ForEach-Object {
            $Return += "$($_)`n"
        }
        return $Return
    }
}

## Markdown related classes

enum MarkdownEmphasisAsHeaderConversion
{
    List
    Header
}