Indented.Build.psm1

using namespace System.Diagnostics
using namespace System.IO
using namespace System.Management.Automation
using namespace System.Management.Automation.Language
using namespace System.Reflection

[FlagsAttribute()]
enum BuildType {
    Setup   = 1
    Build   = 2
    Test    = 4
    Release = 8
    Publish = 16
}

enum ReleaseType {
    Build
    Minor
    Major
}

class BuildTask {
    [String]$Name

    [BuildType]$Stage

    [ScriptBlock]$ValidWhen = { $true }

    [Int32]$Order = 1024

    [ScriptBlock]$Implementation

    BuildTask([String]$name, [BuildType]$stage) {
        $this.Name = $name
        $this.Stage = $stage
    }
}

class BuildInfo {
    # The name of the module being built.
    [String]$ModuleName

    # The build steps.
    [BuildType]$BuildType

    # The release type.
    [ReleaseType]$ReleaseType

    # The version which will be created.
    [Version]$Version

    # The tasks which will be executed during this build.
    [BuildTask[]]$BuildTask

    # The root of this repository.
    [DirectoryInfo]$ProjectRoot

    # The path to the module source
    [DirectoryInfo]$Source

    # The package generated by the build process.
    [DirectoryInfo]$Package

    # An output directory which stores files created by tools like Pester.
    [DirectoryInfo]$Output

    # The manifest associated with the package.
    [FileInfo]$ReleaseManifest

    # The root module associated with the package.
    [FileInfo]$ReleaseRootModule

    # Acceptable code coverage threshold.
    [Double]$CodeCoverageThreshold = 0.9

    # Constructors

    # Supports testing
    hidden BuildInfo() { }

    BuildInfo($BuildType, $ReleaseType) {
        $this.BuildType = $BuildType
        $this.ReleaseType = $ReleaseType

        if ($this.ProjectRoot = (git rev-parse --show-toplevel 2> $null)) {
            # Converts / into \
            $this.ProjectRoot = $this.ProjectRoot.FullName
        } else {
            throw (New-Object InvalidOperationException('Unable to discover repository root'))
        }

        $this.Source = $this.GetSourcePath()
        $this.ModuleName = $this.GetModuleName()
        $this.Version = $this.GetVersion()
        $this.BuildTask = $this.GetBuildTask()

        # Paths

        $this.Package = Join-Path $this.ProjectRoot $this.Version
        $this.Output = Join-Path $this.ProjectRoot 'output'

        if ($this.ProjectRoot.Name -ne $this.ModuleName) {
            $this.Package = [Path]::Combine($this.ProjectRoot, 'build', $this.ModuleName, $this.Version)
            $this.Output = [Path]::Combine($this.ProjectRoot, 'build', 'output', $this.ModuleName)
        }

        $this.ReleaseManifest = Join-Path $this.Package ('{0}.psd1' -f $this.ModuleName)
        $this.ReleaseRootModule = Join-Path $this.Package ('{0}.psm1' -f $this.ModuleName)
    }

    # Private methods

    hidden [BuildTask[]] GetBuildTask() {
        return Get-BuildTask |
            Where-Object { $BuildType -band $_.Stage -and $_.ValidWhen.Invoke() } |
            Sort-Object Stage, Order
    }

    hidden [String] GetModuleName() {
        if ($this.Source.Name -eq 'source') {
            return $this.Source.Parent.Parent.GetDirectories($this.Source.Parent.Name).Name
        } else {
            return $this.Source.Parent.GetDirectories($this.Source.Name).Name
        }
    }

