Freer.Scaffold.psm1

[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
    <#Category#>'PSUseApprovedVerbs', <#CheckId#>'',
    Scope = 'function',
    Target = 'SaveAs-*'
)]
param()

if (-not $Env:SCAFFOLD_HOME) {
    $ENV:SCAFFOLD_HOME = "$HOME/.scaffolds"
}

New-Item $ENV:SCAFFOLD_HOME -ItemType Directory -ErrorAction Ignore

#region PRIVATE
function New-ScaffoldLeaf {
    [OutputType('Scaffold.Leaf')]
    param(
        [string]$Name,
        [PSTypeName('Scaffold.Container')]$Container,
        [string]$Contents
    )
    process {
        $o = [pscustomobject]@{
            Name              = $Name
            FullName          = $Container.FullName ? (Join-Path $Container.FullName $Name) : $Name
            Contents          = [string]::IsNullOrWhiteSpace($Contents) ? $null : $Contents
            ParameterizedVars = ($Contents | sls -Pattern '\{\{\s*(?<var>[a-zA-Z][a-zA-Z0-9\-_ ]+)\s*\}\}' -AllMatches).Matches
        }

        'Block', 'Leaf' | % {
            $o.PSObject.TypeNames.Insert(0, "Scaffold.$_")
        }

        $o
    }
}

function New-ScaffoldContainer {
    [OutputType('Scaffold.Container')]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Name,
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$FullName
    )
    process {
        $o = [pscustomobject]@{
            Name     = $Name
            FullName = $FullName
            Folders  = @{}
            Files    = @()
            Scaffold = @()
        }

        'Block', 'Container' | % {
            $o.PSObject.TypeNames.Insert(0, "Scaffold.$_")
        }

        $o
    }
}

function Read-ScaffoldLeaf {
    param(
        [System.Collections.Generic.List[object]]$Leaf,
        [PSTypeName('Scaffold.Container')]$ParentContainer
    )

    process {
        $Result = @()

        $Leaf | % {
            if ($_ -is [string]) {
                $Result += New-ScaffoldLeaf $_ $ParentContainer
            }
            elseif ($_ -is [hashtable]) {
                $Keys = $_.Keys

                if ($Keys.Count -ne 1) {
                    throw 'Invalid scaffold syntax for file. Only expecting 1 key and 1 string, optional, value'
                }

                $V = $_.Values | Select -First 1

                if ($V -is [string]) {
                    $Result += New-ScaffoldLeaf $Keys[0] $ParentContainer $V
                }
                else {
                    throw 'Invalid scaffold syntax for file. Only expecting 1 key and 1 string, optional, value'
                }
            }
        }

        $Result
    }
}

function Read-ScaffoldContainer {
    param(
        [hashtable]$Container,
        [string]$ParentPath = ''
    )

    process {
        $Result = New-ScaffoldContainer -Name '' -FullName $ParentPath

        $Scaffold = @{
            Files   = @()
            Folders = @()
        }

        $Container.GetEnumerator() | % {
            $Key = $_.Key.Trim()

            if ($Key -ieq '[files]') {
                $Leaf = Read-ScaffoldLeaf $_.Value $Result
                $Result.Files += $Leaf
                $Scaffold.Files += $Leaf
            }
            elseif ($_.Value -is [hashtable]) {
                if ($Key.EndsWith('/')) {
                    $Key = $Key.Substring(0, $Key.Length - 1)
                }

                $pp = $ParentPath ? (Join-Path $ParentPath $Key) : $Key
                $Read = Read-ScaffoldContainer $_.Value -ParentPath $pp
                $Result.Folders += @{ $Key = $Read }
                $Scaffold.Folders += $Read.Scaffold.Folders
                $Scaffold.Files += $Read.Scaffold.Files
            }
            elseif ($Key.EndsWith('/')) {
                $FolderName = $Key.Substring(0, $Key.Length - 1)
                $FullName = $ParentPath ? (Join-Path $ParentPath $FolderName) : $FolderName
                $ScaffoldContainer = New-ScaffoldContainer -Name $FolderName -FullName $FullName
                $Result.Folders += @{ $FolderName = $ScaffoldContainer }
                $Scaffold.Folders += $ScaffoldContainer
            }
            else {
                throw "Invalid scaffold snytax for key=${Key}"
            }
        }

        @{
            Result   = $Result
            Scaffold = $Scaffold
        }
    }
}
function Read-Directory {
    param(
        [ValidateNotNullOrEmpty()]
        [System.IO.DirectoryInfo]$Directory = $PWD
    )

    process {
        Get-ChildItem $Directory -Recurse -Force | % -Begin { $Result = [ordered]@{} } -Process {
            $Relative = Resolve-Path $_.FullName -Relative -RelativeBasePath $Directory

            [string[]]$s = $Relative -split ("\{0}" -f [IO.Path]::DirectorySeparatorChar) | ? { $_ }
            $Q = [System.Collections.Generic.Queue[string]]::new($s)

            $Current = $Result
            while ($Q.Count -gt 1) {
                $i = $Q.Dequeue()
                if ($i -eq '.') { continue }
                if ($Current.Keys -inotcontains $i) {
                    $Current[$i] = [ordered]@{ }
                    $Current = $Current[$i]
                }
                else {
                    $Current = $Current[$i]
                }
            }

            if ($_.Attributes.HasFlag([System.IO.FileAttributes]::Directory)) {
                $Current[$Q.Dequeue()] = [ordered]@{  }
            }
            else {
                if (-not $Current['[files]']) {
                    $Current.'[files]' = @()
                }
                $Name = $Q.Dequeue()
                $Current['[files]'] += @{ "$Name" = "{0}" -f (gc $_.FullName -raw) }
            }
        } -End { $Result }
    }
}
#endregion PRIVATE

