tests/Get-DFCategoryDb.Tests.ps1

BeforeAll {
    . "$PSScriptRoot/../Private/Test-DFCategoryDbSchema.ps1"
    . "$PSScriptRoot/../Private/Get-DFCategoryDb.ps1"

    function New-FixtureDbFile {
        param([string]$Path)
        @{
            schemaVersion = 1
            updated       = '2026-07-01'
            taxonomy      = @{ function = @('search', 'editor', 'file-viewing'); worksWith = @('text', 'filesystem') }
            tools         = [ordered]@{
                ripgrep = @{
                    function = @('search'); worksWith = @('text', 'filesystem'); interface = 'cli'
                    aliases = @('rg'); relatedTo = @('fd'); alternativeTo = @('grep')
                    ids = @{ scoop = 'main/ripgrep'; winget = 'BurntSushi.ripgrep.MSVC' }
                    popularity = 3
                }
                fd = @{
                    function = @('search'); worksWith = @('filesystem'); interface = 'cli'
                    ids = @{ scoop = 'fd' }   # bare name, as Tools/*.json stores it —
                    popularity = 2            # proves the bucket-stripping fallback below
                }
                bat = @{
                    function = @('file-viewing'); worksWith = @('text'); interface = 'cli'
                    alternativeTo = @('cat')
                    popularity = 2
                }
                micro = @{
                    function = @('editor'); worksWith = @('text'); interface = 'cli'
                    popularity = 1
                }
            }
        } | ConvertTo-Json -Depth 6 | Set-Content -Path $Path -Encoding UTF8
    }
}

