Private/Get-ColorScriptMetadataTableInternal.ps1

function Get-ColorScriptMetadataTimestamp {
    [CmdletBinding()]
    [OutputType([datetime])]
    param()

    if (-not (Test-Path -LiteralPath $script:MetadataPath)) {
        return $null
    }

    try {
        return (Get-Item -LiteralPath $script:MetadataPath).LastWriteTimeUtc
    }
    catch {
        Write-Verbose "Unable to determine metadata timestamp: $($_.Exception.Message)"
        return $null
    }
}

function Get-ColorScriptInventoryTimestamp {
    [CmdletBinding()]
    [OutputType([datetime])]
    param()

    try {
        $timestamp = & $script:DirectoryGetLastWriteTimeUtcDelegate $script:ScriptsPath
        if ($timestamp -eq [datetime]::MinValue) {
            return $null
        }
        return $timestamp
    }
    catch {
        Write-Verbose ("Unable to determine colorscript inventory timestamp: {0}" -f $_.Exception.Message)
        return $null
    }
}

function Test-ColorScriptMetadataMemoryCache {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter()][AllowNull()][object]$MetadataTimestamp,
        [Parameter()][AllowNull()][object]$InventoryTimestamp
    )

    return $script:MetadataCache -and
    $script:MetadataLastWriteTime -and
    $MetadataTimestamp -eq $script:MetadataLastWriteTime -and
    $InventoryTimestamp -eq $script:MetadataInventoryLastWriteTime
}

function ConvertTo-StringValueCollection {
    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter()][AllowNull()][object]$Value
    )

    $values = if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) {
        @($Value)
    }
    elseif ($null -ne $Value) {
        @($Value)
    }
    else {
        @()
    }

    return [string[]]@($values | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
}

function ConvertTo-ColorScriptMetadataRecord {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter()][AllowNull()][object]$Source
    )

    if ($Source -is [pscustomobject]) {
        return $Source
    }

    $category = $null
    $categoryCollection = @()
    $tagCollection = @()
    $description = $null
    if ($Source -is [hashtable]) {
        if ($Source.ContainsKey('Category')) {
            $category = [string]$Source['Category']
        }
        if ($Source.ContainsKey('Categories')) {
            $categoryCollection = ConvertTo-StringValueCollection -Value $Source['Categories']
        }
        if ($Source.ContainsKey('Tags')) {
            $tagCollection = ConvertTo-StringValueCollection -Value $Source['Tags']
        }
        if ($Source.ContainsKey('Description')) {
            $description = [string]$Source['Description']
        }
    }

    return [pscustomobject]@{
        Category    = $category
        Categories  = $categoryCollection
        Tags        = $tagCollection
        Description = $description
    }
}

function Get-ColorScriptMetadataCachePath {
    [CmdletBinding()]
    [OutputType([string])]
    param()

    if (-not $script:CacheInitialized -or -not $script:CacheDir) {
        return $null
    }

    return Join-Path -Path $script:CacheDir -ChildPath 'metadata.cache.json'
}

function Import-ColorScriptMetadataJsonCache {
    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter(Mandatory)][string]$CachePath,
        [Parameter()][AllowNull()][object]$MetadataTimestamp,
        [Parameter()][AllowNull()][object]$InventoryTimestamp
    )

    if (-not (Test-Path -LiteralPath $CachePath)) {
        return $null
    }

    try {
        $cacheFileInfo = Get-Item -LiteralPath $CachePath -ErrorAction Stop
        $metadataIsCurrent = -not $MetadataTimestamp -or $cacheFileInfo.LastWriteTimeUtc -ge $MetadataTimestamp
        $inventoryIsCurrent = -not $InventoryTimestamp -or $cacheFileInfo.LastWriteTimeUtc -ge $InventoryTimestamp
        if (-not $metadataIsCurrent -or -not $inventoryIsCurrent) {
            return $null
        }

        $jsonData = Get-Content -LiteralPath $CachePath -Raw -Encoding UTF8 -ErrorAction Stop
        $cachedHash = ConvertFrom-JsonToHashtable -InputObject $jsonData
        $store = New-Object 'System.Collections.Generic.Dictionary[string, object]' ([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($key in $cachedHash.Keys) {
            $store[$key] = ConvertTo-ColorScriptMetadataRecord -Source $cachedHash[$key]
        }

        Write-Verbose 'Loaded metadata from JSON cache (fast path)'
        return $store
    }
    catch {
        Write-Verbose "JSON metadata cache load failed, will rebuild: $($_.Exception.Message)"
        return $null
    }
}

function Merge-UniqueStringValue {
    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [string[]]$Existing,
        [string[]]$Additional
    )

    $set = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
    $list = New-Object 'System.Collections.Generic.List[string]'
    foreach ($value in @($Existing) + @($Additional)) {
        if (-not [string]::IsNullOrWhiteSpace($value) -and $set.Add($value)) {
            $null = $list.Add($value)
        }
    }

    return $list.ToArray()
}

