Indented.Build.psm1

using namespace System.Text

function GetBranchName {
    [OutputType([String])]
    param ( )

    git rev-parse --abbrev-ref HEAD
}

function GetBuildSystem {
    [OutputType([String])]
    param ( )

    if ($env:APPVEYOR -eq $true) { return 'AppVeyor' }
    if ($env:JENKINS_URL)        { return 'Jenkins' }

    return 'Unknown'
}

function GetLastCommitMessage {
    [OutputType([String])]
    param ( )

    return (git log -1 --pretty=%B | Where-Object { $_ } | Out-String).Trim()
}

function GetProjectRoot {
    [OutputType([System.IO.DirectoryInfo])]
    param ( )

    [System.IO.DirectoryInfo](Get-Item (git rev-parse --show-toplevel)).FullName
}

filter GetSourcePath {
    [CmdletBinding()]
    [OutputType([System.IO.DirectoryInfo], [System.IO.DirectoryInfo[]])]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [System.IO.DirectoryInfo]$ProjectRoot
    )

    try {
        Push-Location $ProjectRoot

        # Try and find a match by searching for psd1 files
        $sourcePath = Get-ChildItem .\*\*.psd1 |
            Where-Object { $_.BaseName -eq $_.Directory.Name } |
            ForEach-Object { $_.Directory }

        if ($sourcePath) {
            return $sourcePath
        } else {
            if (Test-Path (Join-Path $ProjectRoot $ProjectRoot.Name)) {
                return [System.IO.DirectoryInfo](Join-Path $ProjectRoot $ProjectRoot.Name)
            }
        }

        throw 'Unable to determine the source path'
    } catch {
        $pscmdlet.ThrowTerminatingError($_)
    } finally {
        Pop-Location
    }
}

function GetVersion {
    [OutputType([Version])]
    param (
        # The path to the a module manifest file.
        [String]$Path
    )

    if ($Path -and (Test-Path $Path)) {
        $manifestContent = Import-PowerShellDataFile $Path
        $versionString = $manifestContent.ModuleVersion

        $version = [Version]'0.0.0'
        if ([Version]::TryParse($versionString, [Ref]$version)) {
            if ($version.Build -eq -1) {
                return [Version]::new($version.Major, $version.Minor, 0)
            } else {
                return $version
            }
        }
    }

    return [Version]'1.0.0'
}

function UpdateVersion {
    [OutputType([Version])]
    param (
        # The current version number.
        [Parameter(Position = 1, ValueFromPipeline = $true)]
        [Version]$Version,

        # The release type.
        [ValidateSet('Build', 'Minor', 'Major', 'None')]
        [String]$ReleaseType = 'Build'
    )

    process {
        $arguments = switch ($ReleaseType) {
            'Major' { ($Version.Major + 1), 0, 0 }
            'Minor' { $Version.Major, ($Version.Minor + 1), 0 }
            'Build' { $Version.Major, $Version.Minor, ($Version.Build + 1) }
            'None'  { return $Version }
        }
        New-Object Version($arguments)
    }
}

function BuildTask {
    <#
    .SYNOPSIS
        Create a build task object.
    .DESCRIPTION
        A build task is a predefined task used to build well-structured PowerShell projects.
    #>


    [CmdletBinding()]
    [OutputType('BuildTask')]
    param (
        # The name of the task.
        [Parameter(Mandatory)]
        [String]$Name,

        # The stage during which the task will be invoked.
        [Parameter(Mandatory)]
        [String]$Stage,

        # Where the task should appear in the build order respective to the stage.
        [Int32]$Order = 1024,

        # The task will only be invoked if the filter condition is true.
        [ScriptBlock]$If = { $true },

        # The task implementation.
        [Parameter(Mandatory)]
        [ScriptBlock]$Definition
    )

    [PSCustomObject]@{
        Name       = $Name
        Stage      = $Stage
        If         = $If
        Order      = $Order
        Definition = $Definition
    } | Add-Member -TypeName 'BuildTask' -PassThru
}