    hidden [String] GetSourcePath() {
        # Valid source paths:
        # ProjectRoot\source
        # ProjectRoot\ModuleName
        # ProjectRoot\ModuleName\source

        if (Test-Path (Join-Path $this.ProjectRoot 'source')) {
            return Join-Path $this.ProjectRoot 'source'
        } elseif (Test-Path 'source') {
            return Join-Path $pwd 'source'
        } elseif ((Split-Path $pwd -Leaf) -eq 'source') {
            return $pwd
        } elseif ((Test-Path '*.psd1') -and ((Get-Item '*.psd1').BaseName -eq (Get-Item $pwd).Name)) {
            return $pwd
        } elseif (Test-Path (Join-Path $this.ProjectRoot $this.ProjectRoot.Name)) {
            return Join-Path $this.ProjectRoot $this.ProjectRoot.Name
        }

        throw 'Unable to determine the source path'
    }

    hidden [Version] GetVersion() {
        # Prefer to use version numbers from git.
        $packageVersion = [Version]'1.0.0.0'
        [String]$gitVersion = (git describe --tags 2> $null) -replace '^v'
        if ([Version]::TryParse($gitVersion, [Ref]$packageVersion)) {
            return $this.IncrementVersion($packageVersion)
        }

        # Fall back on version numbers in the manifest.
        $sourceManifest = Join-Path $this.Source ('{0}.psd1' -f $this.ModuleName)
        if (Test-Path $sourceManifest) {
            $manifestVersionString = Get-Metadata -Path $sourceManifest -PropertyName ModuleVersion

            $manifestVersion = [Version]'0.0.0.0'
            if ([Version]::TryParse($manifestVersionString, [Ref]$manifestVersion)) {
                return $this.IncrementVersion($manifestVersion)
            }
        }

        return $packageVersion
    }

    hidden [Version] IncrementVersion($version) {
        $ctorArgs = switch ($this.ReleaseType) {
            'Major' { ($version.Major + 1), 0, 0, 0 }
            'Minor' { $version.Major, ($version.Minor + 1), 0, 0 }
            'Build' { $version.Major, $version.Minor, ($version.Build + 1), 0 }
        }
        return New-Object Version($ctorArgs)
    }
}

function BuildTask {
    [OutputType('BuildTask')]
    param (
        [Parameter(Mandatory = $true)]
        [String]$Name,

        [Parameter(Mandatory = $true)]
        [BuildType]$Stage,

        [Parameter(Mandatory = $true)]
        [Hashtable]$Properties
    )

    $buildTask = New-Object BuildTask($Name, $Stage)

    $Properties.Keys | ForEach-Object {
        $buildTask.$_ = $Properties.$_
    }

    return $buildTask
}

function 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.
    # .PARAMETER Path
    # A valid metadata file or string containing the metadata.
    # .PARAMETER PropertyName
    # The property to enable.
    # .INPUTS
    # System.String
    # .OUTPUTS
    # System.Boolean
    # .NOTES
    # Change log:
    # 04/08/2016 - Chris Dent - Created.

    [CmdletBinding()]
    [OutputType([Boolean])]
    param (
        [Parameter(ValueFromPipelineByPropertyName = $true, Position = 0)]
        [ValidateScript( { Test-Path $_ -PathType Leaf } )]
        [Alias("PSPath")]
        [String]$Path,

        [String]$PropertyName
    )

    process {
        # 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 [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 = [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
            } catch {
                return $false
            }
            return $true
        } elseif ($existingValue.Count -eq 0) {
            # Item not found
            Write-Verbose "Can't find disabled property '$PropertyName' in $Path"
            return $false
        } else {
            # Ambiguous match
            Write-Verbose "Found more than one '$PropertyName' in $Path"
            return $false
        }
    }
}

function Get-BuildInfo {
    [CmdletBinding()]
    [OutputType('BuildInfo')]
    param (
        [BuildType]$BuildType = 'Setup, Build, Test',

        [ReleaseType]$ReleaseType = 'Build'
    )

    New-Object BuildInfo($BuildType, $ReleaseType)
}

