codaamok.build.psm1

#region Private functions
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 functions
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 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

    foreach ($FunctionType in "Private","Public") {
        '#region {0} functions' -f $FunctionType | Add-Content -Path $RootModule

        $Files = @(Get-ChildItem $DevModulePath\$FunctionType -Filter *.ps1 -Recurse)

        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 $FunctionType | Add-Content -Path $RootModule
        Write-Output "" | 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)]
        [String[]]$Path
    ) 

    $ProcessScript = New-Item -Path $BuildRoot\build\$Script:ModuleName\Process.ps1 -ItemType "File" -Force
    $Files = @(Get-ChildItem $Path -Filter *.ps1)

    foreach ($File in $Files) {
        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 ($Files.IndexOf($File) -ne ($Files.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-Verbose ("# {0}" -f $Property)

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

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

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

    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.IsPresent) {
            # 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
        }
        else {
            $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 
            }
        }
    }
}

function Install-BuildModules {
    <#
    .SYNOPSIS
        Setup
        Install, or update, and import build-dependent modules
    .DESCRIPTION
        Install, or update, and import build-dependent modules
    .EXAMPLE
        PS C:\> Install-BuildModules
        
        Installs the default build modules "PlatyPS","ChangelogManagement","InvokeBuild" if they're not installed, updates them for the first run if they are installed, and finally imports them.
    #>

    [CmdletBinding()]
    param (
        [Parameter()]
        [String[]]$Module = @("PlatyPS","ChangelogManagement","InvokeBuild")
    )

    if (-not (Get-Module $Module) -And (Get-Module $Module -ListAvailable)) {
        # If installed but not imported, try and update them - good for local development, just makes the first run a little delayed
        Update-Module $Module
    }
    elseif (-not (Get-Module $Module -ListAvailable)) {
        Install-Module $Module -Scope CurrentUser -Force
    }

    Import-Module $Module -Force
}

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\$ModuleName",
        "$Path\.github\workflows"
        "$Path\$ModuleName\ScriptsToProcess",
        "$Path\$ModuleName\Files",
        "$Path\$ModuleName\Private",
        "$Path\$ModuleName\Public",
        "$Path\$ModuleName\en-US"
    ) | ForEach-Object {
        New-Item -Path $_ -ItemType Directory -Force
        New-Item -Path $_\.gitkeep -ItemType File -Force
    }

    #Create the module and related files
    $GitIgnorePath = Join-Path -Path $Path -ChildPath ".gitignore"
    $ModuleScript = "{0}.psm1" -f $ModuleName
    $ModuleScriptPath = Join-Path -Path $Path -ChildPath $ModuleScript
    $ModuleManifest = "{0}.psd1" -f $ModuleName
    $ModuleManifestPath = Join-Path -Path $Path -ChildPath $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 $ModuleManifestPath -Force
    @(
        'build/*'
        'release/*'
        '!*.gitkeep'
    ) | Set-Content -Path $GitIgnorePath

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

    $NewModuleManifestSplat = @{
        Path                = Join-Path -Path $Path -ChildPath $ModuleName | Join-Path -ChildPath $ModuleManifest
        RootModule          = $ModuleScript
        Description         = $Description
        PowerShellVersion   = $PowerShellVersion
        Author              = $Author
        FunctionsToExport   = '*'
    }

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

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

    New-ModuleManifest @NewModuleManifestSplat

    # Copy the public/exported functions into the public folder, private functions into private folder

}

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"

    # Check for files within the ModuleBase directory aswell the Files subfolder in case this command is being used during development of codaamok.build itself
    @(
        [PSCustomObject]@{
            File = "{0}\invoke.build.ps1" -f $Module.ModuleBase
            DestinationPath = $DestinationPath
        },
        [PSCustomObject]@{
            File = "{0}\Files\invoke.build.ps1" -f $Module.ModuleBase
            DestinationPath = $DestinationPath
        },
        [PSCustomObject]@{
            File = "{0}\deploy-powershellgallery.yml" -f $Module.ModuleBase
            DestinationPath = "{0}\.github\workflows" -f $DestinationPath
        },
        [PSCustomObject]@{
            File = "{0}\Files\deploy-powershellgallery.yml" -f $Module.ModuleBase
            DestinationPath = "{0}\.github\workflows" -f $DestinationPath
        }
    ) | ForEach-Object {
        if (Test-Path $_.File) {
            New-Item -Path $_.DestinationPath -ItemType "Directory" -Force
            Copy-Item -Path $_.File -Destination $_.DestinationPath -Confirm
        }
    }
}
#endregion