Eigenverft.Manifested.Drydock.Dotnet.ps1

function Enable-TempDotnetTools {
<#
.SYNOPSIS
Install local-tools from a manifest into an ephemeral cache and expose them for THIS session.
 
.DESCRIPTION
- Reads a standard dotnet local tools manifest (dotnet-tools.json) with exact versions.
- Ensures each tool exists in a --tool-path cache (sticky or fresh).
- Puts that folder at the front of PATH for the current session only.
- Returns a single object: @{ ToolPath = "..."; Tools = [ @{Id,Version,Status[,Command]}, ... ] }.
 
.PARAMETER ManifestFile
Path to the dotnet local tools manifest (dotnet-tools.json).
 
.PARAMETER ToolPath
Optional explicit --tool-path. If omitted, a stable cache path is derived from the manifest hash.
 
.PARAMETER Fresh
If set, uses a brand-new GUID cache folder (cold start each time).
 
.PARAMETER NoCache
If set, passes --no-cache to dotnet (disables NuGet HTTP cache; slower).
 
.PARAMETER NoReturn
If set, the function does not return the object to the pipeline (console stays clean).
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$ManifestFile,
        [string]$ToolPath,
        [switch]$Fresh,
        [switch]$NoCache,
        [switch]$NoReturn
    )

    # -----------------------
    # Local helper functions
    # -----------------------

    function _GetToolsInPath {
        param([Parameter(Mandatory)][string]$Path)

        # Prefer --detail for stable parsing; fall back to table format but skip header.
        $map = @{}

        $detail = & dotnet tool list --tool-path $Path --detail 2>$null
        if ($LASTEXITCODE -eq 0 -and $detail -and ($detail -match 'Package Id\s*:')) {
            $block = @()
            foreach ($line in ($detail -split "`r?`n")) {
                if ([string]::IsNullOrWhiteSpace($line)) {
                    if ($block.Count) {
                        $id=$null; $ver=$null
                        foreach ($l in $block) {
                            if ($l -match '^\s*Package Id\s*:\s*(.+)$') { $id = $matches[1].Trim() }
                            elseif ($l -match '^\s*Version\s*:\s*(.+)$')   { $ver = $matches[1].Trim() }
                        }
                        if ($id) { $map[$id] = $ver }
                        $block = @()
                    }
                } else { $block += $line }
            }
            if ($block.Count) {
                $id=$null; $ver=$null
                foreach ($l in $block) {
                    if     ($l -match '^\s*Package Id\s*:\s*(.+)$') { $id = $matches[1].Trim() }
                    elseif ($l -match '^\s*Version\s*:\s*(.+)$')     { $ver = $matches[1].Trim() }
                }
                if ($id) { $map[$id] = $ver }
            }
            return $map
        }

        $table = & dotnet tool list --tool-path $Path 2>$null
        if ($LASTEXITCODE -ne 0 -or -not $table) { return @{} }
        foreach ($l in ($table -split "`r?`n")) {
            if ($l -match '^\s*(\S+)\s+(\S+)\s+') {
                $id = $matches[1]; $ver = $matches[2]
                if ($id -eq 'Package' -and $ver -eq 'Id') { continue } # skip header
                $map[$id] = $ver
            }
        }
        return $map
    }

    function _GetToolCommandsInPath {
        param([Parameter(Mandatory)][string]$Path)
        # Parse commands from --detail; returns @{ <id> = @('cmd1','cmd2') }
        $cmds = @{}
        $detail = & dotnet tool list --tool-path $Path --detail 2>$null
        if ($LASTEXITCODE -ne 0 -or -not $detail) { return $cmds }

        $block = @()
        foreach ($line in ($detail -split "`r?`n")) {
            if ([string]::IsNullOrWhiteSpace($line)) {
                if ($block.Count) {
                    $id=$null; $names=$null
                    foreach ($l in $block) {
                        if ($l -match '^\s*Package Id\s*:\s*(.+)$')        { $id    = $matches[1].Trim() }
                        elseif ($l -match '^\s*Tool command name\s*:\s*(.+)$') { $names = $matches[1].Trim() }
                    }
                    if ($id -and $names) {
                        $arr = @()
                        foreach ($n in ($names -split ',')) { $arr += $n.Trim() }
                        $cmds[$id] = $arr
                    }
                    $block = @()
                }
            } else { $block += $line }
        }
        if ($block.Count) {
            $id=$null; $names=$null
            foreach ($l in $block) {
                if     ($l -match '^\s*Package Id\s*:\s*(.+)$')           { $id    = $matches[1].Trim() }
                elseif ($l -match '^\s*Tool command name\s*:\s*(.+)$')    { $names = $matches[1].Trim() }
            }
            if ($id -and $names) {
                $arr = @()
                foreach ($n in ($names -split ',')) { $arr += $n.Trim() }
                $cmds[$id] = $arr
            }
        }
        return $cmds
    }

    function _EnsureExactTool {
        param(
            [Parameter(Mandatory)][string]$Path,
            [Parameter(Mandatory)][string]$Id,
            [Parameter(Mandatory)][string]$Version,
            [switch]$NoCache,
            [switch]$TryUpdateFirst
        )
        # Order: update -> install (when present) or install -> update (when missing)
        if ($TryUpdateFirst) {
            $cliArgs = @("tool","update","--tool-path",$Path,"--version",$Version,$Id)
            if ($NoCache) { $cliArgs += "--no-cache" }
            & dotnet @cliArgs 2>$null
            if ($LASTEXITCODE -eq 0) { return $true }
            $cliArgs = @("tool","install","--tool-path",$Path,"--version",$Version,$Id)
            if ($NoCache) { $cliArgs += "--no-cache" }
            & dotnet @cliArgs
            return ($LASTEXITCODE -eq 0)
        }
        else {
            $cliArgs = @("tool","install","--tool-path",$Path,"--version",$Version,$Id)
            if ($NoCache) { $cliArgs += "--no-cache" }
            & dotnet @cliArgs 2>$null
            if ($LASTEXITCODE -eq 0) { return $true }
            $cliArgs = @("tool","update","--tool-path",$Path,"--version",$Version,$Id)
            if ($NoCache) { $cliArgs += "--no-cache" }
            & dotnet @cliArgs
            return ($LASTEXITCODE -eq 0)
        }
    }

    function _PrependPathIfMissing {
        param([Parameter(Mandatory)][string]$Path)
        $sep = [IO.Path]::PathSeparator
        $parts = $env:PATH -split [regex]::Escape($sep)
        foreach ($p in $parts) { if ($p -eq $Path) { return } }
        $env:PATH = ($Path + $sep + $env:PATH)
    }

    # -----------------------
    # 1) Resolve inputs
    # -----------------------

    $mf = Resolve-Path -LiteralPath $ManifestFile -ErrorAction Stop
    Write-Host ("[dotnet-tools] Manifest: {0}" -f $mf.Path) -ForegroundColor DarkGray

    $manifest = Get-Content -Raw -LiteralPath $mf | ConvertFrom-Json
    if (-not $manifest.tools) { throw "Manifest has no 'tools' entries: $mf" }

    # Derive tool-path: fresh GUID or sticky cache (hash of manifest) or explicit path
    if ($Fresh) {
        $ToolPath = Join-Path $env:TEMP ("dotnet-tools\" + [guid]::NewGuid().ToString("n"))
    }
    elseif (-not $ToolPath) {
        $hash = (Get-FileHash -LiteralPath $mf -Algorithm SHA256).Hash.Substring(0,16)
        $base = [Environment]::GetFolderPath('LocalApplicationData')
        if (-not $base) { $base = $env:TEMP }
        $ToolPath = Join-Path $base ("dotnet-tools-cache\" + $hash)
    }
    New-Item -ItemType Directory -Force -Path $ToolPath | Out-Null
    Write-Host ("[dotnet-tools] Cache (toolpath): {0}" -f $ToolPath) -ForegroundColor DarkGray

    # -----------------------
    # 2) Snapshot BEFORE
    # -----------------------
    $before = _GetToolsInPath -Path $ToolPath
    if ($before.Count -gt 0) {
        Write-Host ("[dotnet-tools] Cache existing: {0}" -f $before.Count) -ForegroundColor DarkGray
        foreach ($k in ($before.Keys | Sort-Object)) {
            Write-Host (" - {0} {1}" -f $k, $before[$k]) -ForegroundColor DarkGray
        }
    } else {
        Write-Host "[dotnet-tools] Cache existing: none" -ForegroundColor DarkGray
    }

    # -----------------------
    # 3) Ensure each tool (sorted for readability)
    # -----------------------
    $toolsResult = @()
    $toolProps = $manifest.tools.PSObject.Properties | Sort-Object Name
    foreach ($prop in $toolProps) {
        $id = [string]$prop.Name
        $ver = [string]$prop.Value.version
        if (-not $ver) { throw "Tool '$id' in manifest lacks a 'version'." }

        $present   = $before.ContainsKey($id)
        $unchanged = $present -and ($before[$id] -eq $ver)

        $status = "AlreadyPresent"
        if (-not $unchanged) {
            Write-Host ("[dotnet-tools] Ensuring: {0}@{1}" -f $id, $ver) -ForegroundColor DarkGray
            $ok = _EnsureExactTool -Path $ToolPath -Id $id -Version $ver -NoCache:$NoCache -TryUpdateFirst:$present
            if (-not $ok) { throw "Failed to ensure $id@$ver in $ToolPath." }
            if ($present) {
                $status = "Updated"
            } else {
                $status = "Installed"
            }
        }

        # Per-tool status line with color
        switch ($status) {
            "Installed" { $fc = "Green" }
            "Updated"   { $fc = "Yellow" }
            default     { $fc = "Cyan" } # AlreadyPresent
        }
        Write-Host ("[{0,-10}] {1}@{2}" -f $status, $id, $ver) -ForegroundColor $fc

        $toolsResult += [pscustomobject]@{ Id = $id; Version = $ver; Status = $status }
    }

    # -----------------------
    # 4) PATH (session only)
    # -----------------------
    _PrependPathIfMissing -Path $ToolPath
    Write-Host ("[dotnet-tools] PATH updated: {0}" -f $ToolPath) -ForegroundColor DarkGray


    # -----------------------
    # 5) Snapshot AFTER (normalize versions actually resolved by dotnet) + commands (from manifest)
    # -----------------------
    $after = _GetToolsInPath -Path $ToolPath

    # Build command map from the manifest (tools.<id>.commands)
    $cmdInfo = @{}
    $manifestToolsProps = $manifest.tools.PSObject.Properties | Sort-Object Name
    foreach ($p in $manifestToolsProps) {
        $id = [string]$p.Name
        $cmds = @()
        if ($p.Value.PSObject.Properties.Name -contains 'commands') {
            foreach ($n in @($p.Value.commands)) {
                if ($n) { $cmds += [string]$n }
            }
        }
        if ($cmds.Count -gt 0) { $cmdInfo[$id] = $cmds }
    }

    for ($i = 0; $i -lt $toolsResult.Count; $i++) {
        $rid = $toolsResult[$i].Id
        if ($after.ContainsKey($rid)) {
            if ($toolsResult[$i].Version -ne $after[$rid]) {
                Write-Host ("[dotnet-tools] Resolved: {0} -> {1}" -f $toolsResult[$i].Version, $after[$rid]) -ForegroundColor DarkGray
            }
            $toolsResult[$i].Version = $after[$rid]
        }

        if ($cmdInfo.ContainsKey($rid)) {
            $cmdsText = ($cmdInfo[$rid] -join ", ")
            # Attach Command property always, so the printout is consistent.
            Add-Member -InputObject $toolsResult[$i] -NotePropertyName Command -NotePropertyValue $cmdsText -Force
        }
    }

    # -----------------------
    # Pretty print manifest-sourced command names (ASCII only, PS5-safe)
    # -----------------------
    $rows = @()
    foreach ($p in $manifestToolsProps) {
        $id = [string]$p.Name
        $hasCmd = $false
        $joined = "(none)"
        if ($cmdInfo.ContainsKey($id) -and $cmdInfo[$id].Count -gt 0) {
            $joined = ($cmdInfo[$id] -join ", ")
            $hasCmd = $true
        }
        $rows += New-Object psobject -Property @{ Id = $id; Cmds = $joined; Has = $hasCmd }
    }

    # Determine column width for PACKAGE column (min width 8)
    $idWidth = 8
    foreach ($r in $rows) { if ($r.Id.Length -gt $idWidth) { $idWidth = $r.Id.Length } }

    $headerLeft  = "PACKAGE".PadRight($idWidth)
    $headerRight = "TOOLCOMMANDNAMES"
    $sepLeft  = ("-" * $idWidth)
    $sepRight = ("-" * $headerRight.Length)

    Write-Host ("[dotnet-tools] Commands (manifest): {0} tool(s)" -f $rows.Count) -ForegroundColor Cyan
    Write-Host (" {0} {1}" -f $headerLeft, $headerRight) -ForegroundColor DarkGray
    Write-Host (" {0} {1}" -f $sepLeft,     $sepRight)   -ForegroundColor DarkGray

    foreach ($r in $rows) {
        # ASCII status symbol and simple colors (no Unicode)
        $symbol   = "-"
        $symColor = "DarkGray"
        if ($r.Has) { $symbol = "+"; $symColor = "Green" }

        $cmdColor = "DarkGray"
        if ($r.Has) { $cmdColor = "White" }

        Write-Host " " -NoNewline
        Write-Host $symbol -ForegroundColor $symColor -NoNewline
        Write-Host (" {0} " -f $r.Id.PadRight($idWidth)) -ForegroundColor Gray -NoNewline
        Write-Host " ... " -ForegroundColor DarkGray -NoNewline
        Write-Host $r.Cmds -ForegroundColor $cmdColor
    }


    # -----------------------
    # 6) Return single object (unless -NoReturn)
    # -----------------------
    if (-not $NoReturn) {
        return [pscustomobject]@{
            ToolPath = $ToolPath
            Tools    = $toolsResult
        }
    }
}

function Disable-TempDotnetTools {
<#
.SYNOPSIS
Remove the ephemeral tool cache from PATH and optionally delete it.
 
.DESCRIPTION
- Accepts either a direct --tool-path or a manifest file.
- When given -ManifestFile, computes the same sticky cache path used by Enable-TempDotnetTools:
  %LOCALAPPDATA%\dotnet-tools-cache\<SHA256(manifest CONTENT) first 16 chars>
- Removes that folder from the current session PATH.
- Optionally deletes the folder (cold start next time).
 
.PARAMETER ToolPath
The folder previously used with --tool-path.
 
.PARAMETER ManifestFile
Path to dotnet-tools.json; used to derive the sticky cache path.
 
.PARAMETER Delete
Also delete the cache folder on disk.
 
.EXAMPLE
Disable-TempDotnetTools -ManifestFile "$PSScriptRoot\.config\dotnet-tools.json" -Delete
#>

    [CmdletBinding(SupportsShouldProcess=$true, DefaultParameterSetName='ByToolPath')]
    param(
        [Parameter(Mandatory=$true, ParameterSetName='ByToolPath')]
        [string]$ToolPath,

        [Parameter(Mandatory=$true, ParameterSetName='ByManifestFile')]
        [string]$ManifestFile,

        [switch]$Delete
    )

    # Resolve tool path from manifest if requested (uses CONTENT hash to match Enable-TempDotnetTools)
    if ($PSCmdlet.ParameterSetName -eq 'ByManifestFile') {
        $mf = Resolve-Path -LiteralPath $ManifestFile -ErrorAction Stop
        $hash = (Get-FileHash -LiteralPath $mf -Algorithm SHA256).Hash.Substring(0,16)
        $base = [Environment]::GetFolderPath('LocalApplicationData')
        if (-not $base) { $base = $env:TEMP }
        $ToolPath = Join-Path $base ("dotnet-tools-cache\" + $hash)
    }

    # Remove from PATH (session only)
    $sep   = [IO.Path]::PathSeparator
    $parts = $env:PATH -split [regex]::Escape($sep)
    $env:PATH = ($parts | Where-Object { $_ -and ($_ -ne $ToolPath) }) -join $sep

    # Optionally delete on disk
    if ($Delete -and (Test-Path -LiteralPath $ToolPath)) {
        if ($PSCmdlet.ShouldProcess($ToolPath, "Remove-Item -Recurse -Force")) {
            Remove-Item -LiteralPath $ToolPath -Recurse -Force
        }
    }
}

function Register-LocalNuGetDotNetPackageSource {
<#
.SYNOPSIS
    Registers a NuGet source using the dotnet CLI and returns its effective name.
 
.DESCRIPTION
    Ensures the given Location (URL or local path) is present in dotnet nuget sources
    under the chosen name and state (Enabled/Disabled). If -SourceName is omitted,
    the function reuses an existing name for the same Location or generates a temporary one.
    Returns the effective SourceName as a string.
 
.PARAMETER SourceLocation
    Source location. HTTP(S) URL or local/UNC path. Local paths will be created if missing.
    Default: "$HOME/source/LocalNuGet".
 
.PARAMETER SourceName
    Optional name. If omitted, reuse by Location or generate TempNuGetSrc-xxxxxxxx.
    Must start/end with a letter or digit; dot, hyphen, underscore allowed inside.
 
.PARAMETER SourceState
    Enabled or Disabled. Default: Enabled. If a source exists with a different state,
    it will be toggled accordingly.
 
.EXAMPLE
    $n = Register-LocalNuGetDotNetPackageSource -SourceLocation "C:\nuget-local"
 
.EXAMPLE
    $n = Register-LocalNuGetDotNetPackageSource -SourceLocation "https://api.nuget.org/v3/index.json" -SourceName "nuget.org" -SourceState Enabled
#>

    [CmdletBinding()]
    [Alias("rldnps")]
    param(
        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$SourceLocation = "$HOME/source/LocalNuGet",

        [Parameter(Mandatory = $false)]
        [string]$SourceName,

        [Parameter(Mandatory = $false)]
        [ValidateSet('Enabled','Disabled')]
        [string]$SourceState = 'Enabled'
    )

    function Invoke-DotNetNuGet([string[]]$CmdArgs) {
        $out = & dotnet @CmdArgs 2>&1
        if ($LASTEXITCODE -ne 0) { throw "dotnet nuget failed ($LASTEXITCODE): $out" }
        return $out
    }

    if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
        throw "dotnet CLI not found on PATH."
    }

    # Detect URL vs local path; normalize and ensure local dir when needed.
    $isUrl = $false
    try {
        $u = [Uri]$SourceLocation
        if ($u.IsAbsoluteUri -and ($u.Scheme -eq 'http' -or $u.Scheme -eq 'https')) { $isUrl = $true }
    } catch { $isUrl = $false }

    if ($isUrl) {
        Write-Host "Using URL source location: $SourceLocation" -ForegroundColor Cyan
    } else {
        try {
            $SourceLocation = [IO.Path]::GetFullPath((Join-Path -Path $SourceLocation -ChildPath '.'))
        } catch {
            throw "Invalid source path '$SourceLocation': $($_.Exception.Message)"
        }
        if (-not (Test-Path -Path $SourceLocation -PathType Container)) {
            try {
                New-Item -ItemType Directory -Path $SourceLocation -Force -ErrorAction Stop | Out-Null
                Write-Host "Created local source directory: $SourceLocation" -ForegroundColor Green
            } catch {
                throw "Failed to create source directory '$SourceLocation': $($_.Exception.Message)"
            }
        } else {
            Write-Host "Using local source directory: $SourceLocation" -ForegroundColor Cyan
        }
    }

    # List and parse existing sources.
    $lines = (Invoke-DotNetNuGet @('nuget','list','source')) -split '\r?\n'
    $entries = New-Object System.Collections.Generic.List[object]
    for ($i = 0; $i -lt $lines.Count; $i++) {
        if ($lines[$i] -match '^\s*\d+\.\s*(?<Name>\S+)\s*\[(?<Status>Enabled|Disabled)\]\s*$') {
            $nm = $Matches['Name']; $st = $Matches['Status']
            $loc = $null
            for ($j = $i + 1; $j -lt $lines.Count; $j++) {
                $t = $lines[$j].Trim()
                if ($t) { $loc = $t; break }
            }
            if ($loc) { $entries.Add([PSCustomObject]@{ Name=$nm; Location=$loc; Status=$st }) }
        }
    }

    # Determine or validate name.
    $namePattern = '^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$'
    if ([string]::IsNullOrWhiteSpace($SourceName)) {
        $byLoc = $entries | Where-Object { $_.Location -eq $SourceLocation } | Select-Object -First 1
        if ($byLoc) {
            $SourceName = $byLoc.Name
            Write-Host "Reusing existing source name '$SourceName' for location '$SourceLocation'." -ForegroundColor Yellow
        }
        else {
            $SourceName = 'TempNuGetSrc-' + ([Guid]::NewGuid().ToString('N').Substring(8))
            Write-Host "Generated temporary source name: $SourceName" -ForegroundColor Yellow
        }
    } elseif ($SourceName -notmatch $namePattern) {
        throw "SourceName '$SourceName' is invalid. Allowed: letters/digits; dot, hyphen, underscore allowed inside."
    }

    $byName = $entries | Where-Object { $_.Name -eq $SourceName } | Select-Object -First 1
    $byLoc2 = $entries | Where-Object { $_.Location -eq $SourceLocation } | Select-Object -First 1

    # Reconcile location/name clashes.
    if ($byLoc2 -and -not $byName) {
        if ($PSBoundParameters.ContainsKey('SourceName')) {
            Write-Host "Removing conflicting existing source '$($byLoc2.Name)' for location '$SourceLocation'." -ForegroundColor Yellow
            Invoke-DotNetNuGet @('nuget','remove','source',$byLoc2.Name) | Out-Null
        } else {
            $SourceName = $byLoc2.Name
            $byName = $byLoc2
            Write-Host "Reusing existing source '$SourceName' bound to location '$SourceLocation'." -ForegroundColor Yellow
        }
    }

    if ($byName) {
        if ($byName.Location -ne $SourceLocation) {
            Write-Host "Updating source '$SourceName' location from '$($byName.Location)' to '$SourceLocation'." -ForegroundColor Cyan
            Invoke-DotNetNuGet @('nuget','remove','source',$SourceName) | Out-Null
            Invoke-DotNetNuGet @('nuget','add','source',$SourceLocation,'--name',$SourceName) | Out-Null
            $byName = [PSCustomObject]@{ Name=$SourceName; Location=$SourceLocation; Status='Enabled' }
            Write-Host "Source '$SourceName' added at '$SourceLocation' (Enabled)." -ForegroundColor Green
        }
        if ($SourceState -eq 'Enabled' -and $byName.Status -eq 'Disabled') {
            Write-Host "Enabling source '$SourceName'." -ForegroundColor Cyan
            Invoke-DotNetNuGet @('nuget','enable','source',$SourceName) | Out-Null
            Write-Host "Source '$SourceName' is now Enabled." -ForegroundColor Green
        } elseif ($SourceState -eq 'Disabled' -and $byName.Status -eq 'Enabled') {
            Write-Host "Disabling source '$SourceName'." -ForegroundColor Cyan
            Invoke-DotNetNuGet @('nuget','disable','source',$SourceName) | Out-Null
            Write-Host "Source '$SourceName' is now Disabled." -ForegroundColor Green
        } else {
            Write-Host "No state change needed for '$SourceName' (already $($byName.Status))." -ForegroundColor Yellow
        }
    } else {
        Write-Host "Adding source '$SourceName' at '$SourceLocation'." -ForegroundColor Cyan
        Invoke-DotNetNuGet @('nuget','add','source',$SourceLocation,'--name',$SourceName) | Out-Null
        if ($SourceState -eq 'Disabled') {
            Write-Host "Disabling source '$SourceName' after add." -ForegroundColor Cyan
            Invoke-DotNetNuGet @('nuget','disable','source',$SourceName) | Out-Null
            Write-Host "Source '$SourceName' is now Disabled." -ForegroundColor Green
        } else {
            Write-Host "Source '$SourceName' added and Enabled." -ForegroundColor Green
        }
    }

    return $SourceName
}

function Unregister-LocalNuGetDotNetPackageSource {
<#
.SYNOPSIS
    Unregisters a NuGet source by name using the dotnet CLI.
 
.DESCRIPTION
    Removes the specified source if present. Safe to call repeatedly; no error if already absent.
 
.PARAMETER SourceName
    Source name to remove. Must start/end with a letter or digit; dot, hyphen, underscore allowed inside.
 
.EXAMPLE
    $n = Register-LocalNuGetDotNetPackageSource -SourceLocation "C:\nuget-local"
    Unregister-LocalNuGetDotNetPackageSource -SourceName $n
#>

    [CmdletBinding()]
    [Alias("uldnps")]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [ValidatePattern('^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])$')]
        [string]$SourceName
    )

    function Invoke-DotNetNuGet([string[]]$CmdArgs) {
        $out = & dotnet @CmdArgs 2>&1
        if ($LASTEXITCODE -ne 0) { throw "dotnet nuget failed ($LASTEXITCODE): $out" }
        return $out
    }

    if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
        throw "dotnet CLI not found on PATH."
    }

    $lines = (Invoke-DotNetNuGet @('nuget','list','source')) -split '\r?\n'
    $exists = $false
    for ($i = 0; $i -lt $lines.Count; $i++) {
        if ($lines[$i] -match '^\s*\d+\.\s*' + [regex]::Escape($SourceName) + '\s*\[(Enabled|Disabled)\]\s*$') {
            $exists = $true
            break
        }
    }

    if ($exists) {
        Write-Host "Removing NuGet source '$SourceName'." -ForegroundColor Cyan
        Invoke-DotNetNuGet @('nuget','remove','source',$SourceName) | Out-Null
        Write-Host "NuGet source '$SourceName' removed." -ForegroundColor Green
    } else {
        Write-Host "NuGet source '$SourceName' not found; nothing to do." -ForegroundColor Yellow
    }
}