filter Enable-Metadata {
    <#
    .SYNOPSIS
        Enable a metadata property which has been commented out.
    .DESCRIPTION
        This function is derived Get and Update-Metadata from PoshCode\Configuration.
 
        A boolean value is returned indicating if the property is available in the metadata file.
 
        If the property does not exist, or exists more than once within the specified file this command will return false.
    .INPUTS
        System.String
    .EXAMPLE
        Enable-Metadata .\module.psd1 -PropertyName RequiredAssemblies
 
        Enable an existing (commented) RequiredAssemblies property within the module.psd1 file.
    .NOTES
        Change log:
            04/08/2016 - Chris Dent - Created.
    #>


    [CmdletBinding()]
    [OutputType([Boolean])]
    param (
        # A valid metadata file or string containing the metadata.
        [Parameter(ValueFromPipelineByPropertyName, Position = 0)]
        [ValidateScript( { Test-Path $_ -PathType Leaf } )]
        [Alias("PSPath")]
        [String]$Path,

        # The property to enable.
        [String]$PropertyName
    )

    # If the element can be found using Get-Metadata leave it alone and return true
    $shouldEnable = $false
    try {
        $null = Get-Metadata @psboundparameters -ErrorAction Stop
    } catch [System.Management.Automation.ItemNotFoundException] {
        # The function will only execute where the requested value is not present
        $shouldEnable = $true
    } catch {
        # Ignore other errors which may be raised by Get-Metadata except path not found.
        if ($_.Exception.Message -eq 'Path must point to a .psd1 file') {
            $pscmdlet.ThrowTerminatingError($_)
        }
    }
    if (-not $shouldEnable) {
        return $true
    }

    $manifestContent = Get-Content $Path -Raw

    $tokens = $parseErrors = $null
    $ast = [System.Management.Automation.Language.Parser]::ParseInput(
        $manifestContent,
        $Path,
        [Ref]$tokens,
        [Ref]$parseErrors
    )

    # Attempt to find a comment which matches the requested property
    $regex = '^ *# *({0}) *=' -f $PropertyName
    $existingValue = @($tokens | Where-Object { $_.Kind -eq 'Comment' -and $_.Text -match $regex })
    if ($existingValue.Count -eq 1) {
        $manifestContent = $ast.Extent.Text.Remove(
            $existingValue.Extent.StartOffset,
            $existingValue.Extent.EndOffset - $existingValue.Extent.StartOffset
        ).Insert(
            $existingValue.Extent.StartOffset,
            $existingValue.Extent.Text -replace '^# *'
        )

        try {
            Set-Content -Path $Path -Value $manifestContent -NoNewline -ErrorAction Stop
            $true
        } catch {
            $false
        }
    } elseif ($existingValue.Count -eq 0) {
        # Item not found
        Write-Warning "Cannot find disabled property '$PropertyName' in $Path"
        $false
    } else {
        # Ambiguous match
        Write-Warning "Found more than one '$PropertyName' in $Path"
        $false
    }
}