function Import-ColorScriptMetadataFile {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param()

    if (-not (Test-Path -LiteralPath $script:MetadataPath)) {
        return $null
    }

    $importParams = @{ LiteralPath = $script:MetadataPath }
    $supportsSkipLimitCheck = $false
    try {
        $command = Get-Command -Name Import-PowerShellDataFile -ErrorAction SilentlyContinue
        if ($command -and $command.Parameters.ContainsKey('SkipLimitCheck')) {
            $importParams['SkipLimitCheck'] = $true
            $supportsSkipLimitCheck = $true
        }
    }
    catch {
        Write-Verbose "Unable to inspect Import-PowerShellDataFile parameters. Continuing without SkipLimitCheck. Error: $($_.Exception.Message)"
    }

    try {
        if ($supportsSkipLimitCheck) {
            return Import-PowerShellDataFile @importParams
        }

        # Windows PowerShell 5.1 cannot bypass the data-file limit for this package-owned asset.
        # Evaluating only this fixed module path retains Desktop compatibility without accepting
        # a user-supplied script path.
        $metadataSource = [System.IO.File]::ReadAllText($script:MetadataPath)
        return & ([scriptblock]::Create($metadataSource))
    }
    catch {
        Write-Verbose ("Import-PowerShellDataFile failed for ScriptMetadata.psd1: {0}" -f $_.Exception.Message)
        return $null
    }
}

function Get-ColorScriptMetadataInternalEntry {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [System.Collections.Generic.Dictionary[string, hashtable]]$Map,
        [Parameter(Mandatory)][string]$Name
    )

    if (-not $Map.ContainsKey($Name)) {
        $Map[$Name] = @{
            Category    = $null
            Categories  = New-Object 'System.Collections.Generic.List[string]'
            Tags        = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
            Description = $null
        }
    }

    return $Map[$Name]
}

function Merge-ColorScriptCategoryRecord {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$Data,
        [Parameter(Mandatory)]
        [System.Collections.Generic.Dictionary[string, hashtable]]$Map
    )

    if ($Data.Categories -isnot [hashtable]) {
        return
    }

    foreach ($categoryName in $Data.Categories.Keys) {
        foreach ($scriptName in @($Data.Categories[$categoryName])) {
            $entry = Get-ColorScriptMetadataInternalEntry -Map $Map -Name $scriptName
            if (-not $entry.Categories.Contains($categoryName)) {
                $null = $entry.Categories.Add($categoryName)
            }
            $null = $entry.Tags.Add("Category:$categoryName")
            if (-not $entry.Category) {
                $entry.Category = $categoryName
            }
        }
    }
}

function Merge-ColorScriptLevelRecord {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$Data,
        [Parameter(Mandatory)][string]$PropertyName,
        [Parameter(Mandatory)][string]$TagPrefix,
        [Parameter(Mandatory)]
        [System.Collections.Generic.Dictionary[string, hashtable]]$Map
    )

    $levelMap = $Data[$PropertyName]
    if ($levelMap -isnot [hashtable]) {
        return
    }

    foreach ($level in $levelMap.Keys) {
        foreach ($scriptName in @($levelMap[$level])) {
            $entry = Get-ColorScriptMetadataInternalEntry -Map $Map -Name $scriptName
            $null = $entry.Tags.Add("${TagPrefix}:$level")
        }
    }
}

