Public/Clear-ColorScriptCache.ps1

function Add-UniqueColorScriptName {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Value,
        [Parameter(Mandatory)][AllowEmptyCollection()][System.Collections.Generic.HashSet[string]]$Set,
        [Parameter(Mandatory)][AllowEmptyCollection()][System.Collections.Generic.List[string]]$List
    )

    if (-not [string]::IsNullOrWhiteSpace($Value) -and $Set.Add($Value)) {
        [void]$List.Add($Value)
    }
}

function Get-ColorScriptPipelineName {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter()][AllowNull()][object]$InputObject
    )

    if ($InputObject -is [string]) {
        return $InputObject
    }
    if ($InputObject -isnot [System.Management.Automation.PSObject]) {
        return $null
    }
    if ($InputObject.PSObject.Properties['Name']) {
        return [string]$InputObject.PSObject.Properties['Name'].Value
    }
    if ($InputObject.PSObject.Properties['ScriptName']) {
        return [string]$InputObject.PSObject.Properties['ScriptName'].Value
    }
    return $null
}

function Resolve-ColorScriptCacheClearRoot {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][bool]$PathSpecified,
        [Parameter()][AllowNull()][AllowEmptyString()][string]$Path
    )

    if ($PathSpecified -and $Path) {
        $cacheRoot = Resolve-CachePath -Path $Path
        if (-not $cacheRoot) {
            Write-Warning ($script:Messages.CachePathNotFound -f $Path)
            return $null
        }
    }
    else {
        Initialize-CacheDirectory
        $cacheRoot = $script:CacheDir
        if (-not $cacheRoot) {
            Write-Warning ($script:Messages.CachePathNotFound -f '<not set>')
            return $null
        }
    }

    if (-not (Test-Path -LiteralPath $cacheRoot -PathType Container)) {
        Write-Warning ($script:Messages.CachePathNotFound -f $cacheRoot)
        return $null
    }
    return $cacheRoot
}

function Get-ColorScriptCacheInventory {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$CacheRoot
    )

    $cacheFile = @(Get-ChildItem -LiteralPath $CacheRoot -Filter '*.cache' -File -ErrorAction SilentlyContinue)
    $metadataFile = @(Get-ChildItem -LiteralPath $CacheRoot -Filter ('*{0}' -f $script:CacheEntryMetadataExtension) -File -ErrorAction SilentlyContinue)
    $entryFile = @($cacheFile + $metadataFile | Sort-Object -Property BaseName -Unique)
    $cacheLookup = @{}
    foreach ($file in $cacheFile) {
        $key = $file.BaseName.ToLowerInvariant()
        if (-not $cacheLookup.ContainsKey($key)) {
            $cacheLookup[$key] = $file
        }
    }

    return [pscustomobject]@{
        CacheFile   = $cacheFile
        MetadataFile = $metadataFile
        EntryFile   = $entryFile
        CacheLookup = $cacheLookup
    }
}

function Get-ColorScriptCacheClearMetadataRecord {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][object]$Inventory,
        [string[]]$Category,
        [string[]]$Tag
    )

    if (-not $Category -and -not $Tag) {
        $records = @($Inventory.EntryFile | ForEach-Object { [pscustomobject]@{ Name = $_.BaseName } })
        return [pscustomobject]@{ Success = $true; Record = $records }
    }

    try {
        $records = @(Get-ColorScriptEntry -Category $Category -Tag $Tag)
    }
    catch {
        Write-Verbose ("Get-ColorScriptEntry failed: {0}" -f $_.Exception.Message)
        $records = @()
    }
    if (-not $records) {
        Write-Warning $script:Messages.NoScriptsMatchedSpecifiedFilters
        return [pscustomobject]@{ Success = $false; Record = @() }
    }
    return [pscustomobject]@{ Success = $true; Record = $records }
}

function ConvertTo-ColorScriptNameRecordLookup {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Record
    )

    $lookup = @{}
    foreach ($currentRecord in $Record) {
        $key = ([string]$currentRecord.Name).ToLowerInvariant()
        if (-not [string]::IsNullOrWhiteSpace($key) -and -not $lookup.ContainsKey($key)) {
            $lookup[$key] = $currentRecord
        }
    }
    return $lookup
}

