codaamok.build.psm1

#region Private
function GetPSGalleryNextAvailableVersionNumber {
    param (
        [Parameter(Mandatory)]
        [String]$ModuleName,

        [Parameter(Mandatory)]
        [Version]$VersionToBuild
    )

    Write-Verbose "Qualifying the version number to build with is available in the PowerShell Gallery" -Verbose

    for ($i = $VersionToBuild.Build; $i -le 100; $i++) {
        if ($i -eq 100) {
            throw "You have 100 unlisted packages under the same build number? Sort your life out."
        }

        try {
            $PSGalleryModuleInfo = Find-Module -Name $ModuleName -RequiredVersion $VersionToBuild -ErrorAction "Stop"
            if ($PSGalleryModuleInfo) {
                Write-Verbose "Found module in the gallery with the same verison number, adding one to the Build number and will query the gallery again"

                $VersionToBuild = [System.Version]::New(
                    $VersionToBuild.Major,
                    $VersionToBuild.Minor,
                    $VersionToBuild.Build + $i
                )
            }
            else {
                throw "Unusually, there was no object returned or excpetion throw from Find-Module while sussing out unlisted packages"
            }
        }
        catch {
            if ($_.Exception.Message -match "No match was found for the specified search criteria") {
                Write-Verbose "Found the next available version number to build with" -Verbose
                break
            }
            else {
                throw $_
            }
        }
    }

    return $VersionToBuild
}
#endregion

#region Public
function Get-BuildCommands {
    <#
    .SYNOPSIS
        Auxiliary
        Short description
    .DESCRIPTION
        Long description
    .EXAMPLE
        PS C:\> <example usage>
        Explanation of what the example does
    .INPUTS
        Inputs (if any)
    .OUTPUTS
        Output (if any)
    .NOTES
        General notes
    #>

    param (
    )

    $Commands = @{}

    Get-Command -Module "codaamok.build" | ForEach-Object {
        $Help = Get-Help -Name $_.Name
        $Synopsis = $Help.Synopsis

        if ([String]::IsNullOrWhiteSpace($Synopsis[0])) { 
            $Commands["N/A"] += @($_.Name)
        } 
        else {
            $Commands[($Synopsis -split '\n')[0]] += @($_.Name)
        }
    }

    foreach ($Key in $Commands.Keys) {
        Write-Host $Key -ForegroundColor Blue
        foreach ($Value in $Commands[$Key]) {
            Write-Host ("- {0}" -f $Value) -ForegroundColor Green
        }
        Write-Host ""
    }
}