function Merge-ColorScriptRecommendedRecord {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$Data,
        [Parameter(Mandatory)]
        [System.Collections.Generic.Dictionary[string, hashtable]]$Map
    )

    if ($Data.Recommended -isnot [System.Collections.IEnumerable]) {
        return
    }

    foreach ($scriptName in @($Data.Recommended)) {
        $entry = Get-ColorScriptMetadataInternalEntry -Map $Map -Name $scriptName
        $null = $entry.Tags.Add('Recommended')
    }
}

function Merge-ColorScriptTagRecord {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$Data,
        [Parameter(Mandatory)]
        [System.Collections.Generic.Dictionary[string, hashtable]]$Map
    )

    if ($Data.Tags -isnot [hashtable]) {
        return
    }

    foreach ($scriptName in $Data.Tags.Keys) {
        $entry = Get-ColorScriptMetadataInternalEntry -Map $Map -Name $scriptName
        foreach ($tag in @(ConvertTo-StringValueCollection -Value $Data.Tags[$scriptName])) {
            $null = $entry.Tags.Add($tag)
        }
    }
}

function Merge-ColorScriptDescriptionRecord {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$Data,
        [Parameter(Mandatory)]
        [System.Collections.Generic.Dictionary[string, hashtable]]$Map
    )

    if ($Data.Descriptions -isnot [hashtable]) {
        return
    }

    foreach ($scriptName in $Data.Descriptions.Keys) {
        $description = $Data.Descriptions[$scriptName]
        if ($null -eq $description -or [string]::IsNullOrWhiteSpace([string]$description)) {
            continue
        }
        $entry = Get-ColorScriptMetadataInternalEntry -Map $Map -Name $scriptName
        $entry.Description = [string]$description
    }
}

function ConvertTo-ColorScriptMetadataStore {
    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter()][AllowNull()][object]$Data
    )

    $store = New-Object 'System.Collections.Generic.Dictionary[string, object]' ([System.StringComparer]::OrdinalIgnoreCase)
    if ($Data -isnot [hashtable]) {
        return $store
    }

    $internal = New-Object 'System.Collections.Generic.Dictionary[string, hashtable]' ([System.StringComparer]::OrdinalIgnoreCase)
    Merge-ColorScriptCategoryRecord -Data $Data -Map $internal
    Merge-ColorScriptLevelRecord -Data $Data -PropertyName 'Difficulty' -TagPrefix 'Difficulty' -Map $internal
    Merge-ColorScriptLevelRecord -Data $Data -PropertyName 'Complexity' -TagPrefix 'Complexity' -Map $internal
    Merge-ColorScriptRecommendedRecord -Data $Data -Map $internal
    Merge-ColorScriptTagRecord -Data $Data -Map $internal
    Merge-ColorScriptDescriptionRecord -Data $Data -Map $internal

    foreach ($key in $internal.Keys) {
        $value = $internal[$key]
        $category = if ($value.Category) { $value.Category } else { 'Uncategorized' }
        $store[$key] = [pscustomobject]@{
            Category    = $category
            Categories  = ConvertTo-StringValueCollection -Value $value.Categories
            Tags        = ConvertTo-StringValueCollection -Value $value.Tags
            Description = $value.Description
        }
    }

    return $store
}

function ConvertTo-AutoCategoryRule {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][object]$Rule
    )

    $category = [string]$Rule.Category
    if ([string]::IsNullOrWhiteSpace($category)) {
        return $null
    }

    $patterns = ConvertTo-StringValueCollection -Value $Rule.Patterns
    if ($patterns.Count -eq 0) {
        return $null
    }

    return [pscustomobject]@{
        Category = $category
        Patterns = $patterns
        Tags     = ConvertTo-StringValueCollection -Value $Rule.Tags
    }
}

function Get-AutoCategoryRuleCollection {
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter()][AllowNull()][object]$Data
    )

    $rules = New-Object 'System.Collections.Generic.List[object]'
    if ($Data -is [hashtable] -and
        $Data.ContainsKey('AutoCategories') -and
        $Data.AutoCategories -is [System.Collections.IEnumerable]) {
        foreach ($sourceRule in $Data.AutoCategories) {
            $rule = ConvertTo-AutoCategoryRule -Rule $sourceRule
            if ($rule) {
                $null = $rules.Add($rule)
            }
        }
    }

    if ($rules.Count -eq 0) {
        return @($script:DefaultAutoCategoryRules)
    }

    return $rules.ToArray()
}

