PSAnsibleCmdline.psm1

using namespace System.Management.Automation

#region Get-PlaybookParams.ps1
function ToTitleCase
{
    param
    (
        [Parameter(Mandatory, Position = 0, ValueFromPipeline)]
        [string]$String
    )

    begin {$Culture = Get-Culture}

    process
    {
        $Culture.TextInfo.ToTitleCase($String) -replace '-'
    }
}

function Get-PlaybookParams
{
    [Diagnostics.CodeAnalysis.SuppressMessage("PSUseSingularNouns", "")]
    [OutputType([RuntimeDefinedParameterDictionary])]
    [CmdletBinding()]
    param ()

    if ($Script:PlaybookParams) {return $Script:PlaybookParams}

    $Help = ansible-playbook --help
    $Help = $Help.Where({$_ -match '^options:'}, 'SkipUntil') -ne "" -notmatch '^( )?\w' | Out-String
    $Blocks = $Help.Trim() -split "`n (?=-)"

    [string[]]$Aliases = $null
    $SingleLetterAliases = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)

    $Params = $Blocks | ForEach-Object {
        $Aliases, $Help = $_ -split "(?s)\s{2,}"
        $Aliases = $Aliases -split ", "
        $Help = $Help -join " "

        if ($Aliases -match '-verbose') {return}  # special case!

        $Arg = ""
        $Aliases = $Aliases | ForEach-Object {$Alias, $Arg = $_ -split " "; $Alias}

        # Assumption: there's no more than one single-letter alias
        $SingleLetterAlias = @($Aliases) -match '^-\w$' -replace '-'
        if ($SingleLetterAlias -and -not $SingleLetterAliases.Add($SingleLetterAlias))
        {
            # we have a case-insensitive duplicate :-(
            $Aliases = @($Aliases) -notmatch '^-\w$'
        }

        $Name = @($Aliases) -match '^--' | Select-Object -First 1
        $Aliases = @($Aliases) -ne $Name

        if ($Name -match '\w-\w')
        {
            $Aliases = $Name, $Aliases | Write-Output
        }

        $Name = ($Name | ToTitleCase) -replace '-'
        $Aliases = @($Aliases) -replace '^--?'


        $Type = if (-not $Arg)
        {
            [switch]
        }
        elseif ($Arg -match 'S$' -or $Help -match 'may be specified multiple times')
        {
            [string[]]
        }
        else
        {
            [string]
        }

        $Attrs = [Collections.ObjectModel.Collection[Attribute]]::new()
        if ($Aliases)
        {
            $Attrs.Add([Alias]::new($Aliases))
        }
        $ParamAttr = [ParameterAttribute]::new()
        $ParamAttr.HelpMessage = $Help
        $Attrs.Add($ParamAttr)
        [RuntimeDefinedParameter]::new($Name, $Type, $Attrs)
    }

    $Script:PlaybookParams = [RuntimeDefinedParameterDictionary]::new()
    $Params | ForEach-Object {$Script:PlaybookParams.Add($_.Name, $_)}
    $Script:PlaybookParams
}
#endregion Get-PlaybookParams.ps1