function New-DotnetBillOfMaterialsReport {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, HelpMessage = "Array of JSON strings output from dotnet list command.")]
        [string[]]$jsonInput,

        [Parameter(Mandatory = $false, HelpMessage = "Optional file path to save the output.")]
        [string]$OutputFile,

        [Parameter(Mandatory = $false, HelpMessage = "Output format: 'text' or 'markdown'. Defaults to 'text'.")]
        [ValidateSet("text", "markdown")]
        [string]$OutputFormat = "text",

        [Parameter(Mandatory = $false, HelpMessage = "When set to true, transitive packages are ignored. Defaults to true.")]
        [bool]$IgnoreTransitivePackages = $true,

        [Parameter(Mandatory = $false, HelpMessage = "Aggregates the output by grouping on ProjectName, Package, and ResolvedVersion, and optionally PackageType. Defaults to true.")]
        [bool]$Aggregate = $true,

        [Parameter(Mandatory = $false, HelpMessage = "When set to true, the aggregated output includes PackageType. Defaults to false.")]
        [bool]$IncludePackageType = $false,

        [Parameter(Mandatory = $false, HelpMessage = "When set to true, a professional title is generated and prepended to the output. Defaults to true.")]
        [bool]$GenerateTitle = $true,

        [Parameter(Mandatory = $false, HelpMessage = "Overrides the generated markdown title with a custom title. Only applied when OutputFormat is markdown.")]
        [string]$SetMarkDownTitle,

        [Parameter(Mandatory = $false, HelpMessage = "Array of ProjectNames to always include in the output.")]
        [string[]]$ProjectWhitelist,

        [Parameter(Mandatory = $false, HelpMessage = "Array of ProjectNames to exclude from the output unless they are also in the whitelist.")]
        [string[]]$ProjectBlacklist
    )

    <#
    .SYNOPSIS
    Generates a professional Bill of Materials (BOM) report from dotnet list JSON output.
 
    .DESCRIPTION
    Processes JSON input from the dotnet list command to extract project, framework, and package information.
    Each package entry is tagged as "TopLevel" or "Transitive". Optionally, transitive packages can be ignored.
    The function supports aggregation, which groups entries by ProjectName, Package, and ResolvedVersion (and optionally PackageType).
    Additionally, a professional title is generated (if enabled via -GenerateTitle) that lists the projects included in the report.
    When OutputFormat is markdown, the title is rendered as an H2 header, or can be overridden via -SetMarkDownTitle.
    BOM entries can also be filtered using project whitelist and blacklist parameters.
 
    .PARAMETER jsonInput
    Array of JSON strings output from the dotnet list command.
 
    .PARAMETER OutputFile
    Optional file path to save the output.
 
    .PARAMETER OutputFormat
    Specifies the output format: 'text' or 'markdown'. Defaults to 'text'.
 
    .PARAMETER IgnoreTransitivePackages
    When set to $true, transitive packages are ignored. Defaults to $true.
 
    .PARAMETER Aggregate
    When set to $true, aggregates the output by grouping on ProjectName, Package, and ResolvedVersion,
    and optionally PackageType (based on IncludePackageType). Defaults to $true.
 
    .PARAMETER IncludePackageType
    When set to $true, the aggregated output includes PackageType. Defaults to $false.
 
    .PARAMETER GenerateTitle
    When set to $true, a professional title including project names is generated and prepended to the output.
    Defaults to $true.
 
    .PARAMETER SetMarkDownTitle
    Overrides the generated markdown title with a custom title. Only applied when OutputFormat is markdown.
 
    .PARAMETER ProjectWhitelist
    Array of ProjectNames to always include in the output.
 
    .PARAMETER ProjectBlacklist
    Array of ProjectNames to exclude from the output unless they are also in the whitelist.
 
    .EXAMPLE
    New-DotnetBillOfMaterialsReport -jsonInput $jsonData -OutputFormat markdown -IgnoreTransitivePackages $false
 
    .EXAMPLE
    New-DotnetBillOfMaterialsReport -jsonInput $jsonData -Aggregate $false -OutputFile "bom.txt"
 
    .EXAMPLE
    New-DotnetBillOfMaterialsReport -jsonInput $jsonData -ProjectWhitelist "ProjectA","ProjectB" -ProjectBlacklist "ProjectC"
 
    .EXAMPLE
    New-DotnetBillOfMaterialsReport -jsonInput $jsonData -SetMarkDownTitle "Custom BOM Title"
    #>


    try {
        $result = $jsonInput | ConvertFrom-Json
    }
    catch {
        Write-Error "Failed to parse JSON input from dotnet list command."
        exit 1
    }

    $bomEntries = @()

    # Build BOM entries from projects and their frameworks.
    foreach ($project in $result.projects) {
        if ($project.frameworks) {
            foreach ($framework in $project.frameworks) {
                # Process top-level packages.
                if ($framework.topLevelPackages) {
                    foreach ($package in $framework.topLevelPackages) {
                        $bomEntries += [PSCustomObject]@{
                            ProjectName     = [System.IO.Path]::GetFileNameWithoutExtension($project.path)
                            Framework       = $framework.framework
                            Package         = $package.id
                            ResolvedVersion = $package.resolvedVersion
                            PackageType     = "TopLevel"
                        }
                    }
                }

                # Process transitive packages only if not ignored.
                if (-not $IgnoreTransitivePackages -and $framework.transitivePackages) {
                    foreach ($package in $framework.transitivePackages) {
                        $bomEntries += [PSCustomObject]@{
                            ProjectName     = [System.IO.Path]::GetFileNameWithoutExtension($project.path)
                            Framework       = $framework.framework
                            Package         = $package.id
                            ResolvedVersion = $package.resolvedVersion
                            PackageType     = "Transitive"
                        }
                    }
                }
            }
        }
    }

    # Filter BOM entries by project whitelist and blacklist.
    if ($ProjectWhitelist -or $ProjectBlacklist) {
        $bomEntries = $bomEntries | Where-Object {
            if ($ProjectWhitelist -and ($ProjectWhitelist -contains $_.ProjectName)) {
                # Always include if in whitelist.
                $true
            }
            elseif ($ProjectBlacklist -and ($ProjectBlacklist -contains $_.ProjectName)) {
                # Exclude if in blacklist and not whitelisted.
                $false
            }
            else {
                $true
            }
        }
    }

    # If aggregation is enabled, group entries accordingly.
    if ($Aggregate) {
        if ($IncludePackageType) {
            $bomEntries = $bomEntries | Group-Object -Property ProjectName, Package, ResolvedVersion, PackageType | ForEach-Object {
                [PSCustomObject]@{
                    ProjectName     = $_.Group[0].ProjectName
                    Package         = $_.Group[0].Package
                    ResolvedVersion = $_.Group[0].ResolvedVersion
                    PackageType     = $_.Group[0].PackageType
                }
            }
        }
        else {
            $bomEntries = $bomEntries | Group-Object -Property ProjectName, Package, ResolvedVersion | ForEach-Object {
                [PSCustomObject]@{
                    ProjectName     = $_.Group[0].ProjectName
                    Package         = $_.Group[0].Package
                    ResolvedVersion = $_.Group[0].ResolvedVersion
                }
            }
        }
    }

    # Generate output based on the specified format.
    switch ($OutputFormat) {
        "text" {
            $output = $bomEntries | Format-Table -AutoSize | Out-String
        }
        "markdown" {
            if ($Aggregate) {
                if ($IncludePackageType) {
                    $mdTable = @()
                    $mdTable += "| ProjectName | Package | ResolvedVersion | PackageType |"
                    $mdTable += "|-------------|---------|-----------------|-------------|"
                    foreach ($item in $bomEntries) {
                        $mdTable += "| $($item.ProjectName) | $($item.Package) | $($item.ResolvedVersion) | $($item.PackageType) |"
                    }
                }
                else {
                    $mdTable = @()
                    $mdTable += "| ProjectName | Package | ResolvedVersion |"
                    $mdTable += "|-------------|---------|-----------------|"
                    foreach ($item in $bomEntries) {
                        $mdTable += "| $($item.ProjectName) | $($item.Package) | $($item.ResolvedVersion) |"
                    }
                }
                $output = $mdTable -join "`n"
            }
            else {
                $mdTable = @()
                $mdTable += "| ProjectName | Framework | Package | ResolvedVersion | PackageType |"
                $mdTable += "|-------------|-----------|---------|-----------------|-------------|"
                foreach ($item in $bomEntries) {
                    $mdTable += "| $($item.ProjectName) | $($item.Framework) | $($item.Package) | $($item.ResolvedVersion) | $($item.PackageType) |"
                }
                $output = $mdTable -join "`n"
            }
        }
    }

    # Generate and prepend a professional title if enabled.
    if ($GenerateTitle) {
        $distinctProjects = $bomEntries | Select-Object -ExpandProperty ProjectName -Unique | Sort-Object
        $projectsStr = $distinctProjects -join ", "
        $defaultTitle = "Bill of Materials Report for Projects: $projectsStr"

        if ($OutputFormat -eq "markdown") {
            if ([string]::IsNullOrEmpty($SetMarkDownTitle)) {
                $titleText = "## $defaultTitle`n`n"
            }
            else {
                $titleText = "## $SetMarkDownTitle`n`n"
            }
        }
        else {
            $underline = "-" * $defaultTitle.Length
            $titleText = "$defaultTitle`n$underline`n`n"
        }
        $output = $titleText + $output
    }

    # Write output to file if specified; otherwise, output to the pipeline.
    if ($OutputFile) {
        $OutputFile = $OutputFile -replace '[\\/]', [System.IO.Path]::DirectorySeparatorChar
        try {
            # Extract the directory from the output file path.
            $outputDir = Split-Path -Path $OutputFile -Parent
            
            # If the directory does not exist, create it.
            if (-not (Test-Path -Path $outputDir)) {
                New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
                Write-Verbose "Created directory: $outputDir"
            }
            
            # Write the output content to the file.
            Set-Content -Path $OutputFile -Value $output -Force
            Write-Verbose "Output written to $OutputFile"
        }
        catch {
            Write-Error "Failed to write output to file: $_"
        }
    }
    else {
        Write-Output $output
    }
}

