Modules/businessdev.ALbuild.Feeds/Classes/BcFeedModels.ps1

# Typed package model for the dependency resolver: BcPackageDependency and BcPackageManifest.
# Replaces the loose hashtable/PSCustomObject "candidate" shape that used to be rebuilt in the feed
# provider, the solver and the tests.
#
# File name matters: the module loader dot-sources Classes/*.ps1 in filename order, and PowerShell
# resolves class type references (including in method signatures/bodies) at parse time. These model
# types are referenced by BcNuGetFeedProvider, so this file must load first - 'BcFeedModels' sorts
# before 'BcNuGetFeedProvider'. The two classes also live together here so BcPackageManifest's
# parse-time reference to BcPackageDependency (its Dependencies property) resolves.
#
# The From() factories accept anything that exposes the right fields (hashtable, PSCustomObject or
# an instance) so feed providers and in-memory test fixtures can keep producing plain records that
# are normalised into these types at the boundary.

class BcPackageDependency {
    [string] $Id
    [string] $Name
    [string] $Publisher
    [string] $MinVersion
    # The exact NuGet package id this dependency was read from (e.g. read out of a parent package's
    # nuspec). When known it is the authoritative way to locate the package on a feed; reconstructing
    # it from publisher/name via the feed's id scheme is only a fallback (and loses the friendly,
    # space-bearing publisher/name a nuspec id has already normalised away).
    [string] $PackageId

    BcPackageDependency() { }

    BcPackageDependency([string] $id, [string] $name, [string] $publisher, [string] $minVersion) {
        $this.Id = $id
        $this.Name = $name
        $this.Publisher = $publisher
        $this.MinVersion = $minVersion
    }

    # The empty GUID denotes the BC Platform/Application itself - provided by the target build.
    static [string] $EmptyGuid = '00000000-0000-0000-0000-000000000000'

    # True when this dependency is a Microsoft first-party app (or the Platform/Application). Such
    # apps ship with the Business Central build and are pinned to the target, never downloaded from
    # a third-party feed. Recognised by a Microsoft publisher, a 'Microsoft.'-prefixed package id, or
    # the empty GUID.
    [bool] IsMicrosoft() {
        if ($this.Id -eq [BcPackageDependency]::EmptyGuid) { return $true }
        if ($this.Publisher -and $this.Publisher -match '^(?i)microsoft\b') { return $true }
        if ($this.PackageId -and $this.PackageId -match '^(?i)microsoft\.') { return $true }
        return $false
    }

    # Reads a field by name from a hashtable, PSCustomObject or class instance, StrictMode-safe.
    static [object] Field([object] $source, [string] $name, [object] $default) {
        if ($null -eq $source) { return $default }
        if ($source -is [System.Collections.IDictionary]) {
            if ($source.Contains($name)) { return $source[$name] }
            return $default
        }
        $property = $source.PSObject.Properties[$name]
        if ($property) { return $property.Value }
        return $default
    }

    # Normalises any field-bearing record into a BcPackageDependency. A 'Version' field is accepted
    # as the minimum when 'MinVersion' is absent (app.json dependencies carry 'version').
    static [BcPackageDependency] FromObject([object] $source) {
        if ($source -is [BcPackageDependency]) { return $source }
        $min = [BcPackageDependency]::Field($source, 'MinVersion', $null)
        if ($null -eq $min -or "$min" -eq '') { $min = [BcPackageDependency]::Field($source, 'Version', '0.0.0.0') }
        $dep = [BcPackageDependency]::new(
            "$([BcPackageDependency]::Field($source, 'Id', ''))",
            "$([BcPackageDependency]::Field($source, 'Name', ''))",
            "$([BcPackageDependency]::Field($source, 'Publisher', ''))",
            "$min"
        )
        $dep.PackageId = "$([BcPackageDependency]::Field($source, 'PackageId', ''))"
        return $dep
    }
}

class BcPackageManifest {
    [string]                $Id
    [string]                $Name        # friendly app name, for readable logs/output (not just the id)
    [string]                $Version
    [BcPackageDependency[]] $Dependencies = @()
    [string]                $Platform
    [string]                $Application
    [string]                $Kind
    [string]                $PackageId
    [string]                $ProviderName
    [object]                $Provider
    [bool]                  $Pinned
    # The concrete package coordinates to download. For a direct app package these equal
    # PackageId/Version. For an "indirect" (metapackage) candidate - which carries no .app itself but
    # points at a per-app-version runtime sub-package - these are the runtime sub-package id and the
    # BC platform build chosen for the target (closest match). Version stays the *app* version (the
    # axis the solver dedupes/orders on); DownloadVersion is the BC build actually fetched.
    [string]                $DownloadId
    [string]                $DownloadVersion

    BcPackageManifest() { }

    # The package coordinates to download, defaulting to the app package when no indirection applies.
    [string] EffectiveDownloadId()      { return $(if ($this.DownloadId)      { $this.DownloadId }      else { $this.PackageId }) }
    [string] EffectiveDownloadVersion() { return $(if ($this.DownloadVersion) { $this.DownloadVersion } else { $this.Version }) }

    # Normalises any field-bearing record (e.g. a feed provider's candidate hashtable or a test
    # fixture) into a BcPackageManifest, including its dependency list.
    static [BcPackageManifest] FromObject([object] $source) {
        if ($source -is [BcPackageManifest]) { return $source }
        $manifest = [BcPackageManifest]::new()
        $manifest.Id = "$([BcPackageDependency]::Field($source, 'Id', ''))"
        $manifest.Name = "$([BcPackageDependency]::Field($source, 'Name', ''))"
        $manifest.Version = "$([BcPackageDependency]::Field($source, 'Version', ''))"
        $manifest.Platform = "$([BcPackageDependency]::Field($source, 'Platform', ''))"
        $manifest.Application = "$([BcPackageDependency]::Field($source, 'Application', ''))"
        $manifest.Kind = "$([BcPackageDependency]::Field($source, 'Kind', ''))"
        $manifest.PackageId = "$([BcPackageDependency]::Field($source, 'PackageId', ''))"
        $manifest.ProviderName = "$([BcPackageDependency]::Field($source, 'ProviderName', ''))"
        $manifest.Provider = [BcPackageDependency]::Field($source, 'Provider', $null)
        $manifest.Pinned = [bool]([BcPackageDependency]::Field($source, 'Pinned', $false))
        $manifest.DownloadId = "$([BcPackageDependency]::Field($source, 'DownloadId', ''))"
        $manifest.DownloadVersion = "$([BcPackageDependency]::Field($source, 'DownloadVersion', ''))"
        $deps = [BcPackageDependency]::Field($source, 'Dependencies', @())
        $manifest.Dependencies = @($deps | ForEach-Object { [BcPackageDependency]::FromObject($_) })
        return $manifest
    }

    # The pinned baseline entry for an app already present in the target build (cannot be upgraded).
    static [BcPackageManifest] NewPinned([string] $id, [string] $version) {
        $manifest = [BcPackageManifest]::new()
        $manifest.Id = $id
        $manifest.Version = $version
        $manifest.Kind = 'pinned'
        $manifest.PackageId = $id
        $manifest.ProviderName = 'target'
        $manifest.Pinned = $true
        return $manifest
    }
}