Private/BuilderHelpers.ps1

# Internal helpers for the builder functions (Public\Builder.ps1).

function Get-ABAstValue {
    # Evaluates a (constant) parameter default value AST. Arrays are returned wrapped so they aren't unrolled.
    param ($Ast)

    if ($null -eq $Ast) {
        return $null
    }
    if ($Ast -is [System.Management.Automation.Language.VariableExpressionAst]) {
        switch ($Ast.VariablePath.UserPath) {
            'true' { return $true }
            'false' { return $false }
            'null' { return $null }
        }
    }
    try {
        $value = $Ast.SafeGetValue()
    }
    catch {
        $value = $Ast.Extent.Text
    }
    if ($value -is [array]) {
        return , $value
    }
    return $value
}


function ConvertTo-ABOrderedHashtable {
    # Recursively converts PSCustomObjects (from ConvertFrom-Json) into ordered hashtables.
    param ($InputObject)

    if ($null -eq $InputObject) {
        return $null
    }
    if ($InputObject -is [System.Management.Automation.PSCustomObject]) {
        $result = [ordered]@{}
        foreach ($property in $InputObject.PSObject.Properties) {
            $result[$property.Name] = ConvertTo-ABOrderedHashtable $property.Value
        }
        return $result
    }
    if ($InputObject -is [System.Collections.IDictionary]) {
        $result = [ordered]@{}
        foreach ($key in $InputObject.Keys) {
            $result[$key] = ConvertTo-ABOrderedHashtable $InputObject[$key]
        }
        return $result
    }
    if ($InputObject -is [array]) {
        $items = @(foreach ($item in $InputObject) { ConvertTo-ABOrderedHashtable $item })
        return , $items
    }
    return $InputObject
}


function Test-ABValueEqual {
    # Compares two parameter values (including arrays).
    param ($A, $B)

    $jsonA = ConvertTo-Json -InputObject $A -Compress -Depth 5
    $jsonB = ConvertTo-Json -InputObject $B -Compress -Depth 5
    return $jsonA -eq $jsonB
}


function ConvertTo-ABFullPath {
    # Returns the full path for a (possibly relative) path. Relative paths are resolved against PowerShell's current
    # location; [System.IO.Path]::GetFullPath would use the process's current directory, which PowerShell doesn't
    # keep in sync with Set-Location. The path doesn't need to exist.
    param (
        [Parameter(Mandatory = $true)] [string] $Path
    )

    $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
}