function Get-ColorScriptCacheClearSelection {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Record,
        [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$RequestedName,
        [Parameter(Mandatory)][bool]$FilterApplied
    )

    $missingEntry = [System.Collections.Generic.List[psobject]]::new()
    if ($RequestedName.Count -eq 0) {
        return [pscustomobject]@{ Selection = $null; MissingEntry = $missingEntry }
    }

    $selection = Select-RecordsByName -Records $Record -Name $RequestedName
    foreach ($map in @($selection.MatchMap)) {
        if ($map.Matched) {
            continue
        }
        $pattern = [string]$map.Pattern
        if ($FilterApplied) {
            Write-Warning ($script:Messages.ScriptSkippedByFilter -f $pattern)
        }
        elseif (-not [string]::IsNullOrWhiteSpace($pattern)) {
            [void]$missingEntry.Add([pscustomobject]@{ Name = $pattern })
        }
    }

    return [pscustomobject]@{ Selection = $selection; MissingEntry = $missingEntry }
}

function ConvertTo-ColorScriptPatternMatchLookup {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter()][AllowNull()][object]$Selection
    )

    $lookup = @{}
    foreach ($entry in @($Selection.MatchMap)) {
        $pattern = [string]$entry.Pattern
        if (-not [string]::IsNullOrWhiteSpace($pattern) -and -not $lookup.ContainsKey($pattern)) {
            $lookup[$pattern] = $entry
        }
    }
    return $lookup
}

function Get-AllColorScriptCacheTargetName {
    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)][object]$Inventory,
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$MetadataRecord,
        [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$RequestedName,
        [Parameter(Mandatory)][bool]$FilterApplied,
        [Parameter(Mandatory)][string]$CacheRoot
    )

    if ($Inventory.EntryFile.Count -eq 0) {
        Write-Warning ($script:Messages.NoCacheFilesFound -f $CacheRoot)
        return [string[]]@()
    }

    $selectedFile = @($Inventory.EntryFile)
    if ($RequestedName.Count -gt 0) {
        $matchedFile = [System.Collections.Generic.List[System.IO.FileInfo]]::new()
        foreach ($pattern in $RequestedName) {
            foreach ($match in @($Inventory.EntryFile | Where-Object { $_.BaseName -like $pattern })) {
                [void]$matchedFile.Add($match)
            }
        }
        $selectedFile = $matchedFile.ToArray()
    }

    if ($FilterApplied) {
        $allowedName = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($record in $MetadataRecord) {
            if (-not [string]::IsNullOrWhiteSpace([string]$record.Name)) {
                [void]$allowedName.Add([string]$record.Name)
            }
        }
        $selectedFile = @($selectedFile | Where-Object { $allowedName.Contains($_.BaseName) })
    }

    if ($selectedFile.Count -eq 0) {
        Write-Warning ($script:Messages.NoCacheFilesFound -f $CacheRoot)
        return [string[]]@()
    }
    return [string[]]@($selectedFile | Sort-Object -Property BaseName -Unique | ForEach-Object { $_.BaseName })
}

function Add-RequestedColorScriptCacheTargetName {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Value,
        [Parameter(Mandatory)][AllowEmptyCollection()][hashtable]$MetadataLookup,
        [Parameter(Mandatory)][AllowEmptyCollection()][hashtable]$PatternLookup,
        [Parameter(Mandatory)][bool]$FilterApplied,
        [Parameter(Mandatory)][AllowEmptyCollection()][System.Collections.Generic.HashSet[string]]$TargetSet,
        [Parameter(Mandatory)][AllowEmptyCollection()][System.Collections.Generic.List[string]]$TargetName
    )

    $patternEntry = if ($PatternLookup.ContainsKey($Value)) { $PatternLookup[$Value] } else { $null }
    if ($patternEntry -and $patternEntry.Matched -and $patternEntry.Matches) {
        foreach ($matchedName in $patternEntry.Matches) {
            $key = $matchedName.ToLowerInvariant()
            $canonicalName = if ($MetadataLookup.ContainsKey($key)) { [string]$MetadataLookup[$key].Name } else { $matchedName }
            Add-UniqueColorScriptName -Value $canonicalName -Set $TargetSet -List $TargetName
        }
        return
    }

    $key = $Value.ToLowerInvariant()
    if ($MetadataLookup.ContainsKey($key)) {
        Add-UniqueColorScriptName -Value ([string]$MetadataLookup[$key].Name) -Set $TargetSet -List $TargetName
    }
    elseif (-not $FilterApplied) {
        Add-UniqueColorScriptName -Value $Value -Set $TargetSet -List $TargetName
    }
}

function Add-ColorScriptCacheRecordTargetName {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Record,
        [Parameter(Mandatory)][AllowEmptyCollection()][System.Collections.Generic.HashSet[string]]$TargetSet,
        [Parameter(Mandatory)][AllowEmptyCollection()][System.Collections.Generic.List[string]]$TargetName
    )

    foreach ($entry in $Record) {
        Add-UniqueColorScriptName -Value ([string]$entry.Name) -Set $TargetSet -List $TargetName
    }
}