function Get-BuildTask {
    [CmdletBinding()]
    [OutputType('BuildTask')]
    param (
        $Name = '*'
    )

    if (-not $Name.EndsWith('.ps1') -and -not $Name.EndsWith('*')) {
        $Name += '.ps1'
    }

    if ((Split-Path $psscriptroot -Leaf) -eq 'public') {
        $path = Join-Path $psscriptroot '..\task'
    } else {
        $path = Join-Path $psscriptroot 'task'
    }
    Get-ChildItem $path -File -Filter $Name -Recurse | ForEach-Object {
        . $_.FullName
    }
}

function Get-FunctionInfo {
    # .SYNOPSIS
    # Get an instance of FunctionInfo.
    # .DESCRIPTION
    # FuncitonInfo does not present a public constructor. This function calls an internal / private constructor on FunctionInfo to create a description of a function from a script block or file containing one or more functions.
    # .PARAMETER IncludeNested
    # By default functions nested inside other functions are ignored. Setting this parameter will allow nested functions to be discovered.
    # .PARAMETER Path
    # The path to a file containing one or more functions.
    # .PARAMETER ScriptBlock
    # A script block containing one or more functions.
    # .INPUTS
    # System.String
    # System.Management.Automation.ScriptBlock
    # .OUTPUTS
    # System.Management.Automation.FunctionInfo
    # .EXAMPLE
    # Get-ChildItem -Filter *.psm1 | Get-FunctionInfo
    #
    # Get all functions declared within the *.psm1 file and construct FunctionInfo.
    # .EXAMPLE
    # Get-ChildItem C:\Scripts -Filter *.ps1 -Recurse | Get-FunctionInfo
    #
    # Get all functions declared in all ps1 files in C:\Scripts.
    # .NOTES
    # Change log:
    # 10/12/2015 - Chris Dent - Improved error handling.
    # 28/10/2015 - Chris Dent - Created.

    [CmdletBinding(DefaultParameterSetName = 'FromPath')]
    [OutputType([FunctionInfo])]
    param (
        [Parameter(Position = 1, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'FromPath')]
        [Alias('FullName')]
        [String]$Path,

        [Parameter(ParameterSetName = 'FromScriptBlock')]
        [ValidateNotNullOrEmpty()]
        [ScriptBlock]$ScriptBlock,

        [Switch]$IncludeNested
    )

    begin {
        $executionContextType = [PowerShell].Assembly.GetType('System.Management.Automation.ExecutionContext')
        $constructor = [FunctionInfo].GetConstructor(
            [BindingFlags]'NonPublic, Instance',
            $null,
            [CallingConventions]'Standard, HasThis',
            ([String], [ScriptBlock], $ExecutionContextType),
            $null
        )
    }

    process {
        if ($pscmdlet.ParameterSetName -eq 'FromPath') {
            try {
                $scriptBlock = [ScriptBlock]::Create((Get-Content $Path -Raw))
            } catch {
                $ErrorRecord = @{
                    Exception = $_.Exception.InnerException
                    ErrorId   = 'InvalidScriptBlock'
                    Category  = 'OperationStopped'
                }
                Write-Error @ErrorRecord
            }
        }

        if ($scriptBlock) {
            $scriptBlock.Ast.FindAll( {
                    param( $ast )

                    $ast -is [FunctionDefinitionAst]
                },
                $IncludeNested
            ) | ForEach-Object {
                try {
                    $internalScriptBlock = $_.Body.GetScriptBlock()
                } catch {
                    Write-Debug $_.Exception.Message
                }
                if ($internalScriptBlock) {
                    $constructor.Invoke(([String]$_.Name,
                                         $internalScriptBlock,
                                         $null
                    ))
                }
            }
        }
    }
}