function Export-BuildScript {
    <#
    .SYNOPSIS
        Export a build script for use with Invoke-Build.
    .DESCRIPTION
        Export a build script for use with Invoke-Build.
    .INPUTS
        BuildInfo (from Get-BuildInfo)
    #>


    [CmdletBinding()]
    [OutputType([String])]
    param (
        # The build information object is used to determine which tasks are applicable.
        [Parameter(ValueFromPipeline)]
        [PSTypeName('BuildInfo')]
        [PSObject]$BuildInfo = (Get-BuildInfo),

        # By default the build system is automatically discovered. The BuildSystem parameter overrides any automatically discovered value. Tasks associated with the build system are added to the generated script.
        [String]$BuildSystem,

        # If specified, the build script will be written to the the specified path. By default the build script is written (as a string) to the console.
        [String]$Path
    )

    if ($BuildSystem) {
        $BuildInfo.BuildSystem = $BuildSystem
    }

    $script = [StringBuilder]::new()
    $null = $script.AppendLine('param (').
                    AppendLine(' [PSTypeName("BuildInfo")]').
                    AppendLine(' [ValidateCount(1, 1)]').
                    AppendLine(' [PSObject[]]$BuildInfo').
                    AppendLine(')').
                    AppendLine()

    $tasks = $BuildInfo | Get-BuildTask | Sort-Object {
        switch ($_.Stage) {
            'Setup'   { 1; break }
            'Build'   { 2; break }
            'Test'    { 3; break }
            'Pack'    { 4; break }
            'Publish' { 5; break }
        }
    }, Order, Name

    # Build the wrapper tasks and insert the block at the top of the script
    $taskSets = [StringBuilder]::new()
    # Add a default task set
    $null = $taskSets.AppendLine('task default Setup,').
                      AppendLine(' Build,').
                      AppendLine(' Test,').
                      AppendLine(' Pack').
                      AppendLine()

    $tasks | Group-Object Stage | ForEach-Object {
        $indentLength = 'task '.Length + $_.Name.Length
        $null = $taskSets.AppendFormat('task {0} {1}', $_.Name, $_.Group[0].Name)
        foreach ($task in $_.Group | Select-Object -Skip 1) {
            $null = $taskSets.Append(',').
                              AppendLine().
                              AppendFormat('{0} {1}', (' ' * $indentLength), $task.Name)
        }
        $null = $taskSets.AppendLine().
                          AppendLine()
    }
    $null = $script.Append($taskSets.ToString())

    # Add supporting functions to create the BuildInfo object.
    (Get-Command Get-BuildInfo).ScriptBlock.Ast.FindAll(
        {
            param ( $ast )

            $ast -is [Management.Automation.Language.CommandAst]
        },
        $true
    ) | ForEach-Object GetCommandName |
        Select-Object -Unique |
        Sort-Object |
        ForEach-Object {
            $commandInfo = Get-Command $_

            if ($commandInfo.Source -eq $myinvocation.MyCommand.ModuleName) {
                $null = $script.AppendFormat('function {0} {{', $commandInfo.Name).
                                Append($commandInfo.Definition).
                                AppendLine('}').
                                AppendLine()
            }
        }

    'Enable-Metadata', 'Get-BuildInfo', 'Get-BuildItem' | ForEach-Object {
        $null = $script.AppendFormat('function {0} {{', $_).
                        Append((Get-Command $_).Definition).
                        AppendLine('}').
                        AppendLine()
    }

    # Add a generic task which allows BuildInfo to be retrieved
    $null = $script.AppendLine('task GetBuildInfo {').
                    AppendLine(' Get-BuildInfo').
                    AppendLine('}').
                    AppendLine()

    # Add a task that allows all all build jobs within the current project to run
    $null = $script.AppendLine('task BuildAll {').
                    AppendLine(' [String[]]$task = ${*}.Task.Name').
                    AppendLine().
                    AppendLine(' # Re-submit the build request without the BuildAll task').
                    AppendLine(' if ($task.Count -eq 1 -and $task[0] -eq "BuildAll") {').
                    AppendLine(' $task = "default"').
                    AppendLine(' } else {').
                    AppendLine(' $task = $task -ne "BuildAll"').
                    AppendLine(' }').
                    AppendLine().
                    AppendLine(' Get-BuildInfo | ForEach-Object {').
                    AppendLine(' Write-Host').
                    AppendLine(' "Building {0} ({1})" -f $_.ModuleName, $_.Version | Write-Host -ForegroundColor Green').
                    AppendLine(' Write-Host').
                    AppendLine(' Invoke-Build -BuildInfo $_ -Task $task').
                    AppendLine(' }').
                    AppendLine('}').
                    AppendLine()

    $tasks | ForEach-Object {
        $null = $script.AppendFormat('task {0}', $_.Name)
        if ($_.If -and $_.If.ToString().Trim() -ne '$true') {
            $null = $script.AppendFormat(' -If ({0})', $_.If.ToString().Trim())
        }
        $null = $script.AppendLine(' {').
                        AppendLine($_.Definition.ToString().Trim("`r`n")).
                        AppendLine('}').
                        AppendLine()
    }

    if ($Path) {
        $script.ToString() | Set-Content $Path
    } else {
        $script.ToString()
    }
}