function SaveAs-Scaffold {
    <#
    .SYNOPSIS
        Save a directory in it's current state (file content included) to then be used to create the same state somewhere else
    .DESCRIPTION
        Save a directory in it's current state (file content included) to then be used to create the same state somewhere else

        For example, assume you need to create a new PowerShell module you want to publish to the powershell gallery.

        You could simply create a scaffold of that type of project so it can be mounted in future directories that don't have those contents.

        Now for the next module you want to create you would simply navigate to a directory and type `Mount-Scaffold pwsh:gallery`

        Additionally provide a brief description about the scaffold for `Get-Scaffolds` output
    .NOTES
        Additionally provide a brief description about the scaffold for `Get-Scaffolds` output
    .EXAMPLE
        PS> SaveAs-Scaffold './path/to/directory' -Name pwsh:gallery -About 'A scaffold for new powershell modules that are published to gallery'
    #>

    [CmdletBinding()]
    param(
        [Parameter(
            ValueFromPipeline = $true,
            HelpMessage = 'Directory of folder to take a scaffold snapshot',
            Position = 0
        )]
        [ValidateNotNullOrEmpty()]
        [System.IO.DirectoryInfo]$Directory = $PWD,

        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            Position = 1,
            HelpMessage = 'Identifier of the scaffold to refer as later in scaffold snytax: `<category>:<name>'
        )]
        [ValidateNotNullOrEmpty()]
        [string]$Name,

        [Parameter(
            Position = 2,
            HelpMessage = 'Description of the scaffold'
        )]
        [AllowNull()]
        [AllowEmptyString()]
        [string]$About,

        [switch]$Confirm
    )

    process {
        if ($Name.Trim() -inotmatch '[a-zA-Z]+\:[a-zA-Z0-9-_]') {
            throw "$Name is not valid scaffold syntax: <category>:<name>"
        }

        $FileName = '{0}.scaffold' -f $Name.Trim().ToLower() -replace ':', '-'

        Remove-Scaffold $Name

        ConvertTo-Yaml ([ordered]@{
                name     = $Name
                about    = $About
                scaffold = Read-Directory $Directory
            }) | Out-File (Join-Path $ENV:SCAFFOLD_HOME $FileName) -Confirm:$Confirm -Force
    }
}

function Remove-Scaffold {
    <#
    .SYNOPSIS
        Remove the scaffold and it's files from device by it's scaffold identifier
    #>

    [CmdletBinding()]
    param(
        [ArgumentCompleter({ (Get-Scaffolds).Name })]
        [Parameter(
            ValueFromPipeline = $true,
            Position = 0,
            ValueFromPipelineByPropertyName = $true,
            HelpMessage = 'Identifier of the scaffold to refer as later in scaffold snytax `<category>:<name>'
        )]
        [ValidateNotNullOrWhiteSpace()]
        [string]$Name
    )

    process {
        $FileName = '{0}.scaffold' -f $Name.Trim().ToLower() -replace ':', '-'
        Remove-Item (Join-Path $ENV:SCAFFOLD_HOME $FileName) -Force -ErrorAction Ignore
    }
}

function Get-Scaffolds {
    [OutputType('Scaffold.Leaf', 'Scaffold.Container')]
    param()

    process {
        Get-ChildItem $ENV:SCAFFOLD_HOME -Include *.scaffold -File -Recurse -ErrorAction Ignore | % {
            ConvertTo-Scaffold -Path $_
        }
    }
}