function New-ModuleDirStructure {
    <#
    .SYNOPSIS
        Setup
        Short description
    .DESCRIPTION
        Long description
    .EXAMPLE
        PS C:\> <example usage>
        Explanation of what the example does
    .INPUTS
        Inputs (if any)
    .OUTPUTS
        Output (if any)
    .NOTES
        General notes
    #>

    param (
        [Parameter(Mandatory)]
        [String]$Path,
        [Parameter(Mandatory)]
        [String]$ModuleName,
        [Parameter()]
        [String]$Author = "Adam Cook (@codaamok)",
        [Parameter(Mandatory)]
        [String]$Description,
        [Parameter()]
        [String[]]$Tags,
        [Parameter()]
        [String]$ProjectUri,
        [Parameter()]
        [Switch]$CreateFormatFile,
        [Parameter()]
        [Version]$PowerShellVersion = 5.1
    )

    # Create the module and private function directories
    @(
        "$Path\.github\workflows",
        "$Path\src",
        "$Path\src\ScriptsToProcess",
        "$Path\src\Files",
        "$Path\src\Private",
        "$Path\src\Public",
        "$Path\src\Classes",
        "$Path\src\Enums",
        "$Path\src\en-US",
        "$Path\tests",
        "$Path\build",
        "$Path\release",
        "$Path\docs"
    ) | ForEach-Object {
        New-Item -Path $_ -ItemType Directory -Force
        New-Item -Path $_\.gitkeep -ItemType File -Force
    }

    #Create the module and related files
    $GitIgnorePath = "{0}\.gitignore" -f $Path
    $ModuleScript = "{0}.psm1" -f $ModuleName
    $ModuleScriptPath = "{0}\src\{1}" -f $Path, $ModuleScript
    $ModuleManifest = "{0}.psd1" -f $ModuleName
    $ModuleManifestPath = "{0}\src\{1}" -f $Path, $ModuleManifest
    New-Item $ModuleManifestPath -ItemType File -Force
    @(
        '$Public = @( Get-ChildItem -Path $PSScriptRoot\Public -Recurse -Filter "*.ps1" )'
        '$Private = @( Get-ChildItem -Path $PSScriptRoot\Private -Recurse -Filter "*.ps1" )'
        'foreach ($import in @($Public + $Private)) {'
        ' try {'
        ' . $import.fullname'
        ' }'
        ' catch {'
        ' Write-Error -Message "Failed to import function $($import.fullname): $_"'
        ' }'
        '}'
        'Export-ModuleMember -Function $Public.Basename'
    ) | Set-Content -Path $ModuleScriptPath -Force
    @(
        'build/*'
        'release/*'
        '!*.gitkeep'
    ) | Set-Content -Path $GitIgnorePath

    $ModuleHelpPath = "{0}\src\en-US\about_{1}.help.txt" -f $Path, $ModuleName
    New-Item $ModuleHelpPath -ItemType File -Force

    $NewModuleManifestSplat = @{
        Path                = $ModuleManifestPath
        RootModule          = $ModuleScript
        Description         = $Description
        PowerShellVersion   = $PowerShellVersion
        Author              = $Author
        FunctionsToExport   = '*'
    }

    if ($CreateFormatFile) { 
        $ModuleFormat = "{0}.Format.ps1xml" -f $ModuleName
        $ModuleFormatPath = "{0}\src\{1}" -f $Path, $ModuleFormat
        New-Item $ModuleFormatPath -ItemType File -Force
        $NewModuleManifestSplat["FormatsToProcess"] = $ModuleFormat
    }

    if ($ProjectUri) {
        $NewModuleManifestSplat["ProjectUri"] = $ProjectUri
    }

    New-ModuleManifest @NewModuleManifestSplat
}

function New-ProjectDirStructure {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [String]$Path,

        [Parameter(Mandatory)]
        [String]$Name,

        [Parameter()]
        [String]$Platform
    )

    # TODO create CHANGELOG.md, copy github action workflow and build script, create module dir structure
}

function New-VSCodeTaskFile {
    <#
    .SYNOPSIS
        Setup
        Short description
    .DESCRIPTION
        Long description
    .EXAMPLE
        PS C:\> <example usage>
        Explanation of what the example does
    .INPUTS
        Inputs (if any)
    .OUTPUTS
        Output (if any)
    .NOTES
        General notes
    #>

}

function Update-BuildFiles {
    <#
    .SYNOPSIS
        Setup
        Copy the build files (script + GitHub Actiosn workflow) from the module's install directory to the specified directory
    .DESCRIPTION
        Copy the build files (script + GitHub Actiosn workflow) from the module's install directory to the specified directory
    .EXAMPLE
        PS C:\> <example usage>
        Explanation of what the example does
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [String]$DestinationPath
    )

    $Module = Get-Module "codaamok.build"

    # FileList property could be empty if imported the non-released module manifest during development
    if ([String]::IsNullOrWhiteSpace($Module.FileList)) {
        $Module = [PSCustomObject]@{
            FileList = Get-ChildItem -Path "$($Module.ModuleBase)\Files" -Force | Select-Object -ExpandProperty FullName
        }
    }

    $oldbuildyml = "{0}\.github\workflows\build.yml" -f $DestinationPath
    if (Test-Path $oldbuildyml) { 
        Remove-Item -Path $oldbuildyml -Confirm
    }

    switch -Regex ($Module.FileList) {
        "pipeline\.yml$" {
            $Destination = "{0}\.github\workflows" -f $DestinationPath
            $File = "{0}\pipeline.yml" -f $Destination
            if (-not (Test-Path $Destination)) {
                $null = New-Item -Path $Destination -ItemType "Directory" -Force
            }
            elseif (Test-Path $File) {
                $TargetFirstLine = Get-Content $File -TotalCount 1
                $SourceFirstLine = Get-Content $_ -TotalCount 1
                if ($TargetFirstLine -ne $SourceFirstLine) {
                    Write-Warning -Message 'Will not update pipeline.yml as it appears to be customised (indicated by reading the first line)'
                    continue
                }
            }
            Copy-Item -Path $_ -Destination $Destination -Confirm
        }
        "gitignore$" {
            $Destination = "{0}\.gitignore" -f $DestinationPath
            Copy-Item -Path $_ -Destination $Destination -Confirm
        }
        default {
            Copy-Item -Path $_ -Destination $DestinationPath -Confirm
        }
    }
}