function Get-BuildInfo {
    <#
    .SYNOPSIS
        Get properties required to build the project.
    .DESCRIPTION
        Get the properties required to build the project, or elements of the project.
    .EXAMPLE
        Get-BuildInfo
 
        Get build information for the current or any child directories.
    #>


    [CmdletBinding()]
    [OutputType('BuildInfo')]
    param (
        # The tasks to execute, passed to Invoke-Build. BuildType is expected to be a broad description of the build, encompassing a set of tasks.
        [String[]]$BuildType = @('Setup', 'Build', 'Test'),

        # The release type. By default the release type is Build and the build version will increment.
        #
        # If the last commit message includes the phrase "major release" the release type will be reset to Major; If the last commit meessage includes "release" the releasetype will be reset to Minor.
        [ValidateSet('Build', 'Minor', 'Major', 'None')]
        [String]$ReleaseType = 'Build',

        # Generate build information for the specified path.
        [ValidateScript( { Test-Path $_ -PathType Container } )]
        [String]$Path = $pwd.Path
    )

    try {
        $Path = $pscmdlet.GetUnresolvedProviderPathFromPSPath($Path)
        Push-Location $Path

        $projectRoot = GetProjectRoot
        $projectRoot | GetSourcePath | ForEach-Object {
            $buildInfo = [PSCustomObject]@{
                ModuleName            = $moduleName = $_.Parent.GetDirectories($_.Name).Name
                BuildType             = $BuildType
                ReleaseType           = $ReleaseType
                BuildSystem           = GetBuildSystem
                Version               = '1.0.0'
                CodeCoverageThreshold = 0.8
                Repository            = [PSCustomObject]@{
                    Branch                = GetBranchName
                    LastCommitMessage     = GetLastCommitMessage
                }
                Path                  = [PSCustomObject]@{
                    ProjectRoot           = $projectRoot
                    Source                = $_
                    SourceManifest        = Join-Path $_ ('{0}.psd1' -f $moduleName)
                    Package               = ''
                    Output                = $output = [System.IO.DirectoryInfo](Join-Path $projectRoot 'output')
                    Nuget                 = Join-Path $output 'packages'
                    Manifest              = ''
                    RootModule            = ''
                }
            } | Add-Member -TypeName 'BuildInfo' -PassThru

            $buildInfo.Version = GetVersion $buildInfo.Path.SourceManifest | UpdateVersion -ReleaseType $ReleaseType

            $buildInfo.Path.Package = [System.IO.DirectoryInfo](Join-Path $buildInfo.Path.ProjectRoot $buildInfo.Version)
            if ($buildInfo.Path.ProjectRoot.Name -ne $buildInfo.ModuleName) {
                $buildInfo.Path.Package = [System.IO.DirectoryInfo][System.IO.Path]::Combine($buildInfo.Path.ProjectRoot, 'build', $buildInfo.ModuleName, $buildInfo.Version)
                $buildInfo.Path.Output = [System.IO.DirectoryInfo][System.IO.Path]::Combine($buildInfo.Path.ProjectRoot, 'build', 'output', $buildInfo.ModuleName)
                $buildInfo.Path.Nuget = [System.IO.DirectoryInfo][System.IO.Path]::Combine($buildInfo.Path.ProjectRoot, 'build', 'output', 'packages')
            }

            $buildInfo.Path.Manifest = [System.IO.FileInfo](Join-Path $buildInfo.Path.Package ('{0}.psd1' -f $buildInfo.ModuleName))
            $buildInfo.Path.RootModule = [System.IO.FileInfo](Join-Path $buildInfo.Path.Package ('{0}.psm1' -f $buildInfo.ModuleName))

            $buildInfo
        }
    } catch {
        $pscmdlet.ThrowTerminatingError($_)
    } finally {
        Pop-Location
    }
}

