tests/Test-DFToolSchema.Tests.ps1

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

Describe 'Test-DFToolSchema' {
    Context 'valid records' {
        It 'passes a minimal valid tool record' {
            $tool = [PSCustomObject]@{
                name       = 'mytool'
                executable = 'mytool.exe'
            }
            $errors = @()
            Test-DFToolSchema -Tool $tool -Errors ([ref]$errors) | Should -BeTrue
            $errors | Should -BeNullOrEmpty
        }

        It 'passes a tool with type = "exe"' {
            $tool = [PSCustomObject]@{ name = 't'; executable = 't.exe'; type = 'exe' }
            Test-DFToolSchema -Tool $tool -Errors ([ref]$null) | Should -BeTrue
        }

        It 'passes a tool with type = "module"' {
            $tool = [PSCustomObject]@{ name = 't'; executable = 't'; type = 'module' }
            Test-DFToolSchema -Tool $tool -Errors ([ref]$null) | Should -BeTrue
        }

        It 'passes a fully populated valid record' {
            $tool = [PSCustomObject]@{
                name        = 'bat'
                executable  = 'bat.exe'
                description = 'Modern cat'
                tags        = @('viewer')
                packages    = [PSCustomObject]@{ scoop = 'bat' }
                xdg         = [PSCustomObject]@{
                    compliance = 'partial'
                    method     = 'env'
                    vars       = [PSCustomObject]@{ BAT_CONFIG_PATH = '${XDG_CONFIG_HOME}/bat/bat.conf' }
                    dirs       = @()
                }
                aliases = [PSCustomObject]@{}
                picker  = $null
            }
            Test-DFToolSchema -Tool $tool -Errors ([ref]$null) | Should -BeTrue
        }
    }

    Context 'invalid records' {
        It 'fails when name is missing' {
            $tool = [PSCustomObject]@{ executable = 'tool.exe' }
            $errors = @()
            Test-DFToolSchema -Tool $tool -Errors ([ref]$errors) | Should -BeFalse
            $errors | Where-Object { $_ -match 'name' } | Should -Not -BeNullOrEmpty
        }

        It 'fails when executable is missing' {
            $tool = [PSCustomObject]@{ name = 'mytool' }
            $errors = @()
            Test-DFToolSchema -Tool $tool -Errors ([ref]$errors) | Should -BeFalse
            $errors | Where-Object { $_ -match 'executable' } | Should -Not -BeNullOrEmpty
        }

        It 'fails when xdg.method is not a valid value' {
            $tool = [PSCustomObject]@{
                name       = 'mytool'
                executable = 'mytool.exe'
                xdg        = [PSCustomObject]@{ method = 'invalid' }
            }
            $errors = @()
            Test-DFToolSchema -Tool $tool -Errors ([ref]$errors) | Should -BeFalse
            $errors | Where-Object { $_ -match 'xdg.method' } | Should -Not -BeNullOrEmpty
        }

        It 'fails when type is not a valid value' {
            $tool = [PSCustomObject]@{ name = 't'; executable = 't.exe'; type = 'binary' }
            $errors = @()
            Test-DFToolSchema -Tool $tool -Errors ([ref]$errors) | Should -BeFalse
            $errors | Where-Object { $_ -match 'type' } | Should -Not -BeNullOrEmpty
        }

        It 'rejects the old scoopBucket, pointing at a feed in packages' {
            $t = '{ "name": "t", "executable": "t.exe", "scoopBucket": { "name": "x", "url": "u" } }' | ConvertFrom-Json
            $errs = @()
            Test-DFToolSchema -Tool $t -Errors ([ref]$errs) | Should -BeFalse
            "$errs" | Should -Match 'scoopBucket was replaced'
        }

        It 'accepts a packages value that is an id or { id, feed }, and rejects a feed without a url' {
            foreach ($ok in '"g"', '{ "id": "g", "feed": { "name": "b", "url": "https://u" } }', '{ "id": "g" }') {
                $errs = @()
                Test-DFToolSchema -Tool ("{ ""name"": ""t"", ""executable"": ""t.exe"", ""packages"": { ""scoop"": $ok } }" | ConvertFrom-Json) -Errors ([ref]$errs) | Should -BeTrue -Because $ok
            }
            $errs = @()
            Test-DFToolSchema -Tool ('{ "name": "t", "executable": "t.exe", "packages": { "scoop": { "id": "g", "feed": { "name": "b" } } } }' | ConvertFrom-Json) -Errors ([ref]$errs) | Should -BeFalse
            "$errs" | Should -Match 'packages.scoop'
        }

        It 'accepts a one-element executableExclude array' {
            $t = '{ "name": "t", "executable": "t.exe", "executableExclude": ["*\\Git\\usr\\bin\\*"] }' | ConvertFrom-Json
            $errs = @()
            Test-DFToolSchema -Tool $t -Errors ([ref]$errs) | Should -BeTrue -Because "$errs"
        }

        It 'rejects an executableExclude that is not an array of strings' {
            $t = '{ "name": "t", "executable": "t.exe", "executableExclude": "x" }' | ConvertFrom-Json
            $errs = @()
            Test-DFToolSchema -Tool $t -Errors ([ref]$errs) | Should -BeFalse
            "$errs" | Should -Match 'executableExclude'
        }

        It 'rejects roles that is not an object' {
            $t = '{ "name": "t", "executable": "t.exe", "roles": ["listing"] }' | ConvertFrom-Json
            $errs = @()
            Test-DFToolSchema -Tool $t -Errors ([ref]$errs) | Should -BeFalse
            "$errs" | Should -Match 'roles'
        }

        It 'rejects a non-integer role priority' {
            $t = '{ "name": "t", "executable": "t.exe", "roles": { "pager": { "priority": "high" } } }' | ConvertFrom-Json
            $errs = @()
            Test-DFToolSchema -Tool $t -Errors ([ref]$errs) | Should -BeFalse
            "$errs" | Should -Match 'priority'
        }

        It 'accepts an integer role priority and an empty role block' {
            $t = '{ "name": "t", "executable": "t.exe", "roles": { "pager": { "priority": 10 }, "grep": {} } }' | ConvertFrom-Json
            $errs = @()
            Test-DFToolSchema -Tool $t -Errors ([ref]$errs) | Should -BeTrue
        }

        It 'accepts a boolean role optIn and rejects another type' {
            $valid = '{ "name": "t", "executable": "t.exe", "roles": { "optional": { "optIn": true } } }' | ConvertFrom-Json
            $invalid = '{ "name": "t", "executable": "t.exe", "roles": { "optional": { "optIn": "yes" } } }' | ConvertFrom-Json
            $errors = @()
            Test-DFToolSchema -Tool $valid -Errors ([ref]$errors) | Should -BeTrue -Because "$errors"
            $errors = @()
            Test-DFToolSchema -Tool $invalid -Errors ([ref]$errors) | Should -BeFalse
            "$errors" | Should -Match 'optIn'
        }
    }

    Context 'field shapes' {
        BeforeAll {
            function script:Get-SchemaErrors($Extra) {
                $tool = [pscustomobject]@{ name = 't'; executable = 't.exe' }
                foreach ($k in $Extra.Keys) { $tool | Add-Member -NotePropertyName $k -NotePropertyValue $Extra[$k] }
                $errs = @()
                $null = Test-DFToolSchema -Tool $tool -Errors ([ref]$errs)
                "$errs"
            }
            $script:Picker = { param([hashtable]$p) [pscustomobject](@{ function = 'Select-T'; list = 't list' } + $p) }
        }

        It 'accepts picker null, "custom", and a complete object' {
            Get-SchemaErrors @{ picker = $null } | Should -BeNullOrEmpty
            Get-SchemaErrors @{ picker = 'custom' } | Should -BeNullOrEmpty
            Get-SchemaErrors @{ picker = (& $script:Picker @{ action = 'Set-Location {}'; parse = '$_.Split("\t")[0]'; ansi = $true }) } |
                Should -BeNullOrEmpty
        }
        It 'rejects any other picker string, and an object without function and list' {
            Get-SchemaErrors @{ picker = 'yes' } | Should -Match 'picker'
            Get-SchemaErrors @{ picker = [pscustomobject]@{ function = 'Select-T' } } | Should -Match 'picker.list'
            Get-SchemaErrors @{ picker = [pscustomobject]@{ list = 't list' } } | Should -Match 'picker.function'
        }
        It 'rejects picker booleans written as strings' {
            Get-SchemaErrors @{ picker = (& $script:Picker @{ ansi = 'false' }) } | Should -Match 'picker.ansi'
            Get-SchemaErrors @{ picker = (& $script:Picker @{ list_accepts_path = 'true' }) } | Should -Match 'picker.list_accepts_path'
        }
        It 'rejects a picker action or parse that is not valid PowerShell (instead of failing at profile load)' {
            Get-SchemaErrors @{ picker = (& $script:Picker @{ action = 'Set-Location {} )' }) } | Should -Match 'picker.action'
            Get-SchemaErrors @{ picker = (& $script:Picker @{ parse = '$_.Split(' }) } | Should -Match 'picker.parse'
        }
        It 'rejects an alias without a string command, or with non-string args' {
            Get-SchemaErrors @{ aliases = [pscustomobject]@{ ok = [pscustomobject]@{ command = 'x'; args = @('-a') } } } | Should -BeNullOrEmpty
            Get-SchemaErrors @{ aliases = [pscustomobject]@{ bad = [pscustomobject]@{ args = @('-a') } } } | Should -Match 'aliases.bad'
            Get-SchemaErrors @{ aliases = [pscustomobject]@{ bad = 'x' } } | Should -Match 'aliases.bad'
            Get-SchemaErrors @{ aliases = [pscustomobject]@{ bad = [pscustomobject]@{ command = 'x'; args = @(1, [pscustomobject]@{}) } } } |
                Should -Match 'aliases.bad'
        }
        It 'checks role-block aliases and env with the same rules' {
            $roles = [pscustomobject]@{ listing = [pscustomobject]@{ aliases = [pscustomobject]@{ ls = [pscustomobject]@{ args = @() } } } }
            Get-SchemaErrors @{ roles = $roles } | Should -Match 'roles.listing.aliases.ls'
            $roles = [pscustomobject]@{ pager = [pscustomobject]@{ env = [pscustomobject]@{ PAGER = [pscustomobject]@{} } } }
            Get-SchemaErrors @{ roles = $roles } | Should -Match 'roles.pager.env.PAGER'
        }
        It 'requires env and themeMap to be objects of plain values' {
            Get-SchemaErrors @{ env = [pscustomobject]@{ LESS = '-R'; N = 3 } } | Should -BeNullOrEmpty
            Get-SchemaErrors @{ env = 'LESS=-R' } | Should -Match '\benv\b'
            Get-SchemaErrors @{ env = [pscustomobject]@{ LESS = @('-R') } } | Should -Match 'env.LESS'
            Get-SchemaErrors @{ themeMap = [pscustomobject]@{ 'catppuccin-mocha' = 'catppuccin' } } | Should -BeNullOrEmpty
            Get-SchemaErrors @{ themeMap = [pscustomobject]@{ 'catppuccin-mocha' = 1 } } | Should -Match 'themeMap'
        }
        It 'rejects the old dependsOn, naming after and requires' {
            Get-SchemaErrors @{ dependsOn = @('fnm') } | Should -Match 'dependsOn was replaced: use after .* or requires'
        }
        It 'rejects xdg.method config, pointing at setup.seed' {
            Get-SchemaErrors @{ xdg = [pscustomobject]@{ method = 'config' } } | Should -Match 'setup\.seed'
        }
        It 'requires setup.seed to map destinations to file names' {
            Get-SchemaErrors @{ setup = [pscustomobject]@{ seed = [pscustomobject]@{ '${XDG_CONFIG_HOME}/t/c' = 't/c' } } } | Should -BeNullOrEmpty
            Get-SchemaErrors @{ setup = [pscustomobject]@{ seed = [pscustomobject]@{ '${XDG_CONFIG_HOME}/t/c' = 3 } } } | Should -Match 'setup\.seed'
            Get-SchemaErrors @{ setup = 'x' } | Should -Match 'setup must be an object'
        }
        It 'requires after to be an array of strings, and prewarm a boolean' {
            Get-SchemaErrors @{ after = @('fnm') } | Should -BeNullOrEmpty
            Get-SchemaErrors @{ after = 'fnm' } | Should -Match 'after'
            Get-SchemaErrors @{ after = @('fnm', 3) } | Should -Match 'after'
            Get-SchemaErrors @{ prewarm = $false } | Should -BeNullOrEmpty
            Get-SchemaErrors @{ prewarm = 'false' } | Should -Match 'prewarm'
        }
    }

    Context 'typo warnings' {
        BeforeAll {
            function script:Get-SchemaWarnings([pscustomobject]$Tool) {
                $errs = @(); $warns = @()
                $null = Test-DFToolSchema -Tool $Tool -Errors ([ref]$errs) -Warnings ([ref]$warns)
                $warns
            }
        }

        It 'warns about a field that looks like a misspelled known field, naming the suggestion' {
            $w = Get-SchemaWarnings ([pscustomobject]@{ name = 't'; executable = 't.exe'; requirs = @('x'); themMap = [pscustomobject]@{} })
            "$w" | Should -Match "requirs.*requires"
            "$w" | Should -Match "themMap.*themeMap"
        }
        It 'checks inside picker, xdg and role blocks too' {
            $tool = [pscustomobject]@{
                name = 't'; executable = 't.exe'
                picker = [pscustomobject]@{ function = 'Select-T'; list = 'l'; preview_windows = 'up' }
                xdg = [pscustomobject]@{ method = 'env'; varz = [pscustomobject]@{} }
                roles = [pscustomobject]@{ listing = [pscustomobject]@{ priorty = 5 } }
            }
            $w = "$(Get-SchemaWarnings $tool)"
            $w | Should -Match 'preview_windows.*preview_window'
            $w | Should -Match 'varz.*vars'
            $w | Should -Match 'priorty.*priority'
        }
        It 'does not warn about extra fields that resemble nothing known (tool authors may carry data)' {
            Get-SchemaWarnings ([pscustomobject]@{ name = 't'; executable = 't.exe'; homepage = 'https://x'; notes = 'n' }) |
                Should -BeNullOrEmpty
        }
        It 'keeps the tool valid when it only has warnings' {
            $errs = @(); $warns = @()
            Test-DFToolSchema -Tool ([pscustomobject]@{ name = 't'; executable = 't.exe'; requirs = @() }) -Errors ([ref]$errs) -Warnings ([ref]$warns) |
                Should -BeTrue
        }
    }
}