function Get-SelectedColorScriptCacheTargetName {
    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$MetadataRecord,
        [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$RequestedName,
        [Parameter(Mandatory)][object]$SelectionContext,
        [Parameter(Mandatory)][bool]$FilterApplied
    )

    $targetSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    $targetName = [System.Collections.Generic.List[string]]::new()
    $metadataLookup = ConvertTo-ColorScriptNameRecordLookup -Record $MetadataRecord
    $patternLookup = ConvertTo-ColorScriptPatternMatchLookup -Selection $SelectionContext.Selection
    foreach ($value in $RequestedName) {
        Add-RequestedColorScriptCacheTargetName -Value $value -MetadataLookup $metadataLookup -PatternLookup $patternLookup -FilterApplied $FilterApplied -TargetSet $targetSet -TargetName $targetName
    }

    if ($RequestedName.Count -eq 0 -and $FilterApplied) {
        Add-ColorScriptCacheRecordTargetName -Record $MetadataRecord -TargetSet $targetSet -TargetName $targetName
    }
    if ($targetName.Count -eq 0 -and $SelectionContext.MissingEntry.Count -gt 0) {
        Add-ColorScriptCacheRecordTargetName -Record $SelectionContext.MissingEntry -TargetSet $targetSet -TargetName $targetName
    }
    return $targetName.ToArray()
}

function Get-ColorScriptCacheEntryState {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][string]$CacheRoot,
        [Parameter(Mandatory)][AllowEmptyCollection()][hashtable]$CacheLookup
    )

    $key = $Name.ToLowerInvariant()
    $cacheInfo = if ($CacheLookup.ContainsKey($key)) { $CacheLookup[$key] } else { $null }
    $cachePath = if ($cacheInfo) { $cacheInfo.FullName } else { Join-Path -Path $CacheRoot -ChildPath ("{0}.cache" -f $Name) }
    $cacheExists = $cacheInfo -and (Test-Path -LiteralPath $cacheInfo.FullName)
    if (-not $cacheExists) {
        $cachePath = Join-Path -Path $CacheRoot -ChildPath ("{0}.cache" -f $Name)
        $cacheExists = Test-Path -LiteralPath $cachePath
    }
    $metadataPath = Get-CacheEntryMetadataPath -ScriptName $Name -CacheRoot $CacheRoot
    $metadataExists = $metadataPath -and (Test-Path -LiteralPath $metadataPath -PathType Leaf)

    return [pscustomobject]@{
        CachePath      = $cachePath
        CacheExists    = [bool]$cacheExists
        MetadataPath   = $metadataPath
        MetadataExists = [bool]$metadataExists
        Exists         = [bool]($cacheExists -or $metadataExists)
    }
}

function ConvertTo-ColorScriptCacheClearResult {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][string]$CacheFile,
        [Parameter(Mandatory)][string]$Status,
        [Parameter()][AllowNull()][AllowEmptyString()][string]$Message = ''
    )

    return [pscustomobject]@{ Name = $Name; CacheFile = $CacheFile; Status = $Status; Message = $Message }
}

function Invoke-ColorScriptCacheEntryFileRemoval {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][object]$State
    )

    if ($State.CacheExists) {
        Remove-Item -LiteralPath $State.CachePath -Force -ErrorAction Stop
    }
    if (-not $State.MetadataExists) {
        return
    }
    if (-not $State.CacheExists) {
        # A metadata-only sidecar is the primary target, so its deletion must surface failure.
        Remove-Item -LiteralPath $State.MetadataPath -Force -ErrorAction Stop
        return
    }
    try {
        Remove-Item -LiteralPath $State.MetadataPath -Force -ErrorAction Stop
    }
    catch {
        Write-Verbose ("Failed to remove metadata '{0}': {1}" -f $State.MetadataPath, $_.Exception.Message)
    }
}