function Get-BuildItem {
    <#
    .SYNOPSIS
        Get source items.
    .DESCRIPTION
        Get items from the source tree which will be consumed by the build process.
 
        This function centralises the logic required to enumerate files and folders within a project.
    #>


    [CmdletBinding()]
    [OutputType([System.IO.FileInfo], [System.IO.DirectoryInfo])]
    param (
        # Gets items by type.
        #
        # ShouldMerge - *.ps1 files from enum*, class*, priv*, pub* and InitializeModule if present.
        # Static - Files which are not within a well known top-level folder. Captures help content in en-US, format files, configuration files, etc.
        [Parameter(Mandatory)]
        [ValidateSet('ShouldMerge', 'Static')]
        [String]$Type,

        # BuildInfo is used to determine the source path.
        [Parameter(Mandatory, ValueFromPipeline)]
        [PSTypeName('BuildInfo')]
        [PSObject]$BuildInfo,

        # Exclude script files containing PowerShell classes.
        [Switch]$ExcludeClass
    )

    Push-Location $buildInfo.Path.Source

    $itemTypes = [Ordered]@{
        enumeration    = 'enum*'
        class          = 'class*'
        private        = 'priv*'
        public         = 'pub*'
        initialisation = 'InitializeModule.ps1'
    }

    if ($Type -eq 'ShouldMerge') {
        foreach ($itemType in $itemTypes.Keys) {
            if ($itemType -ne 'class' -or ($itemType -eq 'class' -and -not $ExcludeClass)) {
                $items = Get-ChildItem $itemTypes[$itemType] -Recurse -ErrorAction SilentlyContinue |
                    Where-Object { -not $_.PSIsContainer -and $_.Extension -eq '.ps1' -and $_.Length -gt 0 }

                $orderingFilePath = Join-Path $itemTypes[$itemType] 'order.txt'
                if (Test-Path $orderingFilePath) {
                    [String[]]$order = Get-Content (Resolve-Path $orderingFilePath).Path

                    $items = $items | Sort-Object {
                        $index = $order.IndexOf($_.BaseName)
                        if ($index -eq -1) {
                            [Int32]::MaxValue
                        } else {
                            $index
                        }
                    }, Name
                }

                $items
            }
        }
    } elseif ($Type -eq 'Static') {
        [String[]]$exclude = $itemTypes.Values + '*.config', 'test*', 'doc', 'help', '.build*.ps1'

        # Should work, fails when testing.
        # Get-ChildItem -Exclude $exclude
        foreach ($item in Get-ChildItem) {
            $shouldExclude = $false

            foreach ($exclusion in $exclude) {
                if ($item.Name -like $exclusion) {
                    $shouldExclude = $true
                }
            }

            if (-not $shouldExclude) {
                $item
            }
        }
    }

    Pop-Location
}

