Private/Config/Assert-NAVGitConfig.ps1

function Assert-NAVGitConfig {
    <#
    .SYNOPSIS
        Validates a merged navgit.json configuration. All problems are collected and thrown as one error.
        Unknown keys in the repository file (RepoConfig) produce warnings, not errors.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable] $Config,

        [hashtable] $RepoConfig = @{},

        [string] $ConfigPath = 'navgit.json'
    )

    $errors = New-Object System.Collections.Generic.List[string]

    function Test-String([string] $Name, $Value, [switch] $Required, [string] $Pattern, [string] $PatternHint) {
        if ($null -eq $Value -or ($Value -is [string] -and $Value -eq '')) {
            if ($Required) { $errors.Add("'$Name' is required.") }
            return
        }
        if ($Value -isnot [string]) {
            $errors.Add("'$Name' must be a string.")
            return
        }
        if ($Pattern -and $Value -notmatch $Pattern) {
            $errors.Add("'$Name' ('$Value') $PatternHint")
        }
    }

    function Test-Enum([string] $Name, $Value, [string[]] $Allowed) {
        if ($null -eq $Value) { return }
        if ($Value -isnot [string] -or $Value -notin $Allowed) {
            $shown = ($Allowed | ForEach-Object { "'$_'" }) -join ', '
            $errors.Add("'$Name' must be one of $shown (was '$Value').")
        }
    }

    function Test-Bool([string] $Name, $Value) {
        if ($null -ne $Value -and $Value -isnot [bool]) {
            $errors.Add("'$Name' must be true or false (was '$Value').")
        }
    }

    function Test-StringArray([string] $Name, $Value) {
        if ($null -eq $Value) { return }
        if ($Value -is [string] -or $Value -isnot [System.Collections.IEnumerable] -or $Value -is [hashtable]) {
            $errors.Add("'$Name' must be an array of strings.")
            return
        }
        foreach ($item in $Value) {
            if ($item -isnot [string]) {
                $errors.Add("'$Name' must only contain strings.")
                return
            }
        }
    }

    function Test-Object([string] $Name, $Value) {
        if ($null -ne $Value -and $Value -isnot [hashtable]) {
            $errors.Add("'$Name' must be an object.")
            return $false
        }
        return ($null -ne $Value)
    }

    # Unknown keys (typos) -> warnings
    $knownKeys = Get-NAVGitKnownConfigKeys
    foreach ($key in $RepoConfig.Keys) {
        if ($key -eq '$schema') { continue }
        if (-not $knownKeys.ContainsKey($key)) {
            Write-Warning "${ConfigPath}: unknown setting '$key' is ignored."
            continue
        }
        $subKeys = $knownKeys[$key]
        if ($subKeys -and $RepoConfig[$key] -is [hashtable]) {
            foreach ($subKey in $RepoConfig[$key].Keys) {
                if ($subKeys -notcontains $subKey) {
                    Write-Warning "${ConfigPath}: unknown setting '$key.$subKey' is ignored."
                }
            }
        }
    }

    # Identity
    Test-String 'projectName' $Config.projectName -Required
    Test-String 'containerName' $Config.containerName -Required -Pattern '^[A-Za-z0-9][A-Za-z0-9-]{0,14}$' `
        -PatternHint 'must be 1-15 characters (letters, digits, -) and start with a letter or digit.'
    Test-String 'navRelease' $Config.navRelease

    # Artifact
    if ($null -eq $Config.artifact) {
        $errors.Add("'artifact' is required: use { `"url`": ... }, { `"nav`": `"2018`", `"cu`": ... } or { `"version`": `"14.0`" }.")
    }
    elseif (Test-Object 'artifact' $Config.artifact) {
        $artifact = $Config.artifact
        $forms = @(@('url', 'nav', 'version') | Where-Object { $artifact.ContainsKey($_) })
        if ($forms.Count -ne 1) {
            $errors.Add("'artifact' must contain exactly one of 'url', 'nav' or 'version' (found: $(if ($forms.Count) { $forms -join ', ' } else { 'none' })).")
        }
        elseif ($forms[0] -eq 'url') {
            Test-String 'artifact.url' $artifact.url -Required -Pattern '^https://' -PatternHint 'must be an https:// URL.'
            foreach ($extra in @('cu', 'country', 'type', 'select')) {
                if ($artifact.ContainsKey($extra)) { $errors.Add("'artifact.$extra' cannot be combined with 'artifact.url' (the URL already identifies the artifact).") }
            }
        }
        elseif ($forms[0] -eq 'nav') {
            Test-Enum 'artifact.nav' $artifact.nav @('2016', '2017', '2018')
            Test-String 'artifact.cu' $artifact.cu -Pattern '^(rtm|cu\d+)$' -PatternHint "must be 'rtm' or 'cu<number>', e.g. 'cu30'."
            Test-String 'artifact.country' $artifact.country
            foreach ($extra in @('type', 'select')) {
                if ($artifact.ContainsKey($extra)) { $errors.Add("'artifact.$extra' can only be used with 'artifact.version'.") }
            }
        }
        else {
            Test-String 'artifact.version' $artifact.version -Required -Pattern '^\d+(\.\d+){0,3}$' -PatternHint 'must be a version prefix such as 14, 14.0 or 14.0.29537.0.'
            if ($artifact.version -is [string] -and $artifact.version -match '^(\d+)') {
                $major = [int]$Matches[1]
                if ($major -lt 9 -or $major -gt 14) {
                    $errors.Add("'artifact.version' ('$($artifact.version)') must be between 9 (NAV 2016) and 14 (BC14). There are no artifacts before NAV 2016, and C/AL ends with BC14.")
                }
            }
            Test-Enum 'artifact.type' $artifact.type @('OnPrem')
            Test-Enum 'artifact.select' $artifact.select @('Latest', 'First', 'Closest')
            if ($artifact.select -eq 'Closest' -and ($artifact.version -isnot [string] -or $artifact.version -notmatch '^\d+\.\d+\.\d+\.\d+$')) {
                $errors.Add("'artifact.select' = 'Closest' requires a full four-part 'artifact.version'.")
            }
            Test-String 'artifact.country' $artifact.country
            if ($artifact.ContainsKey('cu')) { $errors.Add("'artifact.cu' can only be used with 'artifact.nav'.") }
        }
    }

    Test-String 'imageName' $Config.imageName -Required -Pattern '^[a-z0-9][a-z0-9._-]*$' -PatternHint 'must be lowercase letters, digits, ., _ or -.'

    # Database / license
    if (Test-Object 'database' $Config.database) {
        Test-String 'database.bakFile' $Config.database.bakFile
        Test-Bool 'database.useDemo' $Config.database.useDemo
        Test-StringArray 'database.searchNames' $Config.database.searchNames
        if ($Config.database.useDemo -eq $true -and $Config.database.bakFile) {
            $errors.Add("'database.bakFile' and 'database.useDemo' cannot both be set.")
        }
    }
    Test-String 'licenseFile' $Config.licenseFile

    # Container options
    Test-Enum 'auth' $Config.auth @('Windows', 'NavUserPassword')
    Test-Enum 'isolation' $Config.isolation @('', 'hyperv', 'process')
    Test-String 'memoryLimit' $Config.memoryLimit -Pattern '^\d+(\.\d+)?[MmGg]$' -PatternHint "must look like '8G' or '4096M'."
    Test-String 'dns' $Config.dns
    Test-Bool 'updateHosts' $Config.updateHosts
    Test-Enum 'shortcuts' $Config.shortcuts (Get-NAVGitShortcutsValues)
    Test-Bool 'enableSymbolLoading' $Config.enableSymbolLoading
    Test-Bool 'includeTestToolkit' $Config.includeTestToolkit
    if (Test-Object 'testToolkit' $Config.testToolkit) {
        Test-String 'testToolkit.country' $Config.testToolkit.country
        Test-Bool 'testToolkit.librariesOnly' $Config.testToolkit.librariesOnly
    }
    Test-StringArray 'myScripts' $Config.myScripts
    Test-StringArray 'additionalParameters' $Config.additionalParameters

    # Objects
    if (Test-Object 'paths' $Config.paths) {
        Test-String 'paths.objects' $Config.paths.objects -Required
        Test-String 'paths.tests' $Config.paths.tests -Required
        Test-String 'paths.addIns' $Config.paths.addIns
        foreach ($name in @('objects', 'tests', 'addIns')) {
            $value = $Config.paths[$name]
            if ($value -is [string] -and $value -and [System.IO.Path]::IsPathRooted($value)) {
                $errors.Add("'paths.$name' must be relative to the repository (was '$value').")
            }
        }
        if ($Config.paths.objects -is [string] -and $Config.paths.tests -is [string] -and
            $Config.paths.objects.Trim('\', '/') -eq $Config.paths.tests.Trim('\', '/')) {
            $errors.Add("'paths.objects' and 'paths.tests' must be different folders.")
        }
    }
    if (Test-Object 'testObjects' $Config.testObjects) {
        Test-String 'testObjects.versionListPattern' $Config.testObjects.versionListPattern -Required
        if ($Config.testObjects.versionListPattern -is [string] -and $Config.testObjects.versionListPattern) {
            try { [void][regex]::new($Config.testObjects.versionListPattern) }
            catch { $errors.Add("'testObjects.versionListPattern' is not a valid regular expression: $($_.Exception.InnerException.Message)") }
        }
        Test-Bool 'testObjects.includeTestCodeunits' $Config.testObjects.includeTestCodeunits
    }
    if (Test-Object 'import' $Config.import) {
        Test-Enum 'import.deleteMissing' $Config.import.deleteMissing @('skip', 'warn', 'mark')
        Test-Enum 'import.syncSchema' $Config.import.syncSchema @('Force', 'Yes', 'No')
        Test-String 'import.compileFilter' $Config.import.compileFilter -Required
        Test-String 'import.dateFormat' $Config.import.dateFormat
    }
    if (Test-Object 'export' $Config.export) {
        Test-String 'export.filter' $Config.export.filter
        Test-Enum 'export.format' $Config.export.format @('txt folder')
    }
    if (Test-Object 'client' $Config.client) {
        Test-String 'client.defaultProfile' $Config.client.defaultProfile
    }

    # Output
    Test-Bool 'showBcContainerCommand' $Config.showBcContainerCommand

    if ($errors.Count -gt 0) {
        $list = ($errors | ForEach-Object { " - $_" }) -join [Environment]::NewLine
        throw "Invalid NAVGit configuration in '$ConfigPath':$([Environment]::NewLine)$list"
    }
}