Describe 'Import-DFToolDb schema warnings' {
    It 'loads a tool with a typo warning, and reports the warning with the file name' {
        $dir = Join-Path $TestDrive 'tools-typo'
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
        '{ "name": "typotool", "executable": "typo.exe", "requirs": [] }' | Set-Content (Join-Path $dir 'typotool.json')
        $db = Import-DFToolDb -ToolsPath $dir -WarningVariable w 3>$null
        $db.ContainsKey('typotool') | Should -BeTrue
        "$w" | Should -Match 'typotool\.json.*requirs.*requires'
    }
}

Describe 'Seed tool JSON files' {
    BeforeAll {

    }

    $seedFiles = @(
        'bat', 'eza', 'fzf', 'ripgrep', 'zoxide',
        'fd', 'broot', 'jq', 'glow', 'procs', 'fastfetch',
        'curl', 'wget', 'docker', 'less', 'gh', 'delta',
        'lazygit', 'rustup', 'uv', 'chezmoi', 'micro',
        'bitwarden', 'npm', 'fnm', 'scoop', 'winget',
        'posh-git', 'psreadline', 'PSFzf', 'Terminal-Icons', 'oh-my-posh',
        'gsudo', 'mdcat', 'lsd'
    ) | ForEach-Object {
        @{ Name = $_; Path = Join-Path $PSScriptRoot "../Tools/$_.json" }
    }

    It 'seed file <Name>.json exists' -ForEach $seedFiles {
        Test-Path $Path | Should -BeTrue -Because "$Name.json must exist in Tools/"
    }
}

Describe 'Shipped tool JSON files' {
    # Every shipped tool, discovered rather than listed, so a new tool is covered automatically.
    $shipped = Get-ChildItem (Join-Path $PSScriptRoot '../Tools') -Filter '*.json' |
        ForEach-Object { @{ Name = $_.BaseName; Path = $_.FullName } }

    It '<Name>.json passes schema validation with no errors and no warnings' -ForEach $shipped {
        $tool = Get-Content $Path -Raw | ConvertFrom-Json
        $errors = @(); $warnings = @()
        Test-DFToolSchema -Tool $tool -Errors ([ref]$errors) -Warnings ([ref]$warnings) |
            Should -BeTrue -Because ($errors -join '; ')
        $warnings | Should -BeNullOrEmpty
    }
}