tests/Find-DFPackage.Tests.ps1

BeforeAll {
    . "$PSScriptRoot/../Public/New-DFDirectory.ps1"
    . "$PSScriptRoot/../Private/Test-DFOutputPiped.ps1"
    . "$PSScriptRoot/../Private/Test-DFToolSchema.ps1"
    . "$PSScriptRoot/../Private/Import-DFToolDb.ps1"
    . "$PSScriptRoot/../Private/DFCatalog.ps1"
    . "$PSScriptRoot/../Private/Get-DFCatalogInstalled.ps1"
    . "$PSScriptRoot/../Private/Format-DFToolInfo.ps1"
    . "$PSScriptRoot/../Private/Start-DFCatalogRefreshJob.ps1"
    . "$PSScriptRoot/../Private/Get-DFGitHubRepoInfo.ps1"
    . "$PSScriptRoot/../Private/Get-DFPackageReadme.ps1"
    . "$PSScriptRoot/../Private/Test-DFToolIdentityGuideSchema.ps1"
    . "$PSScriptRoot/../Private/Get-DFToolIdentityGuide.ps1"
    . "$PSScriptRoot/../Private/Resolve-DFCatalogQueryMerge.ps1"
    . "$PSScriptRoot/../Private/Invoke-DFPagerExe.ps1"
    . "$PSScriptRoot/../Public/DFHelpers.Pager.ps1"
    . "$PSScriptRoot/../Public/Find-DFPackage.ps1"
    . "$PSScriptRoot/../Private/Get-DFCategoryDb.ps1"
}