function Test-AutoCategoryRuleMatch {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][object]$Rule
    )

    foreach ($pattern in @(ConvertTo-StringValueCollection -Value $Rule.Patterns)) {
        if ([System.Text.RegularExpressions.Regex]::IsMatch($Name, $pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)) {
            return $true
        }
    }

    return $false
}

function Resolve-ColorScriptAutoCategory {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Rule
    )

    $categoryCollection = New-Object 'System.Collections.Generic.List[string]'
    $tagCollection = New-Object 'System.Collections.Generic.List[string]'
    foreach ($currentRule in $Rule) {
        if (-not (Test-AutoCategoryRuleMatch -Name $Name -Rule $currentRule)) {
            continue
        }

        if (-not $categoryCollection.Contains($currentRule.Category)) {
            $null = $categoryCollection.Add([string]$currentRule.Category)
        }
        foreach ($tag in @(ConvertTo-StringValueCollection -Value $currentRule.Tags)) {
            if (-not $tagCollection.Contains($tag)) {
                $null = $tagCollection.Add($tag)
            }
        }
    }

    if ($categoryCollection.Count -eq 0) {
        $null = $categoryCollection.Add('Abstract')
    }

    return [pscustomobject]@{
        Categories = [string[]]$categoryCollection.ToArray()
        Tags       = [string[]]$tagCollection.ToArray()
    }
}

function Get-EmptyColorScriptMetadataRecord {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param()

    return [pscustomobject]@{
        Category    = 'Uncategorized'
        Categories  = @()
        Tags        = @()
        Description = $null
    }
}

function ConvertTo-AutoCategorizedMetadataRecord {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][object]$ExistingEntry,
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Rule
    )

    $autoInfo = Resolve-ColorScriptAutoCategory -Name $Name -Rule $Rule
    $baseCategories = New-Object 'System.Collections.Generic.List[string]'
    if ($ExistingEntry.Category -and $ExistingEntry.Category -ne 'Uncategorized') {
        $null = $baseCategories.Add([string]$ExistingEntry.Category)
    }
    foreach ($category in @(ConvertTo-StringValueCollection -Value $ExistingEntry.Categories)) {
        if ($category -ne 'Uncategorized') {
            $null = $baseCategories.Add($category)
        }
    }

    $categories = [string[]](Merge-UniqueStringValue -Existing $baseCategories.ToArray() -Additional $autoInfo.Categories)
    if ($categories.Count -eq 0) {
        $categories = @('Abstract')
    }

    $category = if ($ExistingEntry.Category -and $ExistingEntry.Category -ne 'Uncategorized') {
        [string]$ExistingEntry.Category
    }
    else {
        $categories[0]
    }
    $autoTagCollection = @($autoInfo.Tags) + @($categories | ForEach-Object { "Category:$($_)" }) + @('AutoCategorized')
    $tags = [string[]](Merge-UniqueStringValue -Existing (ConvertTo-StringValueCollection -Value $ExistingEntry.Tags) -Additional $autoTagCollection)

    return [pscustomobject]@{
        Category    = $category
        Categories  = $categories
        Tags        = $tags
        Description = $ExistingEntry.Description
    }
}

function Merge-InventoryAutoCategoryRecord {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][object]$Store,
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Rule
    )

    foreach ($scriptFile in @(Get-ColorScriptInventory -Raw)) {
        $name = $scriptFile.BaseName
        $entryExists = $Store.ContainsKey($name)
        $existingEntry = if ($entryExists) { $Store[$name] } else { Get-EmptyColorScriptMetadataRecord }
        $needsAutoCategory = -not $entryExists -or
        [string]::IsNullOrWhiteSpace($existingEntry.Category) -or
        $existingEntry.Category -eq 'Uncategorized'
        if ($needsAutoCategory) {
            $Store[$name] = ConvertTo-AutoCategorizedMetadataRecord -Name $name -ExistingEntry $existingEntry -Rule $Rule
        }
    }
}

