tests/Get-DFCachedCommandOutput.Tests.ps1

BeforeAll {
    . "$PSScriptRoot/TestSupport.ps1"
    foreach ($f in Get-DFTestModuleFile) { . $f }
}

Describe 'Get-DFCachedCommandOutput' {
    BeforeEach {
        Set-DFTestXdg

        Remove-Item $Env:XDG_CACHE_HOME -Recurse -Force -ErrorAction Ignore

        # A real file stands in for the resolved executable so LastWriteTimeUtc
        # is real data, not a mocked property on a fake path.
        $script:FakeExe = Join-Path $TestDrive 'fake-tool.exe'
        Set-Content -Path $script:FakeExe -Value 'binary-stand-in' -Encoding UTF8
        Mock Get-Command { [PSCustomObject]@{ Source = $script:FakeExe } }
    }
    AfterEach {
        Restore-DFTestXdg
    }

    It 'calls -Generate and caches the result on a cold cache' {
        $script:calls = 0
        $result = Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'test-tool' -Generate {
            $script:calls++
            'generated-output'
        }
        $result | Should -Be 'generated-output'
        $script:calls | Should -Be 1
        Test-Path (Join-Path $Env:XDG_CACHE_HOME 'dotforge' 'test-tool.txt') | Should -BeTrue
    }

    It 'writes atomically, the content before the key that vouches for it' {
        # Two shells starting at once must never read a half-written init script,
        # and a crash between the writes must leave a key that does NOT match.
        $script:writes = [System.Collections.Generic.List[string]]::new()
        Mock Write-DFFileAtomic { $script:writes.Add((Split-Path $Path -Leaf)) }
        Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'test-tool' -Generate { 'generated-output' } | Out-Null
        $script:writes | Should -Be @('test-tool.txt', 'test-tool.key')
    }

    It 'reuses the cache on a second call -- does not re-invoke -Generate' {
        Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'test-tool' -Generate { 'first' } | Out-Null

        $result = Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'test-tool' -Generate {
            throw '-Generate should not run on a cache hit'
        }
        $result | Should -Be 'first'
    }

    It 'regenerates when the executable file changes (LastWriteTimeUtc differs)' {
        Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'test-tool' -Generate { 'stale' } | Out-Null

        Start-Sleep -Milliseconds 50
        Set-Content -Path $script:FakeExe -Value 'a rebuilt binary' -Encoding UTF8

        $result = Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'test-tool' -Generate { 'fresh' }
        $result | Should -Be 'fresh'
    }

    It 'regenerates when the resolved path changes even if the old file is untouched' {
        Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'test-tool' -Generate { 'from-old-path' } | Out-Null

        $newExe = Join-Path $TestDrive 'different-tool.exe'
        Set-Content -Path $newExe -Value 'binary-stand-in' -Encoding UTF8
        Mock Get-Command { [PSCustomObject]@{ Source = $newExe } }

        $result = Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'test-tool' -Generate { 'from-new-path' }
        $result | Should -Be 'from-new-path'
    }

    It 'regenerates when -ExtraKey changes, even though the executable is untouched' {
        Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'test-tool' -ExtraKey 'specs:a' -Generate { 'old' } | Out-Null

        Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'test-tool' -ExtraKey 'specs:a' -Generate {
            throw '-Generate should not run while -ExtraKey is unchanged'
        } | Should -Be 'old'

        Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'test-tool' -ExtraKey 'specs:a,b' -Generate { 'new' } |
            Should -Be 'new'
    }

    It 'bypasses a valid cache when -Force is passed' {
        Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'test-tool' -Generate { 'first' } | Out-Null

        $result = Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'test-tool' -Force -Generate { 'forced' }
        $result | Should -Be 'forced'
    }

    It 'never caches an empty result, and retries -Generate next call' {
        Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'test-tool' -Generate { '' } | Out-Null
        Test-Path (Join-Path $Env:XDG_CACHE_HOME 'dotforge' 'test-tool.txt') | Should -BeFalse

        $script:calls = 0
        Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'test-tool' -Generate { $script:calls++; 'now-real' } | Out-Null
        $script:calls | Should -Be 1
    }

    It 'falls back to always calling -Generate, uncached, when the resolved command has no backing file (e.g. a function stand-in)' {
        # Regression: tests/scoop.Tests.ps1 stubs scoop-search as a PowerShell
        # function, not a real binary. Get-Command on a function does not
        # populate .Source with a usable file path -- Get-Item on it must not
        # throw, it must fall back to uncached generation.
        Mock Get-Command { [PSCustomObject]@{ Source = '' } }
        $script:calls = 0
        Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'stub-function' -Generate { $script:calls++; 'x' } | Out-Null
        Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'stub-function' -Generate { $script:calls++; 'x' } | Out-Null
        $script:calls | Should -Be 2
    }

    It 'falls back to always calling -Generate, uncached, when the executable does not resolve' {
        Mock Get-Command { $null }
        $script:calls = 0
        Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'missing-tool' -Generate { $script:calls++; 'x' } | Out-Null
        Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'missing-tool' -Generate { $script:calls++; 'x' } | Out-Null
        $script:calls | Should -Be 2
    }

    Context 'scoop shims' {
        BeforeEach {
            # Scoop never rewrites shims\<name>.exe on upgrade; the sibling
            # <name>.shim names the real binary, whose file identity changes.
            $script:ShimTarget = Join-Path $TestDrive 'apps' 'real-tool.exe'
            New-Item -ItemType Directory -Path (Split-Path $script:ShimTarget) -Force | Out-Null
            Set-Content -Path $script:ShimTarget -Value 'real-binary-v1' -Encoding UTF8
            Set-Content -Path ([IO.Path]::ChangeExtension($script:FakeExe, '.shim')) `
                -Value "path = `"$script:ShimTarget`"" -Encoding UTF8
        }
        AfterEach {
            Remove-Item ([IO.Path]::ChangeExtension($script:FakeExe, '.shim')) -ErrorAction Ignore
        }

        It 'regenerates when the shim target is upgraded, even though the shim itself is untouched' {
            Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'test-tool' -Generate { 'v1-init' } | Out-Null

            Start-Sleep -Milliseconds 50
            Set-Content -Path $script:ShimTarget -Value 'real-binary-v2' -Encoding UTF8

            Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'test-tool' -Generate { 'v2-init' } |
                Should -Be 'v2-init'
        }

        It 'fingerprints the shim target path, not the shim' {
            Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'test-tool' -Generate { 'x' } | Out-Null
            (Get-Content (Join-Path $Env:XDG_CACHE_HOME 'dotforge' 'test-tool.key') -Raw) |
                Should -BeLike "$script:ShimTarget|*"
        }

        It 'falls back to fingerprinting the shim when the .shim file is malformed' {
            Set-Content -Path ([IO.Path]::ChangeExtension($script:FakeExe, '.shim')) -Value 'garbage' -Encoding UTF8
            { Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'test-tool' -Generate { 'x' } } | Should -Not -Throw
            (Get-Content (Join-Path $Env:XDG_CACHE_HOME 'dotforge' 'test-tool.key') -Raw) |
                Should -BeLike "$script:FakeExe|*"
        }

        It 'falls back to fingerprinting the shim when the shim target is missing' {
            Remove-Item $script:ShimTarget
            { Get-DFCachedCommandOutput -Name 'test-tool' -Executable 'test-tool' -Generate { 'x' } } | Should -Not -Throw
            (Get-Content (Join-Path $Env:XDG_CACHE_HOME 'dotforge' 'test-tool.key') -Raw) |
                Should -BeLike "$script:FakeExe|*"
        }
    }

    It 'keeps separate cache entries for different -Name values' {
        Get-DFCachedCommandOutput -Name 'tool-a' -Executable 'test-tool' -Generate { 'output-a' } | Out-Null
        Get-DFCachedCommandOutput -Name 'tool-b' -Executable 'test-tool' -Generate { 'output-b' } | Out-Null

        Get-DFCachedCommandOutput -Name 'tool-a' -Executable 'test-tool' -Generate { throw 'should not run' } | Should -Be 'output-a'
        Get-DFCachedCommandOutput -Name 'tool-b' -Executable 'test-tool' -Generate { throw 'should not run' } | Should -Be 'output-b'
    }
}