function Invoke-ColorScriptCacheClearTarget {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][string]$CacheRoot,
        [Parameter(Mandatory)][AllowEmptyCollection()][hashtable]$CacheLookup,
        [switch]$DryRun,
        [Parameter(Mandatory)][System.Management.Automation.PSCmdlet]$Cmdlet
    )

    $state = Get-ColorScriptCacheEntryState -Name $Name -CacheRoot $CacheRoot -CacheLookup $CacheLookup
    if (-not $state.Exists) {
        return ConvertTo-ColorScriptCacheClearResult -Name $Name -CacheFile $state.CachePath -Status 'Missing' -Message $script:Messages.CacheFileNotFound
    }
    if ($DryRun) {
        return ConvertTo-ColorScriptCacheClearResult -Name $Name -CacheFile $state.CachePath -Status 'DryRun' -Message $script:Messages.NoChangesApplied
    }

    $removalTarget = if ($state.CacheExists) { $state.CachePath } else { $state.MetadataPath }
    if (-not (Invoke-ShouldProcess -Cmdlet $Cmdlet -Target $removalTarget -Action 'Clear colorscript cache entry')) {
        return ConvertTo-ColorScriptCacheClearResult -Name $Name -CacheFile $state.CachePath -Status 'SkippedByUser'
    }

    try {
        Invoke-ColorScriptCacheEntryFileRemoval -State $state
        Remove-CachedOutputMemoryEntry -CacheFile $state.CachePath
        return ConvertTo-ColorScriptCacheClearResult -Name $Name -CacheFile $state.CachePath -Status 'Removed'
    }
    catch {
        Write-Warning ("Failed to remove cache file '{0}': {1}" -f $Name, $_.Exception.Message)
        return ConvertTo-ColorScriptCacheClearResult -Name $Name -CacheFile $state.CachePath -Status 'Error' -Message $_.Exception.Message
    }
}

function Add-ColorScriptCacheClearSummaryStatus {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][System.Collections.IDictionary]$Summary,
        [Parameter(Mandatory)][string]$Status
    )

    switch ($Status) {
        'Removed' { $Summary.Removed++ }
        'Missing' { $Summary.Missing++ }
        'Error' { $Summary.Errors++ }
        'DryRun' { $Summary.DryRun++ }
        'SkippedByUser' { $Summary.Skipped++ }
    }
}

function Write-ColorScriptCacheClearSummary {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][System.Collections.IDictionary]$Summary,
        [switch]$Quiet,
        [switch]$NoAnsiOutput
    )

    $formatString = if ($script:Messages -and $script:Messages.ContainsKey('CacheClearSummaryFormat')) {
        $script:Messages.CacheClearSummaryFormat
    }
    else {
        $null
    }
    if ([string]::IsNullOrWhiteSpace($formatString)) {
        $formatString = 'Cache clear summary: Removed {0}, Missing {1}, Skipped {2}, DryRun {3}, Errors {4}'
    }
    $message = $formatString -f $Summary.Removed, $Summary.Missing, $Summary.Skipped, $Summary.DryRun, $Summary.Errors
    $segment = New-ColorScriptAnsiText -Text $message -Color 'Cyan' -NoAnsiOutput:$NoAnsiOutput
    Write-ColorScriptInformation -Message $segment -Quiet:$Quiet -NoAnsiOutput:$NoAnsiOutput -PreferConsole:(-not $NoAnsiOutput) -Color 'Cyan'
}

function Invoke-ColorScriptCacheClearOperation {
    [CmdletBinding()]
    [OutputType([System.Object[]])]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$RequestedName,
        [string[]]$Category,
        [string[]]$Tag,
        [Parameter(Mandatory)][bool]$PathSpecified,
        [Parameter()][AllowNull()][AllowEmptyString()][string]$Path,
        [switch]$All,
        [switch]$DryRun,
        [switch]$PassThru,
        [switch]$Quiet,
        [switch]$NoAnsiOutput,
        [Parameter(Mandatory)][System.Management.Automation.PSCmdlet]$Cmdlet
    )

    $filterApplied = [bool]($Category -or $Tag)
    if (-not $All -and -not $filterApplied -and $RequestedName.Count -eq 0) {
        Invoke-ColorScriptError -Message $script:Messages.SpecifyAllOrNameToClearCache -ErrorId 'ColorScriptsEnhanced.CacheClearSelectionMissing' -Category ([System.Management.Automation.ErrorCategory]::InvalidOperation) -Cmdlet $Cmdlet
    }

    $cacheRoot = Resolve-ColorScriptCacheClearRoot -PathSpecified $PathSpecified -Path $Path
    if (-not $cacheRoot) {
        return @()
    }
    $inventory = Get-ColorScriptCacheInventory -CacheRoot $cacheRoot
    $metadata = Get-ColorScriptCacheClearMetadataRecord -Inventory $inventory -Category $Category -Tag $Tag
    if (-not $metadata.Success) {
        return @()
    }
    $selection = Get-ColorScriptCacheClearSelection -Record $metadata.Record -RequestedName $RequestedName -FilterApplied $filterApplied
    $targetName = if ($All) {
        Get-AllColorScriptCacheTargetName -Inventory $inventory -MetadataRecord $metadata.Record -RequestedName $RequestedName -FilterApplied $filterApplied -CacheRoot $cacheRoot
    }
    else {
        Get-SelectedColorScriptCacheTargetName -MetadataRecord $metadata.Record -RequestedName $RequestedName -SelectionContext $selection -FilterApplied $filterApplied
    }
    if (-not $targetName -or $targetName.Count -eq 0) {
        return @()
    }

    $summary = [ordered]@{ Removed = 0; Missing = 0; Errors = 0; DryRun = 0; Skipped = 0 }
    $result = foreach ($name in $targetName) {
        $entryResult = Invoke-ColorScriptCacheClearTarget -Name $name -CacheRoot $cacheRoot -CacheLookup $inventory.CacheLookup -DryRun:$DryRun -Cmdlet $Cmdlet
        Add-ColorScriptCacheClearSummaryStatus -Summary $summary -Status $entryResult.Status
        $entryResult
    }
    if ($PassThru) {
        return [System.Object[]]@($result)
    }
    Write-ColorScriptCacheClearSummary -Summary $summary -Quiet:$Quiet -NoAnsiOutput:$NoAnsiOutput
}