function Get-BuildTask {
    <#
    .SYNOPSIS
        Get build tasks.
    .DESCRIPTION
        Get the build tasks deemed to be applicable to this build.
 
        If the ListAvailable parameter is supplied, all available tasks will be returned.
    #>


    [CmdletBinding(DefaultParameterSetName = 'ForBuild')]
    [OutputType('BuildTask')]
    param (
        # A build information object used to determine which tasks will apply to the current build.
        [Parameter(Mandatory, Position = 1, ValueFromPipeline, ParameterSetName = 'ForBuild')]
        [PSTypeName('BuildInfo')]
        [PSObject]$BuildInfo,

        # Filter tasks by task name.
        [String]$Name = '*',

        # List all available tasks, irrespective of conditions applied to the task.
        [Parameter(Mandatory, ParameterSetName = 'List')]
        [Switch]$ListAvailable
    )

    begin {
        if (-not $Name.EndsWith('.ps1') -and -not $Name.EndsWith('*')) {
            $Name += '.ps1'
        }
        $path = Join-Path $psscriptroot 'task'

        if (-not $Script:buildTaskCache) {
            $Script:buildTaskCache = @{}
            Get-ChildItem $path -File -Filter *.ps1 -Recurse | ForEach-Object {
                $task = . $_.FullName
                $Script:buildTaskCache.Add($task.Name, $task)
            }
        }
    }

    process {
        if ($buildInfo) {
            Push-Location $buildInfo.Path.Source
        }

        try {
            $Script:buildTaskCache.Values | Where-Object {
                Write-Verbose ('Evaluating {0}' -f $_.Name)

                $_.Name -like $Name -and ($ListAvailable -or (& $_.If))
            }
        } catch {
            Write-Error -Message ('Failed to evaluate task condition: {0}' -f $_.Exception.Message) -ErrorId 'ConditionEvaluationFailed'
        }

        if ($buildInfo) {
            Pop-Location
        }
    }
}

function Get-ChildBuildInfo {
    <#
    .SYNOPSIS
        Get items which can be built from child paths of the specified folder.
    .DESCRIPTION
        A folder may contain one or more items which can be built, this command may be used to discover individual projects.
    #>


    [CmdletBinding()]
    [OutputType('BuildInfo')]
    param (
        # The starting point for the build search.
        [String]$Path = $pwd.Path,

        # Recurse to the specified depth when attempting to find projects which can be built.
        [Int32]$Depth = 4
    )

    Get-ChildItem $Path -Filter *.psd1 -File -Depth $Depth | Where-Object { $_.BaseName -eq $_.Directory.Name } | ForEach-Object {
        $currentPath = $_.Directory.FullName
        try {
            Get-BuildInfo -Path $currentPath
        } catch {
            Write-Debug ('{0}: {1}' -f $currentPath, $_.Exception.Message)
        }
    }
}

filter Start-Build {
    <#
    .SYNOPSIS
        Start a build.
    .DESCRIPTION
        Start a build using Invoke-Build. If a build script is not present one will be created.
 
        If a build script exists it will be used. If the build script exists this command is superfluous.
    #>


    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingCmdletAliases', '')]
    [CmdletBinding()]
    [OutputType([Void])]
    param (
        # The task categories to execute.
        [String[]]$BuildType = @('Setup', 'Build', 'Test'),

        # The release type to create.
        [ValidateSet('Build', 'Minor', 'Major', 'None')]
        [String]$ReleaseType = 'Build',

        [Parameter(ValueFromPipeline)]
        [PSTypeName('BuildInfo')]
        [PSObject[]]$BuildInfo = (Get-BuildInfo -BuildType $BuildType -ReleaseType $ReleaseType),

        [String]$ScriptName = '.build.ps1'
    )

    foreach ($instance in $BuildInfo) {
        try {
                # If a build script exists in the project root, use it.
            if (Test-Path (Join-Path $instance.Path.ProjectRoot $ScriptName)) {
                $buildScript = Join-Path $instance.Path.ProjectRoot $ScriptName
            } else {
                # Otherwise assume the project contains more than one module and create a module specific script.
                $buildScript = Join-Path $instance.Path.Source $ScriptName
            }

            # Remove the script if it is created by this process. Export-BuildScript can be used to create a persistent script.
            $shouldClean = $false
            if (-not (Test-Path $buildScript)) {
                $instance | Export-BuildScript -Path $buildScript
                $shouldClean = $true
            }

            Import-Module InvokeBuild -Global
            Invoke-Build -Task $BuildType -File $buildScript -BuildInfo $instance
        } catch {
            throw
        } finally {
            if ($shouldClean) {
                Remove-Item $buildScript
            }
        }
    }
}

function InitializeModule {
    # Fill the build task cache. This makes the module immune to source file deletion once the cache is filled (when building itself).
    $null = Get-BuildTask -ListAvailable
}

InitializeModule