function New-DotnetVulnerabilitiesReport {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, HelpMessage = "Array of JSON strings output from dotnet list command with the '--vulnerable' flag.")]
        [string[]]$jsonInput,

        [Parameter(Mandatory = $false, HelpMessage = "Optional file path to save the output.")]
        [string]$OutputFile,

        [Parameter(Mandatory = $false, HelpMessage = "Output format: 'text' or 'markdown'. Defaults to 'text'.")]
        [ValidateSet("text", "markdown")]
        [string]$OutputFormat = "text",

        [Parameter(Mandatory = $false, HelpMessage = "When set to true, the function exits with error code 1 if any vulnerability is found. Defaults to false.")]
        [bool]$ExitOnVulnerability = $false,

        [Parameter(Mandatory = $false, HelpMessage = "When set to true, aggregates the output by grouping on Project, Package, and ResolvedVersion, and optionally PackageType. Defaults to true.")]
        [bool]$Aggregate = $true,

        [Parameter(Mandatory = $false, HelpMessage = "When set to true, transitive packages are ignored. Defaults to true.")]
        [bool]$IgnoreTransitivePackages = $true,

        [Parameter(Mandatory = $false, HelpMessage = "When set to true, the aggregated output includes PackageType. Defaults to false.")]
        [bool]$IncludePackageType = $false,

        [Parameter(Mandatory = $false, HelpMessage = "When set to true, a professional title is generated and prepended to the output. Defaults to true.")]
        [bool]$GenerateTitle = $true,

        [Parameter(Mandatory = $false, HelpMessage = "Overrides the generated markdown title with a custom title. Only applied when OutputFormat is markdown.")]
        [string]$SetMarkDownTitle,

        [Parameter(Mandatory = $false, HelpMessage = "Array of ProjectNames to always include in the output.")]
        [string[]]$ProjectWhitelist,

        [Parameter(Mandatory = $false, HelpMessage = "Array of ProjectNames to exclude from the output unless they are also in the whitelist.")]
        [string[]]$ProjectBlacklist
    )

    <#
    .SYNOPSIS
    Generates a professional vulnerabilities report from JSON input output by the dotnet list command with the '--vulnerable' flag.
 
    .DESCRIPTION
    Processes JSON input from the dotnet list command to gather vulnerability details for each project's frameworks and packages.
    Only the resolved version is reported. Top-level packages are always processed, while transitive packages are processed only when
    -IgnoreTransitivePackages is set to false. The report can be aggregated (grouping by Project, Package, ResolvedVersion, and optionally PackageType),
    and filtered by project whitelist/blacklist. The output is generated in text or markdown format, with a professional title prepended.
    Optionally, if ExitOnVulnerability is enabled and any vulnerability is found, the function exits with error code 1.
 
    .PARAMETER jsonInput
    Array of JSON strings output from the dotnet list command with the '--vulnerable' flag.
 
    .PARAMETER OutputFile
    Optional file path to save the output.
 
    .PARAMETER OutputFormat
    Specifies the output format: 'text' or 'markdown'. Defaults to 'text'.
 
    .PARAMETER ExitOnVulnerability
    When set to true, the function exits with error code 1 if any vulnerability is found. Defaults to false.
 
    .PARAMETER Aggregate
    When set to true, aggregates the output by grouping on Project, Package, and ResolvedVersion (and optionally PackageType). Defaults to true.
 
    .PARAMETER IgnoreTransitivePackages
    When set to true, transitive packages are ignored. Defaults to true.
 
    .PARAMETER IncludePackageType
    When set to true, the aggregated output includes PackageType. Defaults to false.
 
    .PARAMETER GenerateTitle
    When set to true, a professional title including project names is generated and prepended to the output. Defaults to true.
 
    .PARAMETER SetMarkDownTitle
    Overrides the generated markdown title with a custom title. Only applied when OutputFormat is markdown.
 
    .PARAMETER ProjectWhitelist
    Array of ProjectNames to always include in the output.
 
    .PARAMETER ProjectBlacklist
    Array of ProjectNames to exclude from the output unless they are also in the whitelist.
 
    .EXAMPLE
    New-DotnetVulnerabilitiesReport -jsonInput $jsonData -OutputFormat markdown -ExitOnVulnerability $true
 
    .EXAMPLE
    New-DotnetVulnerabilitiesReport -jsonInput $jsonData -OutputFile "vuln_report.txt"
 
    .EXAMPLE
    New-DotnetVulnerabilitiesReport -jsonInput $jsonData -SetMarkDownTitle "Custom Vulnerability Report"
    #>


    try {
        $result = $jsonInput | ConvertFrom-Json
    }
    catch {
        Write-Error "Failed to parse JSON input from dotnet list command."
        exit 1
    }

    $vulnerabilitiesFound = @()

    # Process each project and its frameworks.
    foreach ($project in $result.projects) {
        if ($project.frameworks) {
            foreach ($framework in $project.frameworks) {
                # Process top-level packages.
                if ($framework.topLevelPackages) {
                    foreach ($package in $framework.topLevelPackages) {
                        if ($package.vulnerabilities) {
                            foreach ($vuln in $package.vulnerabilities) {
                                $vulnerabilitiesFound += [PSCustomObject]@{
                                    Project         = [System.IO.Path]::GetFileNameWithoutExtension($project.path)
                                    Framework       = $framework.framework
                                    Package         = $package.id
                                    ResolvedVersion = $package.resolvedVersion
                                    Severity        = $vuln.severity
                                    AdvisoryUrl     = $vuln.advisoryurl
                                    PackageType     = "TopLevel"
                                }
                            }
                        }
                    }
                }
                # Process transitive packages if not ignored.
                if (-not $IgnoreTransitivePackages -and $framework.transitivePackages) {
                    foreach ($package in $framework.transitivePackages) {
                        if ($package.vulnerabilities) {
                            foreach ($vuln in $package.vulnerabilities) {
                                $vulnerabilitiesFound += [PSCustomObject]@{
                                    Project         = [System.IO.Path]::GetFileNameWithoutExtension($project.path)
                                    Framework       = $framework.framework
                                    Package         = $package.id
                                    ResolvedVersion = $package.resolvedVersion
                                    Severity        = $vuln.severity
                                    AdvisoryUrl     = $vuln.advisoryurl
                                    PackageType     = "Transitive"
                                }
                            }
                        }
                    }
                }
            }
        }
    }

    # Filter vulnerabilities by project whitelist and blacklist.
    if ($ProjectWhitelist -or $ProjectBlacklist) {
        $vulnerabilitiesFound = $vulnerabilitiesFound | Where-Object {
            if ($ProjectWhitelist -and ($ProjectWhitelist -contains $_.Project)) {
                $true
            }
            elseif ($ProjectBlacklist -and ($ProjectBlacklist -contains $_.Project)) {
                $false
            }
            else {
                $true
            }
        }
    }

    # Aggregate vulnerabilities if enabled.
    if ($Aggregate) {
        if ($IncludePackageType) {
            $vulnerabilitiesFound = $vulnerabilitiesFound | Group-Object -Property Project, Package, ResolvedVersion, PackageType | ForEach-Object {
                [PSCustomObject]@{
                    Project         = $_.Group[0].Project
                    Package         = $_.Group[0].Package
                    ResolvedVersion = $_.Group[0].ResolvedVersion
                    PackageType     = $_.Group[0].PackageType
                    Severity        = $_.Group[0].Severity
                    AdvisoryUrl     = $_.Group[0].AdvisoryUrl
                }
            }
        }
        else {
            $vulnerabilitiesFound = $vulnerabilitiesFound | Group-Object -Property Project, Package, ResolvedVersion | ForEach-Object {
                [PSCustomObject]@{
                    Project         = $_.Group[0].Project
                    Package         = $_.Group[0].Package
                    ResolvedVersion = $_.Group[0].ResolvedVersion
                    Severity        = $_.Group[0].Severity
                    AdvisoryUrl     = $_.Group[0].AdvisoryUrl
                }
            }
        }
    }

    # Generate report output based on the specified format.
    if ($OutputFormat -eq "text") {
        if ($vulnerabilitiesFound.Count -gt 0) {
            $output = $vulnerabilitiesFound | Format-Table -AutoSize | Out-String
        }
        else {
            $output = "No vulnerabilities found."
        }
    }
    elseif ($OutputFormat -eq "markdown") {
        if ($vulnerabilitiesFound.Count -gt 0) {
            if ($Aggregate) {
                if ($IncludePackageType) {
                    $mdTable = @()
                    $mdTable += "| Project | Package | ResolvedVersion | PackageType | Severity | AdvisoryUrl |"
                    $mdTable += "|---------|---------|-----------------|-------------|----------|-------------|"
                    foreach ($item in $vulnerabilitiesFound) {
                        $mdTable += "| $($item.Project) | $($item.Package) | $($item.ResolvedVersion) | $($item.PackageType) | $($item.Severity) | $($item.AdvisoryUrl) |"
                    }
                }
                else {
                    $mdTable = @()
                    $mdTable += "| Project | Package | ResolvedVersion | Severity | AdvisoryUrl |"
                    $mdTable += "|---------|---------|-----------------|----------|-------------|"
                    foreach ($item in $vulnerabilitiesFound) {
                        $mdTable += "| $($item.Project) | $($item.Package) | $($item.ResolvedVersion) | $($item.Severity) | $($item.AdvisoryUrl) |"
                    }
                }
            }
            else {
                $mdTable = @()
                $mdTable += "| Project | Framework | Package | ResolvedVersion | PackageType | Severity | AdvisoryUrl |"
                $mdTable += "|---------|-----------|---------|-----------------|-------------|----------|-------------|"
                foreach ($item in $vulnerabilitiesFound) {
                    $mdTable += "| $($item.Project) | $($item.Framework) | $($item.Package) | $($item.ResolvedVersion) | $($item.PackageType) | $($item.Severity) | $($item.AdvisoryUrl) |"
                }
            }
            $output = $mdTable -join "`n"
        }
        else {
            $output = "No vulnerabilities found."
        }
    }

    # Generate and prepend a professional title if enabled.
    if ($GenerateTitle) {
        if ($vulnerabilitiesFound.Count -eq 0) {
            # If no vulnerabilities, compute project list from the JSON input.
            $allProjects = $result.projects | ForEach-Object { [System.IO.Path]::GetFileNameWithoutExtension($_.path) } | Sort-Object -Unique
            if ($ProjectWhitelist -or $ProjectBlacklist) {
                $filteredProjects = $allProjects | Where-Object {
                    if ($ProjectWhitelist -and ($ProjectWhitelist -contains $_)) {
                        $true
                    }
                    elseif ($ProjectBlacklist -and ($ProjectBlacklist -contains $_)) {
                        $false
                    }
                    else {
                        $true
                    }
                }
            }
            else {
                $filteredProjects = $allProjects
            }
            $projectsForTitle = $filteredProjects
        }
        else {
            $projectsForTitle = $vulnerabilitiesFound | Select-Object -ExpandProperty Project -Unique | Sort-Object
        }
        if ($projectsForTitle.Count -eq 0) {
            $projectsStr = "None"
        }
        else {
            $projectsStr = $projectsForTitle -join ", "
        }
        $defaultTitle = "Vulnerabilities Report for Projects: $projectsStr"
        
        if ($OutputFormat -eq "markdown") {
            if ([string]::IsNullOrEmpty($SetMarkDownTitle)) {
                $titleText = "## $defaultTitle`n`n"
            }
            else {
                $titleText = "## $SetMarkDownTitle`n`n"
            }
        }
        else {
            $underline = "-" * $defaultTitle.Length
            $titleText = "$defaultTitle`n$underline`n`n"
        }
        $output = $titleText + $output
    }

    # Write output to file if specified; otherwise, output to the pipeline.
    if ($OutputFile) {
        $OutputFile = $OutputFile -replace '[\\/]', [System.IO.Path]::DirectorySeparatorChar
        try {
            # Extract the directory from the output file path.
            $outputDir = Split-Path -Path $OutputFile -Parent
            
            # If the directory does not exist, create it.
            if (-not (Test-Path -Path $outputDir)) {
                New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
                Write-Verbose "Created directory: $outputDir"
            }
            
            # Write the output content to the file.
            Set-Content -Path $OutputFile -Value $output -Force
            Write-Verbose "Output written to $OutputFile"
        }
        catch {
            Write-Error "Failed to write output to file: $_"
        }
    }
    else {
        Write-Output $output
    }

    # Exit behavior: if vulnerabilities are found and ExitOnVulnerability is enabled, exit with error code 1.
    if ($vulnerabilitiesFound.Count -gt 0 -and $ExitOnVulnerability) {
        Write-Host "Vulnerabilities detected. Exiting with error code 1." -ForegroundColor Red
        exit 1
    }
    elseif ($vulnerabilitiesFound.Count -gt 0) {
        Write-Host "Vulnerabilities detected, but not exiting due to configuration." -ForegroundColor Yellow
    }
}