function Clear-ColorScriptCache {
    [OutputType([System.Object[]])]
    [CmdletBinding(DefaultParameterSetName = 'Selection', SupportsShouldProcess = $true, ConfirmImpact = 'Medium', HelpUri = 'https://nick2bad4u.github.io/PS-Color-Scripts-Enhanced/docs/help-redirect.html?cmdlet=Clear-ColorScriptCache')]
    param(
        [Parameter(ParameterSetName = 'Help')][Alias('help')][switch]$h,
        [Parameter(ParameterSetName = 'Selection', ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Parameter(ParameterSetName = 'All', ValueFromPipelineByPropertyName = $true)]
        [SupportsWildcards()][ValidateScript({ Test-ColorScriptNameValue $_ -AllowWildcard })][string[]]$Name,
        [Parameter(ParameterSetName = 'Selection')][Parameter(ParameterSetName = 'All')][string[]]$Category,
        [Parameter(ParameterSetName = 'Selection')][Parameter(ParameterSetName = 'All')][string[]]$Tag,
        [Parameter(ParameterSetName = 'Selection')][Parameter(ParameterSetName = 'All')]
        [ValidateScript({ Test-ColorScriptPathValue $_ })][string]$Path,
        [Parameter(ParameterSetName = 'All')][switch]$All,
        [Parameter(ParameterSetName = 'Selection')][Parameter(ParameterSetName = 'All')][switch]$DryRun,
        [Parameter(ParameterSetName = 'Selection')][Parameter(ParameterSetName = 'All')][switch]$PassThru,
        [Parameter(ParameterSetName = 'Selection')][Parameter(ParameterSetName = 'All')][switch]$Quiet,
        [Parameter(ParameterSetName = 'Selection')][Parameter(ParameterSetName = 'All')][Alias('NoColor')][switch]$NoAnsiOutput
    )

    begin {
        if ($h) {
            Show-ColorScriptHelp -CommandName 'Clear-ColorScriptCache'
            return
        }
        $nameSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        $collectedName = [System.Collections.Generic.List[string]]::new()
        if ($Name) {
            foreach ($value in $Name) {
                Add-UniqueColorScriptName -Value $value -Set $nameSet -List $collectedName
            }
        }
    }
    process {
        if ($h -or -not $MyInvocation.ExpectingInput) {
            return
        }
        if ($PSBoundParameters.ContainsKey('Name') -and $Name) {
            foreach ($value in $Name) {
                Add-UniqueColorScriptName -Value $value -Set $nameSet -List $collectedName
            }
            return
        }
        $pipelineName = Get-ColorScriptPipelineName -InputObject $_
        if ($pipelineName) {
            Add-UniqueColorScriptName -Value $pipelineName -Set $nameSet -List $collectedName
        }
    }
    end {
        if ($h) {
            return
        }
        return Invoke-ColorScriptCacheClearOperation -RequestedName $collectedName.ToArray() -Category $Category -Tag $Tag -PathSpecified $PSBoundParameters.ContainsKey('Path') -Path $Path -All:$All -DryRun:$DryRun -PassThru:$PassThru -Quiet:$Quiet -NoAnsiOutput:$NoAnsiOutput -Cmdlet $PSCmdlet
    }
}