Describe 'Find-DFPackage' {
    BeforeEach {
        $script:SavedXdgCache = $Env:XDG_CACHE_HOME
        $Env:XDG_CACHE_HOME = Join-Path $TestDrive 'cache'
        $script:SavedPager = $Env:Pager
        $Env:Pager = $null
        $script:SavedProviders = $script:DFCatalogProviders

        # Isolated fake providers — no disk, no network.
        $script:DFCatalogProviders = @{
            scoop = @{
                Name = 'scoop'; Kind = 'snapshot'
                Test = { $true }
                Search = { param($Query, $Fresh)
                    if ($Query -eq 'ripgrep') {
                        New-DFToolSourceInfo -Source 'scoop' -PackageId 'main/ripgrep' -Name 'ripgrep' `
                            -Description 'search tool' -LatestVersion '14.1.1' -Homepage 'https://rg.example' `
                            -License 'MIT' -MatchKind 'exact-name'
                    }
                }
                GetInstalled = {
                    [pscustomobject]@{ Source = 'scoop'; Name = 'ripgrep'; PackageId = 'ripgrep'; InstalledVersion = '14.1.0' }
                }
                Refresh = { }
                Detail = { param($Id, $Fresh)
                    New-DFToolSourceDetail -Source 'scoop' -PackageId $Id `
                        -InstallHint "scoop install $Id" -Notes 'test note' }
            }
            choco = @{
                Name = 'choco'; Kind = 'query-cache'
                Test = { $true }
                Search = { param($Query, $Fresh)
                    if ($Query -eq 'ripgrep') {
                        New-DFToolSourceInfo -Source 'choco' -PackageId 'ripgrep' -Name 'ripgrep' `
                            -LatestVersion '14.1.0' -MatchKind 'exact-name'
                    }
                }
                GetInstalled = { }
                Refresh = { }
            }
        }
        $script:DFCatalogAvailability = @{}

        # Get-DFCatalogInstalled (trifle-live-install-status) now calls
        # Invoke-DFCatalogInstalledFetch against the real shipped provider files
        # instead of iterating $script:DFCatalogProviders -- mock the fetch
        # itself so these tests' fake GetInstalled scriptblocks are still
        # consulted, keeping them isolated from this machine's real installed
        # packages. IdentityMap construction is untouched and still runs for
        # real (against the live Tools/*.json db), unless a test below
        # overrides Get-DFCatalogInstalled wholesale for its own fixture.
        Mock Invoke-DFCatalogInstalledFetch {
            @(foreach ($p in $script:DFCatalogProviders.Values) {
                if ($p.GetInstalled) { & $p.GetInstalled }
            })
        }

        Mock Test-DFOutputPiped { $true }   # default: object output

        # Isolated, harmless default so the query-path detail-enrichment
        # block's unconditional Get-DFCategoryDb call never touches the real
        # shipped data/tool-categories.json. Tests that specifically exercise
        # category behavior override this with their own richer fixture.
        Mock Get-DFCategoryDb { [pscustomobject]@{ Raw = $null; FacetIndex = @{}; NameIndex = @{}; IdIndex = @{} } }
        Mock Get-DFToolIdentityGuide { [pscustomobject]@{ Raw = $null; IdIndex = @{} } }
    }
    AfterEach {
        $Env:XDG_CACHE_HOME = $script:SavedXdgCache
        $Env:Pager = $script:SavedPager
        $script:DFCatalogProviders = $script:SavedProviders
        $script:DFCatalogAvailability = @{}
    }

    It 'merges hits from multiple catalogs into one DotForge.ToolInfo' {
        $r = @(Find-DFPackage ripgrep)
        $r.Count | Should -Be 1
        $r[0].PSObject.TypeNames[0] | Should -Be 'DotForge.ToolInfo'
        $r[0].Name | Should -Be 'ripgrep'
        $r[0].Sources.Count | Should -Be 2
        $r[0].Latest['scoop'] | Should -Be '14.1.1'
        $r[0].Latest['choco'] | Should -Be '14.1.0'
    }

    It 'marks installed state from the owning catalog' {
        $r = @(Find-DFPackage ripgrep)
        $r[0].Installed | Should -BeTrue
        $r[0].InstalledVia | Should -Be @('scoop')
        $r[0].InstalledVersion | Should -Be '14.1.0'
        ($r[0].Sources | Where-Object Source -eq 'scoop').Installed | Should -BeTrue
    }

    It 'fills description and homepage from the first source that has them' {
        $r = @(Find-DFPackage ripgrep)
        $r[0].Description | Should -Be 'search tool'
        $r[0].Homepage | Should -Be 'https://rg.example'
        $r[0].License | Should -Be 'MIT'
    }

    It 'accepts multi-word queries via remaining arguments' {
        { Find-DFPackage static site generator } | Should -Not -Throw
    }

    It 'filters providers with -Source' {
        $r = @(Find-DFPackage ripgrep -Source choco)
        $r[0].Sources.Count | Should -Be 1
        $r[0].Sources[0].Source | Should -Be 'choco'
    }

    It 'records the query in seen-queries' {
        $null = Find-DFPackage ripgrep
        Test-Path (Join-Path $Env:XDG_CACHE_HOME 'dotforge/catalogs/seen-queries.json') | Should -BeTrue
    }

    It 'returns raw objects when piped even without -AsObject' {
        $r = Find-DFPackage ripgrep | ForEach-Object { $_ }
        $r.PSObject.TypeNames[0] | Should -Be 'DotForge.ToolInfo'
    }

    It 'renders an info card for a confident single match when interactive' {
        Mock Test-DFOutputPiped { $false }
        $saved = $Env:NO_COLOR; $Env:NO_COLOR = '1'
        try {
            $out = Find-DFPackage ripgrep
            ($out -join "`n") | Should -Match 'Installed'
            ($out -join "`n") | Should -Match 'main/ripgrep'
            $out | Should -BeOfType [string]
        } finally { $Env:NO_COLOR = $saved }
    }

    It 'honors -AsObject even when interactive' {
        Mock Test-DFOutputPiped { $false }
        $r = @(Find-DFPackage ripgrep -AsObject)
        $r[0].PSObject.TypeNames[0] | Should -Be 'DotForge.ToolInfo'
    }

    It 'reports when nothing matches' {
        Mock Test-DFOutputPiped { $false }
        $out = Find-DFPackage zzz-nonexistent
        ($out -join "`n") | Should -Match 'No packages found'
    }

    It 'is aliased to trifle' {
        Get-Alias trifle -ErrorAction Ignore | Should -Not -BeNullOrEmpty
    }

    It 'unifies differently-named packages via the Tools/*.json identity map' {
        $script:DFCatalogProviders['winget'] = @{
            Name = 'winget'; Kind = 'snapshot'; Test = { $true }
            Search = { param($Query, $Fresh)
                if ($Query -eq 'ripgrep') {
                    New-DFToolSourceInfo -Source 'winget' -PackageId 'BurntSushi.ripgrep.MSVC' `
                        -Name 'BurntSushi.ripgrep.MSVC' -LatestVersion '14.1.1' -MatchKind 'exact-name'
                }
            }
            GetInstalled = { }
            Refresh = { }
        }
        Mock Get-DFCatalogInstalled {
            @{
                Items       = @([pscustomobject]@{ Source = 'scoop'; Name = 'ripgrep'; PackageId = 'ripgrep'; InstalledVersion = '14.1.0' })
                IdentityMap = @{
                    'scoop:main/ripgrep'                = 'ripgrep'
                    'scoop:ripgrep'                     = 'ripgrep'
                    'winget:burntsushi.ripgrep.msvc'    = 'ripgrep'
                    'choco:ripgrep'                     = 'ripgrep'
                }
            }
        }
        $r = @(Find-DFPackage ripgrep)
        $r.Count | Should -Be 1
        $r[0].Name | Should -Be 'ripgrep'
        $r[0].DFTool | Should -Be 'ripgrep'
        $r[0].Sources.Source | Should -Contain 'winget'
        $r[0].Sources.Source | Should -Contain 'scoop'
    }

    It 'keeps a name-matched hit from an unlinked source separate (no false merge)' {
        # scoop is identity-mapped to DF tool 'ripgrep'; choco has no identity
        # entry at all. Sharing a package name is not proof of shared
        # identity, so they must render as two separate rows, not merge.
        Mock Get-DFCatalogInstalled {
            @{
                Items       = @()
                IdentityMap = @{ 'scoop:main/ripgrep' = 'ripgrep'; 'scoop:ripgrep' = 'ripgrep' }
            }
        }
        $r = @(Find-DFPackage ripgrep)
        $r.Count | Should -Be 2
        ($r | Where-Object DFTool -eq 'ripgrep').Sources.Source | Should -Be @('scoop')
        ($r | Where-Object { -not $_.DFTool }).Sources.Source | Should -Be @('choco')
    }

    It 'orders exact matches before keyword matches' {
        # Provider emits the keyword hit FIRST — the exact match must still
        # come out on top of the merged results.
        $script:DFCatalogProviders['scoop'].Search = { param($Query, $Fresh)
            if ($Query -eq 'zed') {
                New-DFToolSourceInfo -Source 'scoop' -PackageId 'extras/zed-preview' -Name 'zed-preview' `
                    -Description 'zed editor preview channel' -LatestVersion '0.190.0' -MatchKind 'keyword'
                New-DFToolSourceInfo -Source 'scoop' -PackageId 'extras/zed' -Name 'zed' `
                    -Description 'code editor' -LatestVersion '0.189.0' -MatchKind 'exact-name'
            }
        }
        $r = @(Find-DFPackage zed)
        $r.Count | Should -Be 2
        $r[0].Name | Should -Be 'zed'
        $r[1].Name | Should -Be 'zed-preview'
    }

    It 'reports progress while searching catalogs' {
        Mock Write-Progress { }
        $null = Find-DFPackage ripgrep
        Should -Invoke Write-Progress -ParameterFilter { $Status -like '*scoop*' }
        Should -Invoke Write-Progress -ParameterFilter { $Completed }
    }

    It 'marks a PATH-only command as installed via PATH' {
        # No catalog reports it installed, but the executable is on PATH.
        Mock Get-DFCatalogInstalled { @{ Items = @(); IdentityMap = @{} } }
        Mock Get-Command { [pscustomobject]@{ Name = 'ripgrep.exe'; Version = [version]'14.1.0' } } -ParameterFilter { $Name -eq 'ripgrep' }
        $r = @(Find-DFPackage ripgrep)
        $r[0].Installed | Should -BeTrue
        $r[0].InstalledVia | Should -Be @('PATH')
    }

    It 'enriches the top exact match with Details' {
        $r = @(Find-DFPackage ripgrep)
        $r[0].Details | Should -Not -BeNullOrEmpty
        $r[0].Details['scoop'].InstallHint | Should -Be 'scoop install main/ripgrep'
    }

    It 'renders the DETAIL card interactively on an exact match even with other keyword hits' {
        Mock Test-DFOutputPiped { $false }
        $script:DFCatalogProviders['scoop'].Search = { param($Query, $Fresh)
            if ($Query -eq 'ripgrep') {
                New-DFToolSourceInfo -Source 'scoop' -PackageId 'main/ripgrep' -Name 'ripgrep' `
                    -Description 'search tool' -LatestVersion '14.1.1' -MatchKind 'exact-name'
                New-DFToolSourceInfo -Source 'scoop' -PackageId 'main/ripgrep-all' -Name 'ripgrep-all' `
                    -Description 'rga' -LatestVersion '1.0' -MatchKind 'keyword'
            }
        }
        $saved = $Env:NO_COLOR; $Env:NO_COLOR = '1'
        try {
            $out = (Find-DFPackage ripgrep) -join "`n"
            $out | Should -Match 'Install\s+scoop install main/ripgrep'
            $out | Should -Match '\+ 1 more matches — trifle ripgrep -All'
        } finally { $Env:NO_COLOR = $saved }
    }

    It '-All always renders the table, never the card' {
        Mock Test-DFOutputPiped { $false }
        $saved = $Env:NO_COLOR; $Env:NO_COLOR = '1'
        try {
            $out = (Find-DFPackage ripgrep -All) -join "`n"
            $out | Should -Match 'Name\s+'
            $out | Should -Not -Match 'Installed'
        } finally { $Env:NO_COLOR = $saved }
    }

    It 'resolves a qualified source:packageId query' {
        $r = @(Find-DFPackage scoop:main/ripgrep)
        $r.Count | Should -Be 1
        $r[0].Name | Should -Be 'ripgrep'
        $r[0].Details['scoop'] | Should -Not -BeNullOrEmpty
    }

    It 'passes the full scoped id (not the bare trailing segment) to a non-scoop qualified query' {
        $script:CapturedNpmQuery = $null
        $script:DFCatalogProviders['npm'] = @{
            Name = 'npm'; Kind = 'query-cache'
            Test = { $true }
            Search = { param($Query, $Fresh)
                $script:CapturedNpmQuery = $Query
                if ($Query -eq '@scope/tool') {
                    New-DFToolSourceInfo -Source 'npm' -PackageId '@scope/tool' -Name '@scope/tool' `
                        -LatestVersion '1.0.0' -MatchKind 'exact-id'
                }
            }
            GetInstalled = { }
            Refresh = { }
        }
        try {
            $r = @(Find-DFPackage npm:@scope/tool)
            $script:CapturedNpmQuery | Should -Be '@scope/tool'
            $r.Count | Should -Be 1
            $r[0].Sources[0].PackageId | Should -Be '@scope/tool'
        } finally { Remove-Variable -Name CapturedNpmQuery -Scope Script -ErrorAction Ignore }
    }

    It 'falls back to keyword search for an unknown qualified prefix' {
        { Find-DFPackage foo:bar } | Should -Not -Throw
    }

    It 'warns and falls back when a qualified id does not exist' {
        $w = $null
        $null = Find-DFPackage scoop:main/nope -WarningVariable w -WarningAction SilentlyContinue
        "$w" | Should -Match "No package 'main/nope' found in scoop"
    }

    It '-GitInfo attaches GitHub repo info' {
        Mock Get-DFGitHubRepoInfo { [pscustomobject]@{ PSTypeName = 'DotForge.RepoInfo'; Stars = 5 } }
        Mock Resolve-DFGitHubRepoUrl { [pscustomobject]@{ Owner = 'a'; Repo = 'b' } }
        $r = @(Find-DFPackage ripgrep -GitInfo)
        $r[0].GitHub.Stars | Should -Be 5
    }

    It '-Readme pages the readme after the card' {
        Mock Test-DFOutputPiped { $false }
        Mock Get-DFPackageReadme { @('# readme line') }
        $saved = $Env:NO_COLOR; $Env:NO_COLOR = '1'
        try {
            $out = (Find-DFPackage ripgrep -Readme) -join "`n"
            $out | Should -Match '# readme line'
        } finally { $Env:NO_COLOR = $saved }
    }

    It 'marks a failed detail fetch as null in Details' {
        $script:DFCatalogProviders['choco'].Detail = { param($Id, $Fresh) throw 'api down' }
        $r = @(Find-DFPackage ripgrep)
        $r[0].Details.Contains('choco') | Should -BeTrue
        $r[0].Details['choco'] | Should -BeNullOrEmpty
    }

    It 'attaches Category (Entry + Related) to the top exact match' {
        Mock Get-DFCategoryDb {
            [pscustomobject]@{
                Raw = [pscustomobject]@{
                    taxonomy = [pscustomobject]@{ function = @('search'); worksWith = @('text') }
                    tools    = [pscustomobject]@{
                        ripgrep = [pscustomobject]@{ function = @('search'); worksWith = @('text'); interface = 'cli'; alternativeTo = @('grep') }
                    }
                }
                FacetIndex = @{ 'function:search' = @('ripgrep') }
                NameIndex  = @{ 'ripgrep' = 'ripgrep' }
                IdIndex    = @{}
            }
        }
        $r = @(Find-DFPackage ripgrep)
        $r[0].Category.Entry.alternativeTo | Should -Contain 'grep'
    }

    It 'leaves Category $null when the tool is not in the seed db' {
        Mock Get-DFCategoryDb { [pscustomobject]@{ Raw = $null; FacetIndex = @{}; NameIndex = @{}; IdIndex = @{} } }
        $r = @(Find-DFPackage ripgrep)
        $r[0].Category | Should -BeNullOrEmpty
    }

    Context '-Category / -WorksWith facet search' {
        BeforeEach {
            $script:FakeDb = [pscustomobject]@{
                Raw = [pscustomobject]@{
                    taxonomy = [pscustomobject]@{ function = @('search', 'editor'); worksWith = @('text', 'filesystem') }
                    tools    = [pscustomobject]@{
                        ripgrep = [pscustomobject]@{
                            function = @('search'); worksWith = @('text', 'filesystem'); interface = 'cli'
                            ids = [pscustomobject]@{ scoop = 'ripgrep' }
                        }
                        micro = [pscustomobject]@{
                            function = @('editor'); worksWith = @('text'); interface = 'tui'
                        }
                    }
                }
                FacetIndex = @{
                    'function:search'   = @('ripgrep')
                    'function:editor'   = @('micro')
                    'worksWith:text'    = @('ripgrep', 'micro')
                    'worksWith:filesystem' = @('ripgrep')
                }
            }
            Mock Get-DFCategoryDb { $script:FakeDb }

            # 'micro' has no declared 'ids', so facet resolution falls back to
            # searching its bare db key — the fake scoop provider must be able
            # to answer that live, exactly as it already does for 'ripgrep'.
            $script:DFCatalogProviders['scoop'].Search = { param($Query, $Fresh)
                if ($Query -eq 'ripgrep') {
                    New-DFToolSourceInfo -Source 'scoop' -PackageId 'main/ripgrep' -Name 'ripgrep' `
                        -Description 'search tool' -LatestVersion '14.1.1' -Homepage 'https://rg.example' `
                        -License 'MIT' -MatchKind 'exact-name'
                } elseif ($Query -eq 'micro') {
                    New-DFToolSourceInfo -Source 'scoop' -PackageId 'main/micro' -Name 'micro' `
                        -Description 'terminal text editor' -LatestVersion '2.0.11' -MatchKind 'exact-name'
                }
            }
        }

        It 'resolves a -Category match through the real search-and-merge path' {
            Mock Test-DFOutputPiped { $true }
            $r = @(Find-DFPackage -Category search)
            $r.Count | Should -Be 1
            $r[0].Name | Should -Be 'ripgrep'
            $r[0].Installed | Should -BeTrue   # proves it went through live scoop search + installed overlay, not a db snapshot
        }

        It 'ORs multiple -Category values' {
            $r = @(Find-DFPackage -Category search,editor)
            @($r.Name) | Sort-Object | Should -Be @('micro', 'ripgrep')
        }

        It 'ANDs -Category and -WorksWith together' {
            $r = @(Find-DFPackage -Category search,editor -WorksWith filesystem)
            $r.Count | Should -Be 1
            $r[0].Name | Should -Be 'ripgrep'
        }

        It '-WorksWith alone ORs within the facet' {
            $r = @(Find-DFPackage -WorksWith text)
            @($r.Name) | Sort-Object | Should -Be @('micro', 'ripgrep')
        }

        It 'fails fast on an unknown facet value, naming it' {
            { Find-DFPackage -Category bogus-value -ErrorAction Stop } | Should -Throw '*bogus-value*'
        }

        It 'rejects -All combined with -Category (parameter-set level)' {
            { Find-DFPackage -Category search -All } | Should -Throw
        }

        It 'honors -Source, falling back to name search when the entry has no id in the allowed source' {
            # ripgrep's only id is scoop; restricting to -Source choco means no
            # id matches the allowed set, so it must fall back to a plain name
            # search scoped to choco (which the fake choco provider answers).
            $r = @(Find-DFPackage -Category search -Source choco)
            $r.Count | Should -Be 1
            $r[0].Sources[0].Source | Should -Be 'choco'
        }

        It 'skips a db entry whose ids resolve to nothing live (stale seed data)' {
            $script:FakeDb.Raw.tools | Add-Member -MemberType NoteProperty -Name 'ghost-tool' -Value ([pscustomobject]@{
                function = @('search'); interface = 'cli'; ids = [pscustomobject]@{ scoop = 'totally-nonexistent-package' }
            })
            $script:FakeDb.FacetIndex['function:search'] = @('ripgrep', 'ghost-tool')
            $r = @(Find-DFPackage -Category search)
            @($r.Name) | Should -Not -Contain 'ghost-tool'
        }

        It 'renders a table, never the detail card, even for a single exact-id facet match' {
            Mock Test-DFOutputPiped { $false }
            $saved = $Env:NO_COLOR; $Env:NO_COLOR = '1'
            try {
                $out = (Find-DFPackage -Category search) -join "`n"
                $out | Should -Match 'Name\s+'
                $out | Should -Not -Match 'Installed\s'
            } finally { $Env:NO_COLOR = $saved }
        }

        It 'reports a friendly message when nothing matches the facet' {
            Mock Test-DFOutputPiped { $false }
            $out = Find-DFPackage -Category editor -WorksWith filesystem
            ($out -join "`n") | Should -Match 'No packages found'
        }
    }
}