PwSh.Fw.BuildHelpers.psm1

$Script:PWSHFW_BUILDHELPERS_DIR = $PSScriptRoot

if ($PSVersionTable.PSVersion -ge "6.0.0") {
    Write-Verbose "Loading common.class.ps1 from $PSScriptRoot/Classes"
    . $PSScriptRoot/Classes/common.class.ps1
}

<#
.SYNOPSIS
Create a new project.yml file
 
.DESCRIPTION
Create an empty project.yml file at the location of your choice. Preferably in the root of your new project workspace.
 
.PARAMETER Path
Existing path to write project.yml file
 
.PARAMETER PassThru
If specified, return the content of the project object instead of the absolute location of the file
 
.EXAMPLE
$project = New-ProjectFile -PassThru
 
.NOTES
General notes
#>

function New-ProjectFile {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')][OutputType([String], [boolean])]Param (
        # Destination path where to create project. Use -Force to override existing path
        [Parameter(ValueFromPipeLine = $true)][string]$Path,

        # The namespace of the project
        [string]$Namespace,

        # The name of the project
        [string]$ProjectName,

        # The codename of the project
        [string]$Codename,

        # The user-friendly name of the project
        [string]$DisplayName,

        # Short description of the project
        [string]$ProjectDescription,

        # name of the company
        [string]$CompanyName,

        # copyright string
        [string]$Copyright,

        # Target architecture of the project
        [ValidateSet( [PwShFwSupportedArchitectures] )]
        [Parameter()][string]$Architecture = "all",

        [string]$LicenseURL,
        [string]$LicenseFILE = "LICENSE",
        [switch]$PassThru
    )
    Begin {
        # Write-EnterFunction
    }

    Process {
        $Project = [PSCustomObject]@{
            Namespace = $Namespace
            Name = $ProjectName
            DisplayName = $DisplayName
            Codename = $Codename
            Architecture = $Architecture
            Authors = @("you")
            Owner = "you"
            LicenseURL = $LicenseURL
            LicenseFile = $LicenseFile
            Description = $ProjectDescription
            ReleaseNotes = ""
            Copyright = $Copyright
            Company = $CompanyName
            Depends = @()
        }

        If (Test-Path $Path) {
            if ($PSCmdlet.ShouldProcess("ShouldProcess?")) {
                $Project | ConvertTo-Yaml | Set-Content $Path/project.yml -Encoding utf8
            }
        } else {
            Write-Error "'$Path' not found."
            return $false
        }
        if ($PassThru) {
            return $Project
        } else {
            return (Resolve-Path "$Path/project.yml").Path
        }
    }

    End {
        # Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Create a new project structure
 
.DESCRIPTION
Create minimal files and folders a project needs : CHANGELOG.md, VERSION, README.md, LICENSE, etc...
 
.EXAMPLE
New-ProjectStructure -Path ~/Development/MyNewProject -ProjectName "My New Project" -License mit
 
.NOTES
General notes
#>

function New-ProjectStructure {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    [OutputType([Boolean])]
    Param (
        # Destination path where to create project. Use -Force to override existing path
        [Parameter(ValueFromPipeLine = $true)][string]$Path,

        # The namespace of the project
        [string]$Namespace,

        # The name of the project
        [string]$ProjectName,

        # The codename of the project
        [string]$Codename,

        # The user-friendly name of the project
        [string]$DisplayName,

        # Short description of the project
        [string]$ProjectDescription,

        # name of the company
        [string]$CompanyName,

        # copyright string
        [string]$Copyright,

        # License under which the project is shipped
        [ValidateSet( [PwshFwSupportedLicenses] )]
        [Parameter()][string]$License = "unlicense",

        # Target architecture of the project
        [ValidateSet( [PwShFwSupportedArchitectures] )]
        [Parameter()][string]$Architecture = "all",

        # Used to override / overwrite things
        [switch]$Force
    )
    Begin {
        Write-EnterFunction
    }

    Process {

        try {
            $null = New-Item -Path $Path -ItemType Directory -Force:$Force
        } catch {
            Write-Error $_
            return $false
        }

        if ($PSCmdlet.ShouldProcess("ShouldProcess?")) {
            # files
            @"
# $ProjectName
 
$ProjectDescription
 
"@
 | Set-Content "$Path/README.md" -Encoding utf8NoBOM
            $jLicense = Invoke-RestMethod https://gitlab.com/api/v4/templates/licenses/$License
            $jLicense.content | Set-Content "$Path/LICENSE" -Encoding utf8NoBOM
            @"
# Changelog
All notable changes to this project will be documented in this file.
 
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
 
## [unreleased]
 
### Added
 
### Changed
 
### Deprecated
 
### Removed
 
### Fixed
 
### Security
"@
 | Set-Content "$Path/CHANGELOG.md" -Encoding utf8NoBOM
            if (!(Test-FileExist "$Path/VERSION")) { "0.0.0" | Set-Content "$Path/VERSION" -NoNewline -Encoding utf8NoBOM }
            $null = New-ProjectFile -Path $Path -ProjectName $ProjectName -ProjectDescription $ProjectDescription -LicenseURL $l.html_url -LicenseFile LICENSE -Architecture $Architecture
            # folders
            New-Item -Path "$Path/scripts" -ItemType Directory -ErrorAction SilentlyContinue
            New-Item -Path "$Path/src" -ItemType Directory -ErrorAction SilentlyContinue
            New-Item -Path "$Path/tests" -ItemType Directory -ErrorAction SilentlyContinue
        }

        return (Test-ProjectStructure -Path $Path)
    }

    End {
        Write-LeaveFunction
    }
}

function New-Project {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    [OutputType([Boolean])]
    Param (
        # Destination path where to create project. Use -Force to override existing path
        [Parameter(ValueFromPipeLine = $true)][string]$Path,

        # The namespace of the project
        [string]$Namespace,

        # The name of the project
        [string]$ProjectName,

        # The codename of the project
        [string]$Codename,

        # The user-friendly name of the project
        [string]$DisplayName,

        # Short description of the project
        [string]$ProjectDescription,

        # name of the company
        [string]$CompanyName,

        # copyright string
        [string]$Copyright,

        # License under which the project is shipped
        [ValidateSet( [PwshFwSupportedLicenses] )]
        [Parameter()][string]$License = "unlicense",

        # Target architecture of the project
        [ValidateSet( [PwShFwSupportedArchitectures] )]
        [Parameter()][string]$Architecture = "all",

        # Used to override / overwrite things
        [switch]$Force
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if (New-ProjectStructure @PSBoundParameters) {
            New-ProjectFile @PSBoundParameters
        }
        return $DestinationPath
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Test if a project is compliant with BuildHelpers modules
 
.DESCRIPTION
Check the completeness of a project to be used with BuildHelpers modules.
 
.PARAMETER Path
The root path of the project to test
 
.EXAMPLE
Test-ProjectStructure -Path c:\projects\myNewProject
 
.NOTES
General notes
#>

function Test-ProjectStructure {
    [CmdletBinding()][OutputType([boolean])]Param (
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][string]$Path
    )
    Begin {
        # Write-EnterFunction
    }

    Process {
        $rc = $true
        if (Test-Path $Path -PathType Container) {
            Write-Host -ForegroundColor Green "[+] $Path exist"
        } else {
            Write-Host -ForegroundColor Red "[-] $Path does not exist"
            $rc = $false
        }
        foreach ($f in @('project.yml', 'CHANGELOG.md', 'README.md', 'LICENSE', 'VERSION')) {
            if (Test-Path $Path/$f -PathType Leaf) {
                Write-Host -ForegroundColor Green "[+] $((Resolve-Path $Path/$f).Path) exist"
            } else {
                Write-Host -ForegroundColor Red "[-] $Path/$f does not exist"
                $rc = $false
            }
        }

        return $rc
    }

    End {
        # Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Get changelog in a object form
 
.DESCRIPTION
Slice CHANGELOG file and build a hashtable with each version of a changelog.
 
.PARAMETER Path
Path to the CHANGELOG file
 
.EXAMPLE
Get-ProjectChangelog -Path ./CHANGELOG.md
 
.NOTES
Each new version must follow the https://keepachangelog.com/ format. That is :
## [1.0.0]
If this pattern is not applied, please ask for a request, or fix your changelog format
 
#>

function Get-ProjectChangelog {
    [CmdletBinding()][OutputType([hashtable])]Param (
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][string]$Path
    )
    Begin {
        Write-EnterFunction
        $TMP = [system.io.path]::GetTempPath()
    }

    Process {
        $h = @{}
        if (fileExist $Path) {
            # CHANGELOG is present
            $CHANGELOG = Resolve-Path $Path
            # parse CHANGELOG.md
            # below command line explained :
            # Get-Content $CHANGELOG | Select-String -NotMatch -Pattern '(?ms)^$' --> get CHANGELOG content without empty lines
            # -replace "^## ", "`n## " --> add empty lines only before h2 title level (## in markdown). This way, we got proper paragraph from ## tag to next empty line
            (Get-Content $CHANGELOG | Select-String -NotMatch -Pattern '(?ms)^$') -replace "^## ", "`n## " | Out-File $TMP/changelog.tmp
            # To extract correct §, we need to read the file with -Raw parameter
            # (?ms) sets regex options m (treats ^ and $ as line anchors) and s (makes . match \n (newlines) too`.
            # ^## .*? matches any line starting with ## and any subsequent characters *non-greedily* (non-greedy is '.*?' set of characters at the end of pattern).
            # -AllMatches to get... well... all matches
            # [1] because the last changelog is always [1] from array of matches. [0] is ## [Unreleased]
            $MESSAGES = Get-Content -Raw $TMP/changelog.tmp | Select-String -Pattern '(?ms)^## .*?^$' -AllMatches
            # edevel("MESSAGES = " + $MESSAGES.Matches[0])
            # reduce title level to render more readable in github release page
            # $MESSAGE = ($MESSAGES.Matches[0]) -replace "# ", "## " -replace "'", "``" -replace "unreleased", "$VERSION"
            # Write-Debug "MESSAGE = $MESSAGE"
            foreach ($msg in $MESSAGES.Matches) {
                if ($msg.value -match "^## \[(.*)\]") {
                    $version = $Matches[1]
                    $h.Add($version, $msg.Value)
                }
            }
        } else {
            eerror "Changelog file at '$Path' not found. See https://keepachangelog.com/en/1.0.0/ to begin."
            return $null
        }
        return $h
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Load project data into an object
 
.DESCRIPTION
Load static and dynamic data from project.yml but also from VERSION and CHANGELOG files
 
.PARAMETER Path
Path to the project
 
.EXAMPLE
Get-Project -Path /my/awsome/project
 
.NOTES
General notes
#>

function Get-Project {
    [CmdletBinding()][OutputType([Object])]Param (
        [Parameter(Mandatory = $false, ValueFromPipeLine = $true)][string]$Path = (Get-Location).Path
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if ((Test-ProjectStructure -Path $Path) -eq $false) {
            return $null
        }
        # compute BUILD number and BRANCH name
        $BUILD = $env:BUILD ? $env:BUILD : [int32]0
        $BRANCH = "develop"
        # APPVEYOR: honor appveyor build number
        if ($null -ne $env:APPVEYOR_BUILD_NUMBER) { $BUILD = [int32]$env:APPVEYOR_BUILD_NUMBER}
        if ($null -ne $env:APPVEYOR_REPO_BRANCH) { $BRANCH = $env:APPVEYOR_REPO_BRANCH}
        # TRAVIS: honor travis-ci build number
        if ($null -ne $env:TRAVIS_BUILD_NUMBER) { $BUILD = [int32]$env:TRAVIS_BUILD_NUMBER}
        if ($null -ne $env:TRAVIS_BRANCH) { $BRANCH = $env:TRAVIS_BRANCH}
        # GITLAB: honor gitlab-ci pipeline project's build number
        if ($null -ne $env:CI_PIPELINE_IID) { $BUILD = [int32]$env:CI_PIPELINE_IID}
        if ($null -ne $env:CI_COMMIT_BRANCH) { $BRANCH = $env:CI_COMMIT_BRANCH }
        # consider tag branch like master branch to correctly build project.version number
        if (![string]::IsNullOrEmpty($env:CI_COMMIT_TAG)) { $BRANCH = "master" }
        # consider branch "main" like "master"
        if ($BRANCH -eq "main") { $BRANCH = "master" }

        # get project from project.yml
        $project = Get-Content $Path/project.yml -Raw | ConvertFrom-Yaml
        $project.Root = (Resolve-Path $Path).Path.TrimEnd('/\')

        # compute Version :
        # master branch is x.y.z.#
        # other branches are x.y.z-pre#
        if ($BRANCH -eq "master") {
            $project.version = "$(Get-Content $Path/VERSION).$($BUILD)"
            $project.PreRelease = ""
            $project.fqVersion = $project.version
        } else {
            $project.Version = (Get-Content $Path/VERSION)
            # zero-padd left with BUILD less than 10
            $project.PreRelease = "-pre{0:d2}" -f $BUILD
            $project.fqVersion = "$($project.Version)-$($project.PreRelease)"
        }
        $project.Build = $BUILD

        # fill release notes with changelog
        $changelog = Get-ProjectChangelog -Path $Path/CHANGELOG.md
        if ($changelog.ContainsKey($project.version)) {
            $project.releaseNotes = $changelog[$project.version]
        } elseif ($changelog.ContainsKey("unreleased")) {
            $project.releaseNotes = $changelog["unreleased"]
        }
        return $project
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Initialize a build tree following a user-defined map
 
.DESCRIPTION
Populate the dist/ folder with data found in src/ folder following user-defined rules
 
.PARAMETER Map
Map or Rules to follow
 
.PARAMETER Source
Source folder. Usually src/. Contains the source of your program
 
.PARAMETER Destination
Destination folder. Usually dist/. Contains the build tree of your program
 
.EXAMPLE
Initialize-BuildTree @{"bin" = "bin", "share" = "usr/share"} -Source ./src -Destination ./dist
 
Populate the build tree to build a linux program. It will copy the source 'share' folder to 'usr/share' to match the target linux system
 
.EXAMPLE
Initialize-BuildTree @{"bin" = "$($project.name)", "share" = "$($project.name)/lib"} -Source ./src -Destination ./dist
 
Populate the build tree to build a windows program. It will copy the source 'share' folder to a folder named after the project's name to match the Program Files naming convention.
 
.NOTES
General notes
#>

function Initialize-BuildTree {
    [CmdletBinding()]
    [OutputType([Boolean])]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][hashtable]$Map,
        [string]$Source = "src",
        [string]$Destination = "dist",
        [switch]$Force
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        $rc = $false
        try {
            $null = New-Item "$Destination" -ItemType Directory -Force
            $Destination = (Resolve-Path -Path $Destination).Path

            $null = Remove-Item $Destination/* -Recurse -Force -ErrorAction SilentlyContinue

            Add-ToBuildTree @PSBoundParameters
        } catch {
            eerror $_
            $rc = $false
        }
        return $rc
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Add data to a build tree following a user-defined map
 
.DESCRIPTION
Populate the dist/ folder with data found in src/ folder following user-defined rules without erasing existing data
 
.PARAMETER Map
Map or Rules to follow
 
.PARAMETER Source
Source folder. Usually src/. Contains the source of your program
 
.PARAMETER Destination
Destination folder. Usually dist/. Contains the build tree of your program
 
.EXAMPLE
Initialize-BuildTree @{"bin" = "bin", "share" = "usr/share"} -Source ./src -Destination ./dist
 
Populate the build tree to build a linux program. It will copy the source 'share' folder to 'usr/share' to match the target linux system
 
.EXAMPLE
Initialize-BuildTree @{"bin" = "$($project.name)", "share" = "$($project.name)/lib"} -Source ./src -Destination ./dist
 
Populate the build tree to build a windows program. It will copy the source 'share' folder to a folder named after the project's name to match the Program Files naming convention.
 
.NOTES
General notes
#>

function Add-ToBuildTree {
    [CmdletBinding()]
    [OutputType([String])]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][hashtable]$Map,
        [string]$Source = "src",
        [string]$Destination = "dist",
        [switch]$Force
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        foreach ($k in $Map.Keys) {
            $value = $Map.$k
            if ([string]::IsNullOrEmpty($value)) { $value = "." }
            if (Test-DirExist "$Source/$k") {
                $null = New-Item "$Destination/$value" -ItemType Directory -Force -Verbose:$Verbose
                $null = Copy-Item -Path "$Source/$k/*" -Destination "$Destination/$value/" -Recurse -Force:$Force -Verbose:$Verbose
            } elseif (Test-FileExist "$Source/$k") {
                # file can be copied to a subdirectory in Destination/
                # $dst = Split-Path "$Destination/$value" -Parent
                # if (![string]::IsNullOrEmpty($dst)) {
                # $null = New-Item "$dst" -ItemType Directory -Force
                # }
                $dst = "$Destination/$value"
                $null = New-Item "$dst" -ItemType Directory -Force -Verbose:$Verbose
                $null = Copy-Item -Path "$Source/$k" -Destination "$dst/" -Force:$Force -Verbose:$Verbose
            } else {
                Write-Warning "Resource '$k' not found in $Source."
            }
        }

        # remove unused files
        $null = Get-ChildItem -Path $Destination -Recurse -Filter ".gitkeep" -Force | Remove-Item -Force
        $rc = $true
    }

    End {
        Write-LeaveFunction
    }
}