function Invoke-BuildTask {
    # .SYNOPSIS
    # Invoke a build step.
    # .DESCRIPTION
    # An output display wrapper to show progress through a build.
    # .INPUTS
    # System.String
    # .OUTPUTS
    # System.Object
    # .NOTES
    # Change log:
    # 01/02/2017 - Chris Dent - Added help.

    [CmdletBinding()]
    [OutputType([PSObject])]
    param (
        [Parameter(ValueFromPipeline = $true)]
        [BuildTask]$BuildTask,

        [BuildInfo]$BuildInfo,

        [Ref]$TaskInfo
    )

    begin {
        $stopWatch = New-Object StopWatch
    }

    process {
        $progressParams = @{
            Activity = 'Executing {0}' -f $BuildTask.Name
            Id       = 2
            ParentId = 1
        }
        Write-Progress @progressParams

        $TaskInfo.Value = [PSCustomObject]@{
            Name      = $BuildTask.Name
            Result    = 'Success'
            StartTime = [DateTime]::Now
            TimeTaken = $null
            Errors    = $null
        }
        $messageColour = 'Green'

        $stopWatch = New-Object System.Diagnostics.StopWatch
        $stopWatch.Start()

        try {
            & $BuildTask.Implementation
        } catch {
            $TaskInfo.Value.Result = 'Failed'
            $TaskInfo.Value.Errors = $_.Exception.InnerException
            $messageColour = 'Red'
        }

        $stopWatch.Stop()
        $TaskInfo.Value.TimeTaken = $stopWatch.Elapsed

        if (-not $Quiet) {
            Write-Message $BuildTask.Name.PadRight(30) -ForegroundColor Cyan -NoNewline
            Write-Message -ForegroundColor $messageColour -Object $taskInfo.Value.Result.PadRight(10) -NoNewline
            Write-Message $taskInfo.Value.StartTime.ToString('t').PadRight(10) -ForegroundColor Gray -NoNewLine
            Write-Message $taskInfo.Value.TimeTaken -ForegroundColor Gray
        }
    }
}

function Start-Build {
    [CmdletBinding()]
    [OutputType([PSObject])]
    param (
        [BuildType]$BuildType = 'Setup, Build, Test',

        [ValidateSet('Build', 'Minor', 'Major')]
        [ReleaseType]$ReleaseType = 'Build',

        [Switch]$PassThru,

        [Switch]$Quiet
    )

    try {
        $null = $psboundparameters.Remove('PassThru')
        $null = $psboundparameters.Remove('Quiet')
        $buildInfo = Get-BuildInfo @psboundparameters

        $progressParams = @{
            Activity = 'Building {0} ({1})' -f $buildInfo.ModuleName, $buildInfo.Version
            Id       = 1
        }
        Write-Progress @progressParams

        Write-Message ('Building {0} ({1})' -f $buildInfo.ModuleName, $buildInfo.Version) -Quiet:$Quiet.ToBool() -WithPadding

        foreach ($task in $buildInfo.BuildTask) {
            $taskInfo = New-Object PSObject
            Invoke-BuildTask $task -BuildInfo $BuildInfo -TaskInfo ([Ref]$taskInfo)

            if ($PassThru) {
                $taskInfo
            }

            if ($taskInfo.Result -ne 'Success') {
                throw $taskInfo.Errors
            }
        }

        Write-Message "Build succeeded!" -ForegroundColor Green -Quiet:$Quiet.ToBool() -WithPadding

        $lastexitcode = 0
    } catch {
        Write-Message 'Build Failed!' -ForegroundColor Red -Quiet:$Quiet.ToBool() -WithPadding

        $lastexitcode = 1

        # Catches unexpected errors, rethrows errors raised while executing steps.
        throw
    }
}

function Write-Message {
    # .SYNOPSIS
    # Writes a message to the console.
    # .DESCRIPTION
    # Writes a message to the console.

    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')]
    [CmdletBinding()]
    [OutputType([Void])]
    param (
        [String]$Object,

        [ConsoleColor]$ForegroundColor,

        [Switch]$NoNewLine,

        [Switch]$Quiet,

        [Switch]$WithPadding
    )

    $null = $psboundparameters.Remove('Quiet')
    $null = $psboundparameters.Remove('WithPadding')
    if (-not $Quiet) {
        if ($WithPadding) {
            Write-Host
        }
        Write-Host @psboundparameters
        if ($WithPadding) {
            Write-Host
        }
    }
}