#region play-book.ps1
function Invoke-AnsiblePlaybook
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory, Position = 0)]
        [string]$Playbook,

        [Alias('vv', 'vvv', 'vvvv', 'vvvvv')]
        [switch]$v
    )

    dynamicparam
    {
        $DynParams = Get-PlaybookParams
        $DynParams
    }

    end
    {
        $Verbosity = if ($v -or $VerbosePreference -notin ('SilentlyContinue', 'Ignore'))
        {
            if ($MyInvocation.Line -match '\s-(v*)(\s|$)') {$Matches[1].Length} else {3}
        }

        $PlaybookArgs = [Collections.Generic.List[string]]::new()
        $PSBoundParameters.GetEnumerator() |
            ForEach-Object {
                $Param = $DynParams[$_.Key]
                if (-not $Param) {return}  # filter out static and common params

                $PSName = $_.Key
                $Aliases = $Param.Attributes.AliasNames
                $Name = $Aliases | Where-Object {$_ -replace '-' -ilike $PSName} | Select-Object -First 1
                if (-not $Name) {$Name = $PSName.ToLower()}

                if ($Param.ParameterType -eq [switch])
                {
                    $PlaybookArgs.Add("--$Name")
                }
                else
                {
                    $PlaybookArgs.Add("--$Name")
                    $PlaybookArgs.Add("$($_.Value)")
                }
            }

        # If the user passed a value that's not in the completion cache, invalidate the cache
        $PSBoundParameters.GetEnumerator() | ForEach-Object {
            $CompletionKey = $Script:PlaybookCompletableParams[$_.Key]
            if (-not $CompletionKey) {return}
            $Completions = $Script:PlaybookCompletionValues[$CompletionKey]
            if ($_.Value | Where-Object {$_ -inotin $Completions})
            {
                $Script:PlaybookCompletionValues[$CompletionKey] = $null
            }
        }

        if ($Verbosity)
        {
            $PlaybookArgs.Add("-$('v' * $Verbosity)")
        }

        try
        {
            $ANSIBLE_DEBUG = $env:ANSIBLE_DEBUG
            if ($DebugPreference -notin ('SilentlyContinue', 'Ignore')) {$env:ANSIBLE_DEBUG = 1}

            if ($Verbosity) {$VerbosePreference = 'Continue'}
            Write-Verbose "ansible-playbook $PlaybookArgs $Playbook"
            ansible-playbook $PlaybookArgs $Playbook
        }
        finally
        {
            $env:ANSIBLE_DEBUG = $ANSIBLE_DEBUG
        }
    }
}

Set-Alias play-book Invoke-AnsiblePlaybook

$Script:PlaybookCompletionValues = @{}
$Script:PlaybookCompletableParams = @{
    Tags = 'Tags'
    SkipTags = 'Tags'
    StartAtTask = 'Tasks'
    Limit = 'Hosts'
}
$Script:PlaybookCompleters = @{
    Tags = {
        param ($Playbook, $Inventory)
        $PSBoundParameters.ListTags = $true

        $Output = Invoke-Playbook @PSBoundParameters
        $Tags = $Output |
            ForEach-Object {if ($_ -match 'TAGS: \[(?<tags>.+?)\]') {$Matches.tags -split ', '}} |
            Sort-Object -Unique
        $Tags, 'always', 'never' | Write-Output
    }

    Tasks = {
        param ($Playbook, $Inventory, $Tags, $SkipTags)
        $PSBoundParameters.ListTasks = $true

        $Output = Invoke-Playbook @PSBoundParameters
        $Tasks = $Output |
            ForEach-Object {if ($_ -match '^\s+[^:]+ : (?<task>.*?)\s+TAGS:') {$Matches.task}} |
            Select-Object -Unique
        $Tasks
    }

    Hosts = {
        param ($Playbook, $Inventory)
        $PSBoundParameters.ListHosts = $true

        $Output = Invoke-Playbook @PSBoundParameters
        $Hosts = $Output |
            ForEach-Object {if ($_ -match '^\s+(?<host>\S*[^:])$') {$Matches.host}} |
            Sort-Object -Unique
        $Hosts, 'localhost', 'all' | Write-Output
    }
}

$PlaybookCompletableParams.GetEnumerator() | ForEach-Object {
    $ParamName = $_.Key
    $CompletionKey = $_.Value
    $Fetcher = $PlaybookCompleters[$CompletionKey]

    $Completer = {
        param ($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)

        $Completions = $Script:PlaybookCompletionValues[$CompletionKey]
        $wordToComplete = $wordToComplete -replace "^(?<quote>['`"])(?<content>.*?)\k<quote>$", '${content}'

        # The user may be developing the playbook and updating tags, tasks, or hosts.
        # If they type a word to complete that we don't have, invalidate the cache.
        foreach ($Attempt in 1..2)
        {
            $ShouldRetry = $true
            if (-not $Completions)
            {
                $Completions = & $Fetcher @fakeBoundParameters
                $Script:PlaybookCompletionValues[$CompletionKey] = $Completions
                $ShouldRetry = $false
            }

            $Completions = (@($Completions) -like "$wordToComplete*"), (@($Completions) -like "*$wordToComplete*") | Write-Output
            if ($Completions -and -not $ShouldRetry) {break}
        }

        @($Completions) -replace '.* .*', "'`$0'"
    }.GetNewClosure()

    Register-ArgumentCompleter -CommandName Invoke-Playbook -ParameterName $ParamName -ScriptBlock $Completer
}
#endregion play-book.ps1