function Export-RootModule {
    <#
    .SYNOPSIS
        Build
        Get all of the function definition content for the module and create a single .psm1 with said content
    .DESCRIPTION
        Get all of the function definition content for the module and create a single .psm1 with said content
    .EXAMPLE
        PS C:\> <example usage>
        Explanation of what the example does
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [String[]]$DevModulePath,

        [Parameter(Mandatory)]
        [String[]]$RootModule
    )

    $null = New-Item -Path $RootModule -ItemType "File" -Force

    switch ('Classes','Enums','Private','Public') {
        'Classes' {
            $Files = @(Get-ChildItem $DevModulePath\$_ -Filter *.ps1 -Recurse)

            if ($Files) {
                '#region {0}' -f $_ | Add-Content -Path $RootModule

                $ClassCode = foreach ($File in $Files) {
                    Get-Content -Path $File.FullName | ForEach-Object {
                        $LastLineWasUsingStatement = $false
                        if ($_ -match '^using .+') {
                            $_ | Add-Content -Path $RootModule
                            $LastLineWasUsingStatement = $true
                        }
                        elseif (-not $LastLineWasUsingStatement -And -not [String]::IsNullOrWhiteSpace($_)) {
                            $_
                        }
                    } 

                    # Add new line only if the current file isn't the last one (minus 1 because array indexes from 0)
                    if ($Files.IndexOf($File) -ne ($Files.Count - 1)) {
                        ''
                    }
                }

                '',$ClassCode,('#endregion' -f $_),'' | Add-Content -Path $RootModule
            }
        }
        default {
            $Files = @(Get-ChildItem $DevModulePath\$_ -Filter *.ps1 -Recurse)

            if ($Files) {
                '#region {0}' -f $_ | Add-Content -Path $RootModule
        
                foreach ($File in $Files) {
                    Get-Content -Path $File.FullName | Add-Content -Path $RootModule
        
                    # Add new line only if the current file isn't the last one (minus 1 because array indexes from 0)
                    if ($Files.IndexOf($File) -ne ($Files.Count - 1)) {
                        Write-Output "" | Add-Content -Path $RootModule
                    }
                }
        
                ('#endregion' -f $_),'' | Add-Content -Path $RootModule
            }
        }
    }
}

function Export-ScriptsToProcess {
    <#
    .SYNOPSIS
        Build
        Create a single Process.ps1 script file for all script files under ScriptsToProcess\*
    .DESCRIPTION
        Create a single Process.ps1 script file for all script files under ScriptsToProcess\*
    .EXAMPLE
        PS C:\> <example usage>
        Explanation of what the example does
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [System.IO.FileSystemInfo[]]$File,

        [Parameter(Mandatory)]
        [String]$Path
    ) 

    $ProcessScript = New-Item -Path $Path -ItemType "File" -Force

    foreach ($_File in $File) {
        Get-Content -Path $_File.FullName | Add-Content -Path $ProcessScript

        # Add new line only if the current file isn't the last one (minus 1 because array indexes from 0)
        if ($File.IndexOf($_File) -ne ($File.Count - 1)) {
            Write-Output "" | Add-Content -Path $ProcessScript
        }
    }
}

function Export-UnreleasedNotes {
    <#
    .SYNOPSIS
        Short description
    .DESCRIPTION
        Long description
    .EXAMPLE
        PS C:\> <example usage>
        Explanation of what the example does
    .INPUTS
        Inputs (if any)
    .OUTPUTS
        Output (if any)
    .NOTES
        General notes
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [String]$Path,

        [Parameter(Mandatory)]
        [PSCustomObject]$ChangeLogData,

        [Parameter()]
        [Bool]$NewRelease
    )

    $EmptyChangeLog = $true

    $ReleaseNotes = foreach ($Property in $ChangeLogData.Unreleased[0].Data.PSObject.Properties.Name) {
        $Data = $ChangeLogData.Unreleased[0].Data.$Property

        if ($Data) {
            $EmptyChangeLog = $false

            Write-Output ("# {0}" -f $Property)

            foreach ($item in $Data) {
                Write-Output ("- {0}" -f $item)
            }
        }
    }

    if ($EmptyChangeLog -eq $true -Or $ReleaseNotes.Count -eq 0) {
        if ($NewRelease) {
            throw "Can not build with empty Unreleased section in the change log"
        }
        else {
            $ReleaseNotes = "None"
        }
    }

    Write-Verbose "Release notes:" -Verbose
    $ReleaseNotes | Write-Verbose -Verbose

    Set-Content -Value $ReleaseNotes -Path $Path -Force
}