Describe 'Get-DFCategoryDb' {
    BeforeEach {
        $script:FixturePath = Join-Path $TestDrive 'tool-categories.json'
        New-FixtureDbFile -Path $script:FixturePath
        $script:DFCategoryDb = $null
        $script:DFCategoryDbWarned = $false
    }
    AfterEach {
        $script:DFCategoryDb = $null
        $script:DFCategoryDbWarned = $false
    }

    It 'loads a valid fixture and exposes the raw taxonomy' {
        $db = Get-DFCategoryDb -Path $script:FixturePath
        $db.Raw.tools.ripgrep.function | Should -Contain 'search'
        $db.Raw.taxonomy.function | Should -Contain 'editor'
    }

    It 'builds a name index including aliases, lowercased' {
        $db = Get-DFCategoryDb -Path $script:FixturePath
        $db.NameIndex['ripgrep'] | Should -Be 'ripgrep'
        $db.NameIndex['rg'] | Should -Be 'ripgrep'
    }

    It 'builds an id index from each tool''s ids' {
        $db = Get-DFCategoryDb -Path $script:FixturePath
        $db.IdIndex['scoop:main/ripgrep'] | Should -Be 'ripgrep'
        $db.IdIndex['winget:burntsushi.ripgrep.msvc'] | Should -Be 'ripgrep'
    }

    It 'builds an inverted facet index' {
        $db = Get-DFCategoryDb -Path $script:FixturePath
        $db.FacetIndex['function:search'] | Should -Contain 'ripgrep'
        $db.FacetIndex['function:search'] | Should -Contain 'fd'
        $db.FacetIndex['worksWith:text'] | Should -Contain 'bat'
    }

    It 'caches the -Path load as a singleton until -Force' {
        # -Path always forces a fresh read itself (per spec) — singleton caching
        # is exercised through the no-arg call path, which -Path feeds via the
        # shipped-side override so this still validates real caching behavior.
        $db1 = Get-DFCategoryDb -Path $script:FixturePath
        $script:DFCategoryDb = $db1   # simulate what a no-Path call would have cached
        $db2 = Get-DFCategoryDb
        $db2 | Should -Be $db1
        $db3 = Get-DFCategoryDb -Force -Path $script:FixturePath
        $db3 | Should -Not -BeNullOrEmpty
    }

    It 'returns an empty-but-valid object when the shipped file is unreadable, and warns once' {
        Set-Content -Path $script:FixturePath -Value 'not json {{{' -Encoding UTF8
        $db = Get-DFCategoryDb -Path $script:FixturePath -WarningAction SilentlyContinue
        $db.Raw | Should -BeNullOrEmpty
        $db.NameIndex.Count | Should -Be 0
        $db.FacetIndex.Count | Should -Be 0
    }

    It 'a refreshed copy newer than shipped wins' {
        $script:SavedDataHome = $Env:XDG_DATA_HOME
        $Env:XDG_DATA_HOME = Join-Path $TestDrive 'data'
        try {
            New-Item -ItemType Directory -Path (Join-Path $Env:XDG_DATA_HOME 'dotforge') -Force | Out-Null
            $refreshedPath = Join-Path $Env:XDG_DATA_HOME 'dotforge/tool-categories.json'
            @{ schemaVersion = 1; updated = '2026-09-01'; taxonomy = @{ function = @('refreshed-value'); worksWith = @('text') }; tools = @{} } |
                ConvertTo-Json -Depth 6 | Set-Content $refreshedPath
            # Fixture ("shipped" stand-in via -Path) is dated 2026-07-01 (older).
            # NOTE: taxonomy.worksWith must be non-empty per Test-DFCategoryDbSchema
            # (Task 1) or this refreshed doc fails schema validation and falls back
            # to shipped, defeating the point of this test.
            $db = Get-DFCategoryDb -Path $script:FixturePath
            $db.Raw.taxonomy.function | Should -Contain 'refreshed-value'
        } finally { $Env:XDG_DATA_HOME = $script:SavedDataHome }
    }

    It 'a refreshed copy OLDER than shipped is ignored' {
        $script:SavedDataHome = $Env:XDG_DATA_HOME
        $Env:XDG_DATA_HOME = Join-Path $TestDrive 'data-old'
        try {
            New-Item -ItemType Directory -Path (Join-Path $Env:XDG_DATA_HOME 'dotforge') -Force | Out-Null
            $refreshedPath = Join-Path $Env:XDG_DATA_HOME 'dotforge/tool-categories.json'
            @{ schemaVersion = 1; updated = '2020-01-01'; taxonomy = @{ function = @('stale-value'); worksWith = @() }; tools = @{} } |
                ConvertTo-Json -Depth 6 | Set-Content $refreshedPath
            $db = Get-DFCategoryDb -Path $script:FixturePath   # fixture dated 2026-07-01, newer
            $db.Raw.taxonomy.function | Should -Not -Contain 'stale-value'
            $db.Raw.taxonomy.function | Should -Contain 'editor'   # the fixture's own real content
        } finally { $Env:XDG_DATA_HOME = $script:SavedDataHome }
    }

    It 'a refreshed copy with unparseable JSON falls back to shipped without throwing' {
        $script:SavedDataHome = $Env:XDG_DATA_HOME
        $Env:XDG_DATA_HOME = Join-Path $TestDrive 'data-corrupt'
        try {
            New-Item -ItemType Directory -Path (Join-Path $Env:XDG_DATA_HOME 'dotforge') -Force | Out-Null
            'not json {{{' | Set-Content (Join-Path $Env:XDG_DATA_HOME 'dotforge/tool-categories.json')
            { Get-DFCategoryDb -Path $script:FixturePath -WarningAction SilentlyContinue } | Should -Not -Throw
            $db = Get-DFCategoryDb -Path $script:FixturePath -WarningAction SilentlyContinue
            $db.Raw.taxonomy.function | Should -Contain 'editor'   # fell back to the fixture
        } finally { $Env:XDG_DATA_HOME = $script:SavedDataHome }
    }

    It 'a refreshed copy that parses but fails schema validation falls back to shipped' {
        # Distinct from the unparseable-JSON case above: this file IS valid
        # JSON with a valid, newer 'updated' date (so it gets selected as the
        # resolved path), but its tools entry uses an unknown function value
        # — a schema failure, not a parse failure.
        $script:SavedDataHome = $Env:XDG_DATA_HOME
        $Env:XDG_DATA_HOME = Join-Path $TestDrive 'data-schema-invalid'
        try {
            New-Item -ItemType Directory -Path (Join-Path $Env:XDG_DATA_HOME 'dotforge') -Force | Out-Null
            $refreshedPath = Join-Path $Env:XDG_DATA_HOME 'dotforge/tool-categories.json'
            @{
                schemaVersion = 1; updated = '2026-09-01'
                taxonomy = @{ function = @('search'); worksWith = @() }
                tools    = @{ broken = @{ function = @('not-a-real-function'); interface = 'cli' } }
            } | ConvertTo-Json -Depth 6 | Set-Content $refreshedPath

            $db = Get-DFCategoryDb -Path $script:FixturePath -WarningAction SilentlyContinue
            $db.Raw.taxonomy.function | Should -Contain 'editor'   # fell back to the fixture, not the newer-but-invalid refreshed copy
        } finally { $Env:XDG_DATA_HOME = $script:SavedDataHome }
    }
}