function Test-ABPathUnder {
    # Returns $true if Path is Folder or inside it (case-insensitive; both must be full paths)
    param (
        [Parameter(Mandatory = $true)] [string] $Path,
        [Parameter(Mandatory = $true)] [string] $Folder
    )

    $folderFull = $Folder.TrimEnd('\', '/')
    $pathFull = $Path.TrimEnd('\', '/')
    $pathFull.Equals($folderFull, [System.StringComparison]::OrdinalIgnoreCase) -or
    $pathFull.StartsWith($folderFull + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)
}


function Copy-ABItemNoOverwrite {
    # Copies a file, or a folder recursively, without overwriting files that already exist at the destination
    # (unless -Force), so a user's customized files are never replaced.
    param (
        [Parameter(Mandatory = $true)] [string] $Source,
        [Parameter(Mandatory = $true)] [string] $Destination,
        [switch] $Force
    )

    if (Test-Path -LiteralPath $Source -PathType Container) {
        if (-not (Test-Path -LiteralPath $Destination)) {
            New-Item -Path $Destination -ItemType Directory -Force | Out-Null
        }
        foreach ($child in (Get-ChildItem -LiteralPath $Source -Force)) {
            Copy-ABItemNoOverwrite -Source $child.FullName -Destination (Join-Path $Destination $child.Name) -Force:$Force
        }
    } elseif ($Force -or -not (Test-Path -LiteralPath $Destination)) {
        $parent = Split-Path -Path $Destination -Parent
        if ($parent -and -not (Test-Path -LiteralPath $parent)) {
            New-Item -Path $parent -ItemType Directory -Force | Out-Null
        }
        Copy-Item -LiteralPath $Source -Destination $Destination -Force
    }
}


function Update-ABAssetBase {
    <#
    .SYNOPSIS
    Prepares a configuration's file and folder parameters for a configuration that moves from OldBase to NewBase.
    .DESCRIPTION
    Files and folders that come from the module's project template are copied into NewBase (at the same relative path,
    without overwriting existing files) and keep their relative paths, so saving the default template somewhere turns
    that folder into a project. Other paths are rebased so they keep pointing at the same files: relative if they are
    inside NewBase, otherwise full paths.
    #>

    param (
        [Parameter(Mandatory = $true)] [System.Collections.IDictionary] $Configuration,
        [Parameter(Mandatory = $true)] [object[]] $TaskDefinitions,
        [Parameter(Mandatory = $true)] [string] $OldBase,
        [Parameter(Mandatory = $true)] [string] $NewBase
    )

    $template = Get-ABTemplatePath
    foreach ($definition in $TaskDefinitions) {
        if (-not $Configuration.Tasks.Contains($definition.Name)) {
            continue
        }
        $entry = $Configuration.Tasks[$definition.Name]
        foreach ($p in ($definition.Parameters | Where-Object { $_.AssetKind })) {
            $value = $entry.Parameters[$p.Name]
            if ([string]::IsNullOrWhiteSpace($value)) {
                continue
            }
            $full = Resolve-ABAssetPath -Path $value -BasePath $OldBase
            if ((Test-ABPathUnder -Path $full -Folder $template) -and (Test-Path -LiteralPath $full)) {
                $relative = Get-ABRelativePath -Path $full -BasePath $template
                Copy-ABItemNoOverwrite -Source $full -Destination (Join-Path $NewBase $relative)
                $entry.Parameters[$p.Name] = $relative
            } else {
                $entry.Parameters[$p.Name] = Get-ABRelativePath -Path $full -BasePath $NewBase
            }
        }
    }
}


function Test-ABSameContent {
    # Returns $true if two files, or two folders (recursively), have the same content
    param (
        [Parameter(Mandatory = $true)] [string] $A,
        [Parameter(Mandatory = $true)] [string] $B
    )

    if ((Test-Path -LiteralPath $A -PathType Leaf) -and (Test-Path -LiteralPath $B -PathType Leaf)) {
        return (Get-FileHash -LiteralPath $A).Hash -eq (Get-FileHash -LiteralPath $B).Hash
    }
    if ((Test-Path -LiteralPath $A -PathType Container) -and (Test-Path -LiteralPath $B -PathType Container)) {
        $filesA = @(Get-ChildItem -LiteralPath $A -Recurse -File -Force | ForEach-Object { $_.FullName.Substring($A.TrimEnd('\', '/').Length + 1) } | Sort-Object)
        $filesB = @(Get-ChildItem -LiteralPath $B -Recurse -File -Force | ForEach-Object { $_.FullName.Substring($B.TrimEnd('\', '/').Length + 1) } | Sort-Object)
        if (($filesA -join '|') -ne ($filesB -join '|')) {
            return $false
        }
        foreach ($relative in $filesA) {
            if ((Get-FileHash -LiteralPath (Join-Path $A $relative)).Hash -ne (Get-FileHash -LiteralPath (Join-Path $B $relative)).Hash) {
                return $false
            }
        }
        return $true
    }
    return $false
}


function Get-ABProjectAssetTarget {
    <#
    .SYNOPSIS
    Describes where a file or folder would go when copied into a project's Assets folder.
    .OUTPUTS
    AssetsFolder, Destination (full path), Relative (path relative to the project), InAssets (the source is already in
    the Assets folder), Exists (the destination exists) and Identical (the destination has the same content).
    #>

    param (
        [Parameter(Mandatory = $true)] [string] $Source,
        [Parameter(Mandatory = $true)] [string] $ProjectPath
    )

    $Source = ConvertTo-ABFullPath $Source
    $assets = Join-Path (ConvertTo-ABFullPath $ProjectPath) 'Assets'
    $inAssets = (Test-ABPathUnder -Path $Source -Folder $assets) -and -not ($Source.TrimEnd('\', '/') -eq $assets.TrimEnd('\', '/'))
    if ($inAssets) {
        $destination = $Source
    } else {
        $destination = Join-Path $assets (Split-Path -Path $Source -Leaf)
    }
    $exists = Test-Path -LiteralPath $destination
    [pscustomobject]@{
        AssetsFolder = $assets
        Destination  = $destination
        Relative     = Get-ABRelativePath -Path $destination -BasePath $ProjectPath
        InAssets     = $inAssets
        Exists       = $exists
        Identical    = $inAssets -or ($exists -and (Test-ABSameContent -A $Source -B $destination))
    }
}


function Copy-ABProjectAsset {
    <#
    .SYNOPSIS
    Copies a file or folder into a project's Assets folder and returns its path relative to the project.
    .PARAMETER Replace
    Replace an existing file or folder with the same name (a folder is replaced completely).
    #>

    param (
        [Parameter(Mandatory = $true)] [string] $Source,
        [Parameter(Mandatory = $true)] [string] $ProjectPath,
        [switch] $Replace
    )

    $target = Get-ABProjectAssetTarget -Source $Source -ProjectPath $ProjectPath
    if ($target.InAssets -or $target.Identical) {
        return $target.Relative
    }
    if ($target.Exists) {
        if (-not $Replace) {
            throw "$($target.Destination) already exists; specify -Replace to replace it."
        }
        Remove-Item -LiteralPath $target.Destination -Recurse -Force
    }
    if (-not (Test-Path -LiteralPath $target.AssetsFolder)) {
        New-Item -Path $target.AssetsFolder -ItemType Directory -Force | Out-Null
    }
    if (Test-Path -LiteralPath $Source -PathType Container) {
        Copy-Item -LiteralPath $Source -Destination $target.Destination -Recurse -Force
    } else {
        Copy-Item -LiteralPath $Source -Destination $target.Destination -Force
    }
    return $target.Relative
}