function New-DotnetDeprecatedReport {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, HelpMessage = "Array of JSON strings output from dotnet list command with the '--deprecated' flag.")]
        [string[]]$jsonInput,

        [Parameter(Mandatory = $false, HelpMessage = "Optional file path to save the output.")]
        [string]$OutputFile,

        [Parameter(Mandatory = $false, HelpMessage = "Output format: 'text' or 'markdown'. Defaults to 'text'.")]
        [ValidateSet("text", "markdown")]
        [string]$OutputFormat = "text",

        [Parameter(Mandatory = $false, HelpMessage = "When set to true, the function exits with error code 1 if any deprecated package is found. Defaults to false.")]
        [bool]$ExitOnDeprecated = $false,

        [Parameter(Mandatory = $false, HelpMessage = "When set to true, aggregates the output by grouping on Project, Package, and ResolvedVersion (and optionally PackageType). Defaults to true.")]
        [bool]$Aggregate = $true,

        [Parameter(Mandatory = $false, HelpMessage = "When set to true, transitive packages are ignored. Defaults to true.")]
        [bool]$IgnoreTransitivePackages = $true,

        [Parameter(Mandatory = $false, HelpMessage = "When set to true, the aggregated output includes PackageType. Defaults to false.")]
        [bool]$IncludePackageType = $false,

        [Parameter(Mandatory = $false, HelpMessage = "When set to true, a professional title is generated and prepended to the output. Defaults to true.")]
        [bool]$GenerateTitle = $true,

        [Parameter(Mandatory = $false, HelpMessage = "Overrides the generated markdown title with a custom title. Only applied when OutputFormat is markdown.")]
        [string]$SetMarkDownTitle,

        [Parameter(Mandatory = $false, HelpMessage = "Array of ProjectNames to always include in the output.")]
        [string[]]$ProjectWhitelist,

        [Parameter(Mandatory = $false, HelpMessage = "Array of ProjectNames to exclude from the output unless they are also in the whitelist.")]
        [string[]]$ProjectBlacklist
    )

    <#
    .SYNOPSIS
    Generates a professional deprecation report from JSON input output by the dotnet list command with the '--deprecated' flag.
 
    .DESCRIPTION
    Processes JSON input from the dotnet list command to gather deprecation details for each project's frameworks and packages.
    Both top-level and, optionally, transitive packages are processed if they contain deprecation reasons.
    The report aggregates data (grouping by Project, Package, ResolvedVersion, and optionally PackageType) and filters by project whitelist/blacklist.
    Output is generated in text or markdown format with an optional professional title.
    Optionally, if ExitOnDeprecated is enabled and any deprecated package is found, the function exits with error code 1.
 
    .PARAMETER jsonInput
    Array of JSON strings output from the dotnet list command with the '--deprecated' flag.
 
    .PARAMETER OutputFile
    Optional file path to save the output.
 
    .PARAMETER OutputFormat
    Specifies the output format: 'text' or 'markdown'. Defaults to 'text'.
 
    .PARAMETER ExitOnDeprecated
    When set to true, the function exits with error code 1 if any deprecated package is found. Defaults to false.
 
    .PARAMETER Aggregate
    When set to true, aggregates the output by grouping on Project, Package, and ResolvedVersion (and optionally PackageType). Defaults to true.
 
    .PARAMETER IgnoreTransitivePackages
    When set to true, transitive packages are ignored. Defaults to true.
 
    .PARAMETER IncludePackageType
    When set to true, the aggregated output includes PackageType. Defaults to false.
 
    .PARAMETER GenerateTitle
    When set to true, a professional title including project names is generated and prepended to the output. Defaults to true.
 
    .PARAMETER SetMarkDownTitle
    Overrides the generated markdown title with a custom title. Only applied when OutputFormat is markdown.
 
    .PARAMETER ProjectWhitelist
    Array of ProjectNames to always include in the output.
 
    .PARAMETER ProjectBlacklist
    Array of ProjectNames to exclude from the output unless they are also in the whitelist.
 
    .EXAMPLE
    New-DotnetDeprecatedReport -jsonInput $jsonData -OutputFormat markdown -ExitOnDeprecated $true
 
    .EXAMPLE
    New-DotnetDeprecatedReport -jsonInput $jsonData -OutputFile "deprecated_report.txt"
 
    .EXAMPLE
    New-DotnetDeprecatedReport -jsonInput $jsonData -SetMarkDownTitle "Custom Deprecated Packages Report"
    #>


    try {
        $result = $jsonInput | ConvertFrom-Json
    }
    catch {
        Write-Error "Failed to parse JSON input from dotnet list command."
        exit 1
    }

    $deprecatedFound = @()

    # Process each project and its frameworks.
    foreach ($project in $result.projects) {
        if ($project.frameworks) {
            foreach ($framework in $project.frameworks) {
                # Process top-level packages.
                if ($framework.topLevelPackages) {
                    foreach ($package in $framework.topLevelPackages) {
                        if ($package.deprecationReasons -and $package.deprecationReasons.Count -gt 0) {
                            $deprecatedFound += [PSCustomObject]@{
                                Project            = [System.IO.Path]::GetFileNameWithoutExtension($project.path)
                                Framework          = $framework.framework
                                Package            = $package.id
                                ResolvedVersion    = $package.resolvedVersion
                                DeprecationReasons = ($package.deprecationReasons -join ", ")
                                PackageType        = "TopLevel"
                            }
                        }
                    }
                }
                # Process transitive packages if not ignored.
                if (-not $IgnoreTransitivePackages -and $framework.transitivePackages) {
                    foreach ($package in $framework.transitivePackages) {
                        if ($package.deprecationReasons -and $package.deprecationReasons.Count -gt 0) {
                            $deprecatedFound += [PSCustomObject]@{
                                Project            = [System.IO.Path]::GetFileNameWithoutExtension($project.path)
                                Framework          = $framework.framework
                                Package            = $package.id
                                ResolvedVersion    = $package.resolvedVersion
                                DeprecationReasons = ($package.deprecationReasons -join ", ")
                                PackageType        = "Transitive"
                            }
                        }
                    }
                }
            }
        }
    }

    # Filter deprecated packages by project whitelist and blacklist.
    if ($ProjectWhitelist -or $ProjectBlacklist) {
        $deprecatedFound = $deprecatedFound | Where-Object {
            if ($ProjectWhitelist -and ($ProjectWhitelist -contains $_.Project)) {
                $true
            }
            elseif ($ProjectBlacklist -and ($ProjectBlacklist -contains $_.Project)) {
                $false
            }
            else {
                $true
            }
        }
    }

    # Aggregate deprecated packages if enabled.
    if ($Aggregate) {
        if ($IncludePackageType) {
            $deprecatedFound = $deprecatedFound | Group-Object -Property Project, Package, ResolvedVersion, PackageType | ForEach-Object {
                [PSCustomObject]@{
                    Project            = $_.Group[0].Project
                    Package            = $_.Group[0].Package
                    ResolvedVersion    = $_.Group[0].ResolvedVersion
                    PackageType        = $_.Group[0].PackageType
                    DeprecationReasons = $_.Group[0].DeprecationReasons
                }
            }
        }
        else {
            $deprecatedFound = $deprecatedFound | Group-Object -Property Project, Package, ResolvedVersion | ForEach-Object {
                [PSCustomObject]@{
                    Project            = $_.Group[0].Project
                    Package            = $_.Group[0].Package
                    ResolvedVersion    = $_.Group[0].ResolvedVersion
                    DeprecationReasons = $_.Group[0].DeprecationReasons
                }
            }
        }
    }

    # Generate report output based on the specified format.
    if ($OutputFormat -eq "text") {
        if ($deprecatedFound.Count -gt 0) {
            if ($Aggregate) {
                if ($IncludePackageType) {
                    $output = $deprecatedFound | Format-Table Project, Package, ResolvedVersion, PackageType, DeprecationReasons -AutoSize | Out-String
                }
                else {
                    $output = $deprecatedFound | Format-Table Project, Package, ResolvedVersion, DeprecationReasons -AutoSize | Out-String
                }
            }
            else {
                $output = $deprecatedFound | Format-Table Project, Framework, Package, ResolvedVersion, PackageType, DeprecationReasons -AutoSize | Out-String
            }
        }
        else {
            $output = "No deprecated packages found."
        }
    }
    elseif ($OutputFormat -eq "markdown") {
        if ($deprecatedFound.Count -gt 0) {
            if ($Aggregate) {
                if ($IncludePackageType) {
                    $mdTable = @()
                    $mdTable += "| Project | Package | ResolvedVersion | PackageType | DeprecationReasons |"
                    $mdTable += "|---------|---------|-----------------|-------------|--------------------|"
                    foreach ($item in $deprecatedFound) {
                        $mdTable += "| $($item.Project) | $($item.Package) | $($item.ResolvedVersion) | $($item.PackageType) | $($item.DeprecationReasons) |"
                    }
                }
                else {
                    $mdTable = @()
                    $mdTable += "| Project | Package | ResolvedVersion | DeprecationReasons |"
                    $mdTable += "|---------|---------|-----------------|--------------------|"
                    foreach ($item in $deprecatedFound) {
                        $mdTable += "| $($item.Project) | $($item.Package) | $($item.ResolvedVersion) | $($item.DeprecationReasons) |"
                    }
                }
            }
            else {
                $mdTable = @()
                $mdTable += "| Project | Framework | Package | ResolvedVersion | PackageType | DeprecationReasons |"
                $mdTable += "|---------|-----------|---------|-----------------|-------------|--------------------|"
                foreach ($item in $deprecatedFound) {
                    $mdTable += "| $($item.Project) | $($item.Framework) | $($item.Package) | $($item.ResolvedVersion) | $($item.PackageType) | $($item.DeprecationReasons) |"
                }
            }
            $output = $mdTable -join "`n"
        }
        else {
            $output = "No deprecated packages found."
        }
    }

    # Generate and prepend a professional title if enabled.
    if ($GenerateTitle) {
        if ($deprecatedFound.Count -eq 0) {
            # If no deprecated packages, compute project list from the JSON input.
            $allProjects = $result.projects | ForEach-Object { [System.IO.Path]::GetFileNameWithoutExtension($_.path) } | Sort-Object -Unique
            if ($ProjectWhitelist -or $ProjectBlacklist) {
                $filteredProjects = $allProjects | Where-Object {
                    if ($ProjectWhitelist -and ($ProjectWhitelist -contains $_)) {
                        $true
                    }
                    elseif ($ProjectBlacklist -and ($ProjectBlacklist -contains $_)) {
                        $false
                    }
                    else {
                        $true
                    }
                }
            }
            else {
                $filteredProjects = $allProjects
            }
            $projectsForTitle = $filteredProjects
        }
        else {
            $projectsForTitle = $deprecatedFound | Select-Object -ExpandProperty Project -Unique | Sort-Object
        }
        if ($projectsForTitle.Count -eq 0) {
            $projectsStr = "None"
        }
        else {
            $projectsStr = $projectsForTitle -join ", "
        }
        $defaultTitle = "Deprecated Packages Report for Projects: $projectsStr"
        
        if ($OutputFormat -eq "markdown") {
            if ([string]::IsNullOrEmpty($SetMarkDownTitle)) {
                $titleText = "## $defaultTitle`n`n"
            }
            else {
                $titleText = "## $SetMarkDownTitle`n`n"
            }
        }
        else {
            $underline = "-" * $defaultTitle.Length
            $titleText = "$defaultTitle`n$underline`n`n"
        }
        $output = $titleText + $output
    }

    # Write output to file if specified; otherwise, output to the pipeline.
    if ($OutputFile) {
        $OutputFile = $OutputFile -replace '[\\/]', [System.IO.Path]::DirectorySeparatorChar
        try {
            # Extract the directory from the output file path.
            $outputDir = Split-Path -Path $OutputFile -Parent
            
            # If the directory does not exist, create it.
            if (-not (Test-Path -Path $outputDir)) {
                New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
                Write-Verbose "Created directory: $outputDir"
            }
            
            # Write the output content to the file.
            Set-Content -Path $OutputFile -Value $output -Force
            Write-Verbose "Output written to $OutputFile"
        }
        catch {
            Write-Error "Failed to write output to file: $_"
        }
    }
    else {
        Write-Output $output
    }

    # Exit behavior: if deprecated packages are found and ExitOnDeprecated is enabled, exit with error code 1.
    if ($deprecatedFound.Count -gt 0 -and $ExitOnDeprecated) {
        Write-Host "Deprecated packages detected. Exiting with error code 1." -ForegroundColor Red
        exit 1
    }
    elseif ($deprecatedFound.Count -gt 0) {
        Write-Host "Deprecated packages detected, but not exiting due to configuration." -ForegroundColor Yellow
    }
}