function Get-BuildVersionNumber {
    <#
    .SYNOPSIS
        Build
        Qualify the next version number to build with
    .DESCRIPTION
        Qualify the next version number to build with
    .EXAMPLE
        PS C:\> Get-BuildVersionNumber -ModuleName "PSShlink" -ManifestData $ManifestData -ChangeLogData $ChangeLogData
    #>

    param (
        [Parameter(Mandatory)]
        [String]$ModuleName,

        [Parameter(Mandatory, ParameterSetName='DetermineNextVersion')]
        [Hashtable]$ManifestData,

        [Parameter(Mandatory, ParameterSetName='DetermineNextVersion')]
        [PSCustomObject]$ChangeLogData,

        [Parameter(Mandatory, ParameterSetName='HardCodeNextVersion')]
        [Version]$VersionToBuild,

        [Parameter(ParameterSetName='DetermineNextVersion')]
        [Switch]$NewRelease
    )

    # Get PowerShell Gallery current verison number (if published)
    try {
        $PSGalleryModuleInfo = Find-Module -Name $ModuleName -ErrorAction "Stop"
    }
    catch {
        if ($_.Exception.Message -notmatch "No match was found for the specified search criteria") {
            throw $_
        }
        else {
            $PSGalleryModuleInfo = [PSCustomObject]@{
                "Name"    = $ModuleName
                "Version" = "0.0"
            }
        }
    }

    Write-Verbose ("PowerShell Gallery verison: {0}" -f $PSGalleryModuleInfo.Version) -Verbose
    Write-Verbose ("Changelog version: {0}" -f $ChangeLogData.Released[0].Version) -Verbose
    Write-Verbose ("Manifest version: {0}" -f $ManifestData.ModuleVersion) -Verbose

    if (-not $VersionToBuild) {
        if ($NewRelease) {
            # Try and piece together an understanding from the module manifest, PowerShell Gallery, and the change log, as to what the next version number should be

            # If the last released version in the change log and latest version available in the PowerShell gallery do not match, throw an exception - get them level!
            if ($null -ne $ChangeLogData.Released[0].Version -And $ChangeLogData.Released[0].Version -ne $PSGalleryModuleInfo.Version) {
                throw "The latest released version in the changelog does not match the latest released version in the PowerShell gallery"
            }
            # If module isn't yet published in the PowerShell gallery, and there's no Released section in the change log, set initial version as per the manifest
            elseif ($PSGalleryModuleInfo.Version -eq "0.0" -And $ChangeLogData.Released.Count -eq 0) {
                Write-Verbose "Module is not published to the PowerShell Gallery and there is not a Released section in the change log. Will use version from the module manifest." -Verbose
                $VersionToBuild = [System.Version]$ManifestData.ModuleVersion
            }
            # If module isn't yet published in the PowerShell gallery, and there is a Released section in the change log, update version
            elseif ($PSGalleryModuleInfo.Version -eq "0.0" -And $ChangeLogData.Released.Count -ge 1) {
                Write-Verbose "Module is not published to the PowerShell Gallery and there is a Released secton in the change log. Will +1 on the minor build from the changelog version." -Verbose
                $CurrentVersion = [System.Version]$ChangeLogData.Released[0].Version
                $VersionToBuild = [System.Version]::New(
                    $CurrentVersion.Major,
                    $CurrentVersion.Minor + 1,
                    $CurrentVersion.Build
                )
            }
            # If the module's PowerShell Gallery version and the last Released verison in the change log are in harmony, update version
            elseif ($ChangeLogData.Released[0].Version -eq $PSGalleryModuleInfo.Version) {
                Write-Verbose "Module is published to the PowerShell Gallery and its version matches the last Releases section in the changelog. Will +1 on the mintor build from the PowerShell Gallery version." -Verbose
                $CurrentVersion = [System.Version]$PSGalleryModuleInfo.Version
                $VersionToBuild = [System.Version]::New(
                    $CurrentVersion.Major,
                    $CurrentVersion.Minor + 1,
                    $CurrentVersion.Build
                )
            }
            else {
                Write-Output ("Latest release version from change log: {0}" -f $ChangeLogData.Released[0].Version)
                Write-Output ("Latest release version from PowerShell gallery: {0}" -f $PSGalleryModuleInfo.Version)
                throw "Can not determine next version number"
            }

            # Loop through and suss out any unlisted packages for the module in the PowerShell Gallery using the same version number
            # Keep looping and bumping the build version number by 1 until an available version number is found
            # Try this process up to 100 times and fail if can't find one
            # This can execute even if the module is not yet in the gallery because unlisted packages can still be present
            $VersionToBuild = GetPSGalleryNextAvailableVersionNumber -ModuleName $ModuleName -VersionToBuild $VersionToBuild
        }
        else {
            $VersionToBuild = [System.Version]::New(
                ([System.Version]$ManifestData.ModuleVersion).Major, 
                ([System.Version]$ManifestData.ModuleVersion).Minor, 
                ([System.Version]$ManifestData.ModuleVersion).Build + 1
            )
        }
    }
    else {
        Write-Verbose "Version to build with is hard coded" -Verbose
        if ($PSGalleryModuleInfo.Version -ne "0.0") {
            Write-Verbose "Module is published to the PowerShell Gallery" -Verbose
            $VersionToBuild = GetPSGalleryNextAvailableVersionNumber -ModuleName $ModuleName -VersionToBuild $VersionToBuild
        }
        else {
            Write-Verbose "Module not published to the PowerShell Gallery, will build with the given version number" -Verbose
        }
    }

    Write-Verbose ("Version to build: '{0}'" -f $VersionToBuild) -Verbose

    return $VersionToBuild
}

