tests/Get-DFCommandConflict.Tests.ps1

BeforeAll {
    . "$PSScriptRoot/../Private/Test-DFToolSchema.ps1"
    . "$PSScriptRoot/../Private/Expand-DFXdgPath.ps1"
    . "$PSScriptRoot/../Private/Import-DFToolDb.ps1"
    . "$PSScriptRoot/../Private/Get-DFCoreutilsShadowSet.ps1"
    . "$PSScriptRoot/../Public/Get-DFCommandConflict.ps1"
}

Describe 'Get-DFCommandConflict' {
    BeforeEach {
        $script:DFToolDb = $null
        $script:TmpTools = Join-Path $TestDrive 'tools'
        New-Item -ItemType Directory -Force -Path $script:TmpTools | Out-Null

        # 'cat' mirrors the real bat.json collision; 'ff' is a picker alias.
        @'
{
  "name": "testtool",
  "executable": "testtool.exe",
  "xdg": { "compliance": "none", "method": "default" },
  "aliases": { "cat": { "command": "bat", "args": [] } },
  "picker": { "alias": "ff", "function": "Select-TestTool", "list": "testtool list", "action": "output" }
}
'@
 | Set-Content (Join-Path $script:TmpTools 'testtool.json')

        $script:SavedConfig = $Global:DFConfig
        $Global:DFConfig = $null
    }

    AfterEach {
        $Global:DFConfig = $script:SavedConfig
    }

    It 'returns nothing when no coreutils hook applies to this host' {
        # The hook is injected per-host, so a host that never loads it (e.g. VS Code)
        # must report no conflict even when coreutils is installed machine-wide.
        Mock Get-DFCoreutilsShadowSet { @() }
        Get-DFCommandConflict -ToolsPath $script:TmpTools | Should -BeNullOrEmpty
    }

    It 'reports a tool alias that coreutils shadows' {
        Mock Get-DFCoreutilsShadowSet { [string[]]@('cat', 'touch', 'ls') }
        $c = @(Get-DFCommandConflict -ToolsPath $script:TmpTools)
        $c.Command | Should -Contain 'cat'
    }

    It 'does not report DotForge commands coreutils leaves alone' {
        Mock Get-DFCoreutilsShadowSet { [string[]]@('cat', 'touch', 'ls') }
        $c = @(Get-DFCommandConflict -ToolsPath $script:TmpTools)
        # 'ff' is a DotForge picker alias but is not a coreutils utility.
        $c.Command | Should -Not -Contain 'ff'
        # 'ls' is a coreutils utility but this fixture's tool does not declare it.
        $c.Command | Should -Not -Contain 'ls'
    }

    It 'picks up a newly added tool alias with no code change' {
        # The owned-name set is derived from the tool database, so a future tool that
        # declares a colliding alias is covered automatically.
        @'
{
  "name": "futuretool",
  "executable": "futuretool.exe",
  "xdg": { "compliance": "none", "method": "default" },
  "aliases": { "sort": { "command": "futuretool", "args": ["--sort"] } },
  "picker": null
}
'@
 | Set-Content (Join-Path $script:TmpTools 'futuretool.json')
        $script:DFToolDb = $null
        Mock Get-DFCoreutilsShadowSet { [string[]]@('sort') }
        (@(Get-DFCommandConflict -ToolsPath $script:TmpTools)).Command | Should -Contain 'sort'
    }

    It 'surfaces the exact fix command' {
        Mock Get-DFCoreutilsShadowSet { [string[]]@('cat') }
        $c = @(Get-DFCommandConflict -ToolsPath $script:TmpTools)
        $c[0].Fix | Should -BeLike '*coreutils-manager disable cat*'
        $c[0].ShadowedBy | Should -Be 'coreutils'
        $c[0].DisableWith | Should -Be 'cat'
    }

    It "resolves 'la' to 'ls', which is the utility coreutils-manager accepts" {
        # There is no la.cmd: the installer synthesizes 'la' into the hook only while
        # 'ls' is enabled, and `coreutils-manager disable la` is rejected. Suggesting
        # it would hand the user a command that fails.
        @'
{
  "name": "ezalike",
  "executable": "ezalike.exe",
  "xdg": { "compliance": "none", "method": "default" },
  "aliases": { "la": { "command": "eza", "args": ["--all"] } },
  "picker": null
}
'@
 | Set-Content (Join-Path $script:TmpTools 'ezalike.json')
        $script:DFToolDb = $null
        Mock Get-DFCoreutilsShadowSet { [string[]]@('la', 'ls') }
        $c = @(Get-DFCommandConflict -ToolsPath $script:TmpTools)
        ($c | Where-Object Command -eq 'la').DisableWith | Should -Be 'ls'
        ($c | Where-Object Command -eq 'la').Fix | Should -Not -BeLike '*disable la*'
    }

    It 'suppresses conflicts listed in $DFConfig.IgnoreConflicts' {
        Mock Get-DFCoreutilsShadowSet { [string[]]@('cat') }
        $Global:DFConfig = @{ IgnoreConflicts = @('cat') }
        Get-DFCommandConflict -ToolsPath $script:TmpTools | Should -BeNullOrEmpty
    }

    It 'still reports ignored conflicts with -IncludeIgnored, flagged as Ignored' {
        Mock Get-DFCoreutilsShadowSet { [string[]]@('cat') }
        $Global:DFConfig = @{ IgnoreConflicts = @('cat') }
        $c = @(Get-DFCommandConflict -ToolsPath $script:TmpTools -IncludeIgnored)
        $c.Command | Should -Contain 'cat'
        ($c | Where-Object Command -eq 'cat').Ignored | Should -BeTrue
    }

    It 'reports the module helper aliases from the manifest, not just tool aliases' {
        # touch/env/paste are created at import by Public/*.ps1 with -Scope Global, so
        # the module never owns them and ExportedAliases is empty — AliasesToExport in
        # the manifest is the only maintained list of them.
        Mock Get-DFCoreutilsShadowSet { [string[]]@('touch', 'env', 'paste') }
        $c = @(Get-DFCommandConflict -ToolsPath $script:TmpTools)
        $c.Command | Should -Contain 'touch'
        $c.Command | Should -Contain 'env'
    }
}