function New-DotnetOutdatedReport {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, HelpMessage = "Array of JSON strings output from dotnet list command with the '--outdated' flag.")]
        [string[]]$jsonInput,

        [Parameter(Mandatory = $false, HelpMessage = "Optional file path to save the output.")]
        [string]$OutputFile,

        [Parameter(Mandatory = $false, HelpMessage = "Output format: 'text' or 'markdown'. Defaults to 'text'.")]
        [ValidateSet("text", "markdown")]
        [string]$OutputFormat = "text",

        [Parameter(Mandatory = $false, HelpMessage = "When set to true, the function exits with error code 1 if any outdated package is found. Defaults to false.")]
        [bool]$ExitOnOutdated = $false,

        [Parameter(Mandatory = $false, HelpMessage = "When set to true, aggregates the output by grouping on Project, Package, ResolvedVersion and LatestVersion (and optionally PackageType). Defaults to true.")]
        [bool]$Aggregate = $true,

        [Parameter(Mandatory = $false, HelpMessage = "When set to true, transitive packages are ignored. Defaults to true.")]
        [bool]$IgnoreTransitivePackages = $true,

        [Parameter(Mandatory = $false, HelpMessage = "When set to true, the aggregated output includes PackageType. Defaults to false.")]
        [bool]$IncludePackageType = $false,

        [Parameter(Mandatory = $false, HelpMessage = "When set to true, a professional title is generated and prepended to the output. Defaults to true.")]
        [bool]$GenerateTitle = $true,

        [Parameter(Mandatory = $false, HelpMessage = "Overrides the generated markdown title with a custom title. Only applied when OutputFormat is markdown.")]
        [string]$SetMarkDownTitle,

        [Parameter(Mandatory = $false, HelpMessage = "Array of ProjectNames to always include in the output.")]
        [string[]]$ProjectWhitelist,

        [Parameter(Mandatory = $false, HelpMessage = "Array of ProjectNames to exclude from the output unless they are also in the whitelist.")]
        [string[]]$ProjectBlacklist
    )

    <#
    .SYNOPSIS
    Generates a professional outdated packages report from JSON input output by the dotnet list command with the '--outdated' flag.
 
    .DESCRIPTION
    Processes JSON input from the dotnet list command to identify outdated packages for each project's frameworks.
    A package is considered outdated if its resolvedVersion does not match its latestVersion.
    Both top-level and (optionally) transitive packages are processed.
    The report aggregates data (grouping by Project, Package, ResolvedVersion, LatestVersion and optionally PackageType)
    and filters by project whitelist/blacklist. The output is generated in text or markdown format with a professional title.
    Optionally, if ExitOnOutdated is enabled and any outdated package is found, the function exits with error code 1.
 
    .PARAMETER jsonInput
    Array of JSON strings output from the dotnet list command with the '--outdated' flag.
 
    .PARAMETER OutputFile
    Optional file path to save the output.
 
    .PARAMETER OutputFormat
    Specifies the output format: 'text' or 'markdown'. Defaults to 'text'.
 
    .PARAMETER ExitOnOutdated
    When set to true, the function exits with error code 1 if any outdated package is found. Defaults to false.
 
    .PARAMETER Aggregate
    When set to true, aggregates the output by grouping on Project, Package, ResolvedVersion, and LatestVersion (and optionally PackageType). Defaults to true.
 
    .PARAMETER IgnoreTransitivePackages
    When set to true, transitive packages are ignored. Defaults to true.
 
    .PARAMETER IncludePackageType
    When set to true, the aggregated output includes PackageType. Defaults to false.
 
    .PARAMETER GenerateTitle
    When set to true, a professional title including project names is generated and prepended to the output. Defaults to true.
 
    .PARAMETER SetMarkDownTitle
    Overrides the generated markdown title with a custom title. Only applied when OutputFormat is markdown.
 
    .PARAMETER ProjectWhitelist
    Array of ProjectNames to always include in the output.
 
    .PARAMETER ProjectBlacklist
    Array of ProjectNames to exclude from the output unless they are also in the whitelist.
 
    .EXAMPLE
    New-DotnetOutdatedReport -jsonInput $jsonData -OutputFormat markdown -ExitOnOutdated $true
 
    .EXAMPLE
    New-DotnetOutdatedReport -jsonInput $jsonData -OutputFile "outdated_report.txt"
 
    .EXAMPLE
    New-DotnetOutdatedReport -jsonInput $jsonData -SetMarkDownTitle "Custom Outdated Packages Report"
    #>


    try {
        $result = $jsonInput | ConvertFrom-Json
    }
    catch {
        Write-Error "Failed to parse JSON input from dotnet list command."
        exit 1
    }

    $outdatedFound = @()

    # Process each project and its frameworks.
    foreach ($project in $result.projects) {
        if ($project.frameworks) {
            foreach ($framework in $project.frameworks) {
                # Process top-level packages.
                if ($framework.topLevelPackages) {
                    foreach ($package in $framework.topLevelPackages) {
                        if ($package.latestVersion -and ($package.resolvedVersion -ne $package.latestVersion)) {
                            $outdatedFound += [PSCustomObject]@{
                                Project         = [System.IO.Path]::GetFileNameWithoutExtension($project.path)
                                Framework       = $framework.framework
                                Package         = $package.id
                                ResolvedVersion = $package.resolvedVersion
                                LatestVersion   = $package.latestVersion
                                PackageType     = "TopLevel"
                            }
                        }
                    }
                }
                # Process transitive packages if not ignored.
                if (-not $IgnoreTransitivePackages -and $framework.transitivePackages) {
                    foreach ($package in $framework.transitivePackages) {
                        if ($package.latestVersion -and ($package.resolvedVersion -ne $package.latestVersion)) {
                            $outdatedFound += [PSCustomObject]@{
                                Project         = [System.IO.Path]::GetFileNameWithoutExtension($project.path)
                                Framework       = $framework.framework
                                Package         = $package.id
                                ResolvedVersion = $package.resolvedVersion
                                LatestVersion   = $package.latestVersion
                                PackageType     = "Transitive"
                            }
                        }
                    }
                }
            }
        }
    }

    # Filter outdated packages by project whitelist and blacklist.
    if ($ProjectWhitelist -or $ProjectBlacklist) {
        $outdatedFound = $outdatedFound | Where-Object {
            if ($ProjectWhitelist -and ($ProjectWhitelist -contains $_.Project)) {
                $true
            }
            elseif ($ProjectBlacklist -and ($ProjectBlacklist -contains $_.Project)) {
                $false
            }
            else {
                $true
            }
        }
    }

    # Aggregate outdated packages if enabled.
    if ($Aggregate) {
        if ($IncludePackageType) {
            $outdatedFound = $outdatedFound | Group-Object -Property Project, Package, ResolvedVersion, LatestVersion, PackageType | ForEach-Object {
                [PSCustomObject]@{
                    Project         = $_.Group[0].Project
                    Package         = $_.Group[0].Package
                    ResolvedVersion = $_.Group[0].ResolvedVersion
                    LatestVersion   = $_.Group[0].LatestVersion
                    PackageType     = $_.Group[0].PackageType
                }
            }
        }
        else {
            $outdatedFound = $outdatedFound | Group-Object -Property Project, Package, ResolvedVersion, LatestVersion | ForEach-Object {
                [PSCustomObject]@{
                    Project         = $_.Group[0].Project
                    Package         = $_.Group[0].Package
                    ResolvedVersion = $_.Group[0].ResolvedVersion
                    LatestVersion   = $_.Group[0].LatestVersion
                }
            }
        }
    }

    # Generate report output based on the specified format.
    if ($OutputFormat -eq "text") {
        if ($outdatedFound.Count -gt 0) {
            if ($Aggregate) {
                if ($IncludePackageType) {
                    $output = $outdatedFound | Format-Table -AutoSize | Out-String
                }
                else {
                    $output = $outdatedFound | Format-Table Project, Package, ResolvedVersion, LatestVersion -AutoSize | Out-String
                }
            }
            else {
                $output = $outdatedFound | Format-Table Project, Framework, Package, ResolvedVersion, LatestVersion, PackageType -AutoSize | Out-String
            }
        }
        else {
            $output = "No outdated packages found."
        }
    }
    elseif ($OutputFormat -eq "markdown") {
        if ($outdatedFound.Count -gt 0) {
            if ($Aggregate) {
                if ($IncludePackageType) {
                    $mdTable = @()
                    $mdTable += "| Project | Package | ResolvedVersion | LatestVersion | PackageType |"
                    $mdTable += "|---------|---------|-----------------|---------------|-------------|"
                    foreach ($item in $outdatedFound) {
                        $mdTable += "| $($item.Project) | $($item.Package) | $($item.ResolvedVersion) | $($item.LatestVersion) | $($item.PackageType) |"
                    }
                }
                else {
                    $mdTable = @()
                    $mdTable += "| Project | Package | ResolvedVersion | LatestVersion |"
                    $mdTable += "|---------|---------|-----------------|---------------|"
                    foreach ($item in $outdatedFound) {
                        $mdTable += "| $($item.Project) | $($item.Package) | $($item.ResolvedVersion) | $($item.LatestVersion) |"
                    }
                }
            }
            else {
                $mdTable = @()
                $mdTable += "| Project | Framework | Package | ResolvedVersion | LatestVersion | PackageType |"
                $mdTable += "|---------|-----------|---------|-----------------|---------------|-------------|"
                foreach ($item in $outdatedFound) {
                    $mdTable += "| $($item.Project) | $($item.Framework) | $($item.Package) | $($item.ResolvedVersion) | $($item.LatestVersion) | $($item.PackageType) |"
                }
            }
            $output = $mdTable -join "`n"
        }
        else {
            $output = "No outdated packages found."
        }
    }

    # Generate and prepend a professional title if enabled.
    if ($GenerateTitle) {
        if ($outdatedFound.Count -eq 0) {
            # If no outdated packages, compute project list from the JSON input.
            $allProjects = $result.projects | ForEach-Object { [System.IO.Path]::GetFileNameWithoutExtension($_.path) } | Sort-Object -Unique
            if ($ProjectWhitelist -or $ProjectBlacklist) {
                $filteredProjects = $allProjects | Where-Object {
                    if ($ProjectWhitelist -and ($ProjectWhitelist -contains $_)) {
                        $true
                    }
                    elseif ($ProjectBlacklist -and ($ProjectBlacklist -contains $_)) {
                        $false
                    }
                    else {
                        $true
                    }
                }
            }
            else {
                $filteredProjects = $allProjects
            }
            $projectsForTitle = $filteredProjects
        }
        else {
            $projectsForTitle = $outdatedFound | Select-Object -ExpandProperty Project -Unique | Sort-Object
        }
        if ($projectsForTitle.Count -eq 0) {
            $projectsStr = "None"
        }
        else {
            $projectsStr = $projectsForTitle -join ", "
        }
        $defaultTitle = "Outdated Packages Report for Projects: $projectsStr"
        
        if ($OutputFormat -eq "markdown") {
            if ([string]::IsNullOrEmpty($SetMarkDownTitle)) {
                $titleText = "## $defaultTitle`n`n"
            }
            else {
                $titleText = "## $SetMarkDownTitle`n`n"
            }
        }
        else {
            $underline = "-" * $defaultTitle.Length
            $titleText = "$defaultTitle`n$underline`n`n"
        }
        $output = $titleText + $output
    }

    # Write output to file if specified; otherwise, output to the pipeline.
    if ($OutputFile) {
        $OutputFile = $OutputFile -replace '[\\/]', [System.IO.Path]::DirectorySeparatorChar
        try {
            # Extract the directory from the output file path.
            $outputDir = Split-Path -Path $OutputFile -Parent
            
            # If the directory does not exist, create it.
            if (-not (Test-Path -Path $outputDir)) {
                New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
                Write-Verbose "Created directory: $outputDir"
            }
            
            # Write the output content to the file.
            Set-Content -Path $OutputFile -Value $output -Force
            Write-Verbose "Output written to $OutputFile"
        }
        catch {
            Write-Error "Failed to write output to file: $_"
        }
    }
    else {
        Write-Output $output
    }

    # Exit behavior: if outdated packages are found and ExitOnOutdated is enabled, exit with error code 1.
    if ($outdatedFound.Count -gt 0 -and $ExitOnOutdated) {
        Write-Host "Outdated packages detected. Exiting with error code 1." -ForegroundColor Red
        exit 1
    }
    elseif ($outdatedFound.Count -gt 0) {
        Write-Host "Outdated packages detected, but not exiting due to configuration." -ForegroundColor Yellow
    }
}