function ConvertTo-NormalizedColorScriptMetadataRecord {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][object]$Entry
    )

    $baseCategories = New-Object 'System.Collections.Generic.List[string]'
    if ($Entry.Category -and $Entry.Category -ne 'Uncategorized') {
        $null = $baseCategories.Add([string]$Entry.Category)
    }
    foreach ($category in @(ConvertTo-StringValueCollection -Value $Entry.Categories)) {
        $null = $baseCategories.Add($category)
    }
    if ($baseCategories.Count -eq 0) {
        $null = $baseCategories.Add('Abstract')
    }

    $categories = [string[]](Merge-UniqueStringValue -Existing @() -Additional $baseCategories.ToArray())
    if ($categories.Count -eq 0) {
        $categories = @('Abstract')
    }
    $categoryTags = @($categories | ForEach-Object { "Category:$($_)" })
    $tags = [string[]](Merge-UniqueStringValue -Existing (ConvertTo-StringValueCollection -Value $Entry.Tags) -Additional $categoryTags)

    return [pscustomobject]@{
        Category    = $categories[0]
        Categories  = $categories
        Tags        = $tags
        Description = $Entry.Description
    }
}

function Convert-ColorScriptMetadataStore {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][object]$Store
    )

    foreach ($key in @($Store.Keys)) {
        $Store[$key] = ConvertTo-NormalizedColorScriptMetadataRecord -Entry $Store[$key]
    }
}

function Use-ColorScriptMetadataStore {
    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter(Mandatory)][object]$Store,
        [Parameter()][AllowNull()][object]$MetadataTimestamp,
        [Parameter()][AllowNull()][object]$InventoryTimestamp
    )

    $script:MetadataCache = $Store
    $script:MetadataLastWriteTime = $MetadataTimestamp
    $script:MetadataInventoryLastWriteTime = $InventoryTimestamp
    return $script:MetadataCache
}

function Write-ColorScriptMetadataJsonCache {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$CachePath,
        [Parameter(Mandatory)][object]$Store
    )

    try {
        $jsonData = $Store | ConvertTo-Json -Depth 10 -Compress
        Invoke-FileWriteAllText -Path $CachePath -Content $jsonData -Encoding $script:Utf8NoBomEncoding
        Write-Verbose 'Saved metadata to JSON cache for faster future loads'
    }
    catch {
        Write-Verbose "Failed to save JSON metadata cache: $($_.Exception.Message)"
    }
}

function Get-ColorScriptMetadataTableInternal {
    $metadataTimestamp = Get-ColorScriptMetadataTimestamp
    $inventoryTimestamp = Get-ColorScriptInventoryTimestamp
    if (Test-ColorScriptMetadataMemoryCache -MetadataTimestamp $metadataTimestamp -InventoryTimestamp $inventoryTimestamp) {
        return $script:MetadataCache
    }

    $binaryCachePath = Get-ColorScriptMetadataCachePath
    if ($binaryCachePath) {
        $cachedStore = Import-ColorScriptMetadataJsonCache -CachePath $binaryCachePath -MetadataTimestamp $metadataTimestamp -InventoryTimestamp $inventoryTimestamp
        if ($cachedStore) {
            return Use-ColorScriptMetadataStore -Store $cachedStore -MetadataTimestamp $metadataTimestamp -InventoryTimestamp $inventoryTimestamp
        }
    }

    $data = Import-ColorScriptMetadataFile
    $store = ConvertTo-ColorScriptMetadataStore -Data $data
    $autoCategoryRule = @(Get-AutoCategoryRuleCollection -Data $data)
    Merge-InventoryAutoCategoryRecord -Store $store -Rule $autoCategoryRule
    Convert-ColorScriptMetadataStore -Store $store
    $result = Use-ColorScriptMetadataStore -Store $store -MetadataTimestamp $metadataTimestamp -InventoryTimestamp $inventoryTimestamp

    if ($binaryCachePath) {
        Write-ColorScriptMetadataJsonCache -CachePath $binaryCachePath -Store $store
    }

    return $result
}