Describe 'Get-DFCoreutilsShadowSet' {
    BeforeEach {
        $script:FakeProfile = Join-Path $TestDrive 'Microsoft.PowerShell_profile.ps1'
    }

    AfterEach {
        Remove-Variable -Name '__COREUTILS__' -Scope Global -ErrorAction Ignore
    }

    It 'returns empty when the profile carries no coreutils marker' {
        'Write-Host "an ordinary profile"' | Set-Content $script:FakeProfile
        Get-DFCoreutilsShadowSet -ProfilePath $script:FakeProfile | Should -BeNullOrEmpty
    }

    It 'returns empty when the profile does not exist' {
        Get-DFCoreutilsShadowSet -ProfilePath (Join-Path $TestDrive 'nope.ps1') |
            Should -BeNullOrEmpty
    }

    It 'reads the live set once the hook has loaded' {
        $Global:__COREUTILS__ = [System.Collections.Generic.HashSet[string]]::new(
            [string[]]@('cat', 'ls'), [System.StringComparer]::OrdinalIgnoreCase)
        $s = Get-DFCoreutilsShadowSet -ProfilePath $script:FakeProfile
        $s | Should -Contain 'cat'
        $s | Should -Contain 'ls'
    }

    It 'parses the pending hook from the profile before it has loaded' {
        # Regression: profiles load CurrentUserAllHosts (Register-DFTool -All) BEFORE
        # CurrentUserCurrentHost (the hook), so at check time $__COREUTILS__ does not
        # exist yet. Reading only the variable made this check dead code in a real
        # profile — it must fall back to the file.
        @'
# DO NOT MODIFY -- coreutils -- 60b36fc6-2d59-49df-be51-28dd2f4c3c9a
$script:__COREUTILS__ = [System.Collections.Generic.HashSet[string]]::new(
    [string[]]@('cat','ls','touch'),
    [System.StringComparer]::OrdinalIgnoreCase
)
# DO NOT MODIFY -- coreutils -- 60b36fc6-2d59-49df-be51-28dd2f4c3c9a
'@
 | Set-Content $script:FakeProfile

        Remove-Variable -Name '__COREUTILS__' -Scope Global -ErrorAction Ignore
        $s = Get-DFCoreutilsShadowSet -ProfilePath $script:FakeProfile
        $s | Should -Contain 'cat'
        $s | Should -Contain 'ls'
        $s | Should -Contain 'touch'
    }

    It 'prefers the loaded variable over the profile text' {
        # After `coreutils-manager disable ls` the registry and profile are rewritten,
        # but a session that already loaded the old hook keeps the old set. The live
        # variable is what the hook actually consults, so it wins.
        @'
# DO NOT MODIFY -- coreutils -- 60b36fc6-2d59-49df-be51-28dd2f4c3c9a
$script:__COREUTILS__ = [System.Collections.Generic.HashSet[string]]::new(
    [string[]]@('cat','ls','touch'),
    [System.StringComparer]::OrdinalIgnoreCase
)
'@
 | Set-Content $script:FakeProfile
        $Global:__COREUTILS__ = [string[]]@('cat')

        $s = Get-DFCoreutilsShadowSet -ProfilePath $script:FakeProfile
        $s | Should -Contain 'cat'
        $s | Should -Not -Contain 'ls'
    }
}