Describe 'Get-DFCategoryDbEntry' {
    BeforeEach { $script:Db = Get-DFCategoryDb -Path (Join-Path $TestDrive 'fixture.json') }
    BeforeAll { New-FixtureDbFile -Path (Join-Path $TestDrive 'fixture.json') }

    It 'resolves via DFTool name' {
        $info = [pscustomobject]@{ DFTool = 'ripgrep'; Name = 'irrelevant'; Sources = @() }
        (Get-DFCategoryDbEntry -Info $info -Database $script:Db).Key | Should -Be 'ripgrep'
    }

    It 'resolves via a source:packageId' {
        $src = [pscustomobject]@{ Source = 'winget'; PackageId = 'BurntSushi.ripgrep.MSVC' }
        $info = [pscustomobject]@{ DFTool = $null; Name = 'irrelevant'; Sources = @($src) }
        (Get-DFCategoryDbEntry -Info $info -Database $script:Db).Key | Should -Be 'ripgrep'
    }

    It 'resolves a bucket-qualified LIVE scoop id against a bare-name SEED id' {
        # Fixture's 'fd' entry stores ids.scoop = 'fd' (bare, matching Tools/*.json
        # convention); a live scoop hit reports the bucket-qualified 'extras/fd'.
        # A direct "scoop:extras/fd" lookup misses the index key "scoop:fd" — the
        # trailing-segment fallback must still resolve it.
        $src = [pscustomobject]@{ Source = 'scoop'; PackageId = 'extras/fd' }
        $info = [pscustomobject]@{ DFTool = $null; Name = 'irrelevant'; Sources = @($src) }
        (Get-DFCategoryDbEntry -Info $info -Database $script:Db).Key | Should -Be 'fd'
    }

    It 'resolves via plain name as a last resort' {
        $info = [pscustomobject]@{ DFTool = $null; Name = 'bat'; Sources = @() }
        (Get-DFCategoryDbEntry -Info $info -Database $script:Db).Key | Should -Be 'bat'
    }

    It 'returns $null for an unmapped tool' {
        $info = [pscustomobject]@{ DFTool = $null; Name = 'totally-unknown-tool'; Sources = @() }
        Get-DFCategoryDbEntry -Info $info -Database $script:Db | Should -BeNullOrEmpty
    }
}

Describe 'Get-DFCategoryRelatedTools' {
    BeforeAll {
        New-FixtureDbFile -Path (Join-Path $TestDrive 'fixture2.json')
        $script:Db = Get-DFCategoryDb -Path (Join-Path $TestDrive 'fixture2.json')
    }

    It 'lists curated relatedTo first' {
        (Get-DFCategoryRelatedTools -Database $script:Db -Key 'ripgrep') | Select-Object -First 1 | Should -Be 'fd'
    }

    It 'fills remaining slots with same-function tools by popularity, excluding self' {
        # 'fd' shares function 'search' with 'ripgrep'; ripgrep's own relatedTo already lists 'fd'
        # so the fill should not duplicate it, and must never include 'ripgrep' itself.
        $related = Get-DFCategoryRelatedTools -Database $script:Db -Key 'ripgrep'
        $related | Should -Not -Contain 'ripgrep'
        @($related | Where-Object { $_ -eq 'fd' }).Count | Should -Be 1
    }

    It 'caps at 6 and returns fewer when fewer are available' {
        $related = Get-DFCategoryRelatedTools -Database $script:Db -Key 'micro'
        $related.Count | Should -BeLessOrEqual 6
    }
}