function Get-PublicFunctions {
    <#
    .SYNOPSIS
        Build
        Get a list of functions - as functions to export - defined in script files within the Public directory
    .DESCRIPTION
        Get a list of functions - as functions to export - defined in script files within the Public directory
    .EXAMPLE
        PS C:\> <example usage>
        Explanation of what the example does
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [String[]]$Path
    )

    $Files = @(Get-ChildItem $Path -Filter *.ps1 -Recurse)

    foreach ($File in $Files) {
        $tokens = $errors = @()
        $Ast = [System.Management.Automation.Language.Parser]::ParseFile(
            $File.FullName,
            [ref]$tokens,
            [ref]$errors
        )

        if ($errors[0].ErrorId -eq 'FileReadError') {
            throw [InvalidOperationException]::new($errors[0].Message)
        }

        Write-Output $Ast.EndBlock.Statements.Name
    }
}

function Invoke-BuildClean {
    <#
    .SYNOPSIS
        Build
        Empty the contents of the build and release directories. If not exist, create them.
    .DESCRIPTION
        Empty the contents of the build and release directories. If not exist, create them.
    .EXAMPLE
        PS C:\> <example usage>
        Explanation of what the example does
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [String[]]$Path
    )

    foreach ($item in $Path) {
        if (Test-Path $item) {
            Remove-Item -Path $item -Exclude ".gitkeep" -Recurse -Force
        }
        $null = New-Item -Path $item -ItemType "Directory" -Force
    }
}

function New-BuildEnvironmentVariable {
    <#
    .SYNOPSIS
        Build
        Set build and platform specific environment variables.
    .DESCRIPTION
        Set build and platform specific environment variables.
    .EXAMPLE
        PS C:\> New-BuildEnvironmentVariable -Variables @{ VersionToBuild = "1.2.3" } -Platform "GitHubActions"
        
        Writes to GitHub Action's environment variable file to create environment variable "VersionToBuild" with value of "1.2.3".
    #>

    param (
        [Parameter(Mandatory)]
        [Hashtable]$Variable,

        [Parameter(Mandatory)]
        [ValidateSet("GitHubActions")]
        [String[]]$Platform
    )

    switch ($Platform) {
        "GitHubActions" {
            foreach ($var in $Variable.GetEnumerator()) {
                Write-Output ("{0}={1}" -f $var.Key, $var.Value) | Add-Content -Path $env:GITHUB_ENV 
            }
        }
    }
}
#endregion