function Mount-Scaffold {
    <#
    .SYNOPSIS
        Mount one to many scaffolds to a given destination
    .DESCRIPTION
        Mount one to many scaffolds to a given destination

        Mounting scaffolds effectively means you want to take a scaffold (layout/wireframe) of a directory and apply that layout to any directory of choice

        When mounting, you can do it silently or interactively

        When mounting silently:
        The folders defined in the scaffold (empty or not) are applied to the given destination, overwriting
        The files defined in the scaffold are applied to it's given destination via `Out-File -Force`,
            including any content in the file

        When mounting interactively:
        The folders defined in the scaffold (empty or not) are applied to the given destination, overwriting
        for each file defined in the scaffold:
            1. Template vars are searched for with the pattern: \{\{\s*(?<var>[a-zA-Z][a-zA-Z0-9\-_ ]+)\s*\}\}
            2. Duplicate matches are removed
            3. for each match:
                1. get value from user via `Read-Host`
                2. replace all of group 'var' with value
                3. update file content with replacements
            4. Apply file with `Out-File -Force -NoNewLine`
    .EXAMPLE
        Mount-Scaffold pwsh:gallery
        Mounts a scaffold of a directory created to publish powershell modules
    .EXAMPLE
        Mount-Scaffold pwsh:gallery, pwsh:cmdlet
        Mounts a scaffold of a directory created to publish powershell modules and also mounts a scaffold
        of a directory created to create a dll module
    #>

    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            HelpMessage = "Name of a registered scaffold you wish to mount",
            Position = 0
        )]
        [ValidateNotNullOrEmpty()]
        [ArgumentCompleter({ (Get-Scaffolds).Name })]
        [string[]]$Name,

        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias("Directory")]
        [String]$Destination = $PWD,

        [Parameter(
            HelpMessage = "When a file has template syntax all the parameters found will be prompted for values to be supplied"
        )]
        [switch]$Interactive
    )

    process {

        if (-not $Interactive.IsPresent) {
            Write-Progress -Id 0 -Activity 'Scaffold Mount'
        }

        $Name | % {
            $Target = '{0}.scaffold' -f (Join-Path $ENV:SCAFFOLD_HOME ($_.Trim().ToLower() -replace ':', '-'))
            if (-not (Test-Path $Target -PathType Leaf)) {
                Write-Error "'$_' scaffold not registered"
                return
            }

            Write-Progress -Id 1 -ParentId 0 -Activity $_.Trim().ToLower()

            ConvertTo-Scaffold (Get-Content $Target -Raw) | ? {
                $_.Scaffold | % {
                    $tns = $_.PSObject.TypeNames
                    if ($_.PSObject.TypeNames -contains 'Scaffold.Leaf') {
                        New-Item (Join-Path $Destination $_.FullName) -Force -ErrorAction Stop | Out-Null

                        if ($_.Contents) {
                            if ($Interactive.IsPresent) {
                                $PVars = $_.ParameterizedVars | Select-Object @{
                                    L = 'Var'
                                    E = { $_.Groups[1].ToString().Trim() }
                                } -Unique

                                for ($i = 0; $i -lt $PVars.Count; $i++) {
                                    $var = $PVars[$i].Var
                                    $stdin = Read-Host ("Scaffold Parameters [{0}] ({1}/{2})`n{3}" -f $_.Name, ($i + 1), $PVars.Count, $var)
                                    $_.Contents = $_.Contents -replace "\{\{\s*$var\s*\}\}", $stdin
                                }
                            }

                            $_.Contents.Trim() | Out-File (Join-Path $Destination $_.FullName) -Force -NoNewline
                        }
                    }
                    else {
                        New-Item (Join-Path $Destination $_.FullName) -ItemType Directory -Force
                    }
                }
            } | Out-Null

            Write-Progress -Id 1 -ParentId 0 -Completed
        }
    }
}

function ConvertTo-Scaffold {
    <#
    .SYNOPSIS
        Convert a scaffold yaml file or string to a custom object
    #>

    [CmdletBinding()]
    [OutputType('Scaffold.Document')]
    param(
        [Parameter(ValueFromPipeline = $true)]
        [AllowNull()]
        [AllowEmptyString()]
        [string]$Yaml,

        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [System.IO.FileInfo]$Path
    )

    process {
        $Document = if ($Path) {
            if ($Path.Attributes.HasFlag([System.IO.FileAttributes]::Directory)) {
                [pscustomobject]@{
                    name     = $null
                    about    = $null
                    scaffold = Read-Directory $Path
                }
            }
            else {
                ConvertFrom-Yaml (Get-Content $Path -Raw)
            }
        }
        else { ConvertFrom-Yaml $Yaml }

        $Read = Read-ScaffoldContainer $Document.Scaffold
        $Scaffold = @()
        $Scaffold += $Read.Scaffold.Folders | Sort-Object FullName
        $Scaffold += $Read.Scaffold.Files | Sort-Object FullName
        [pscustomobject][ordered]@{
            PSTypeName = 'Scaffold.Document'
            Name       = $Document.Name
            About      = $Document.About
            Scaffold   = $Scaffold
        }
    }
}