Modules/businessdev.ALbuild.Core/Public/Clear-ALbuildCache.ps1

function Clear-ALbuildCache {
    <#
    .SYNOPSIS
        Prunes old, unused ALbuild cache content to reclaim disk space on a build agent.
 
    .DESCRIPTION
        Build agents accumulate large caches that ALbuild never trims on its own: the extracted
        artifact/symbol cache (ArtifactCacheFolder), the dependency package cache (PackageCacheFolder)
        and the Business Central artifact cache (BcArtifactCacheFolder) - the last two of which grow a
        new sub-folder per BC version and per package, unbounded. This cmdlet removes cache entries that
        have not been written to within a retention window, keeping recent ones.
 
        The cache locations are read from Get-ALbuildConfig, so it prunes exactly the folders the tasks
        use (including any relocated to a roomier drive). Each cache's immediate child entries are the
        pruning unit; an entry is removed when its last-write age exceeds -KeepDays, except the newest
        -KeepLatest entries per cache are always retained as a safety floor. Age is measured by
        LastWriteTime (last-access time is unreliable - Windows disables it by default).
 
        Supports -WhatIf/-Confirm (it is destructive), reports the space reclaimed per cache, and logs
        every removal so a scheduled cleanup is auditable from the pipeline log. With -IncludeDocker it
        also prunes unused Docker images and stopped containers older than the same window.
 
    .PARAMETER KeepDays
        Retention window in days. Cache entries not written within this many days are removed. Default 30.
 
    .PARAMETER KeepLatest
        Always keep at least this many most-recently-written entries per cache, regardless of age
        (a safety floor so a cache is never emptied). Default 0.
 
    .PARAMETER Cache
        Which caches to prune: All (default), Artifacts, Packages or BcArtifacts.
 
    .PARAMETER IncludeDocker
        Also run 'docker image prune' (unused images) and 'docker container prune' (stopped containers)
        filtered to the same -KeepDays window. Skipped with a note if the docker CLI is unavailable.
 
    .EXAMPLE
        Clear-ALbuildCache -WhatIf
        Show what a default 30-day prune would remove, without deleting anything.
 
    .EXAMPLE
        Clear-ALbuildCache -KeepDays 14 -KeepLatest 2 -IncludeDocker
        Keep 14 days (but never fewer than the 2 newest per cache) and also prune old Docker content.
 
    .OUTPUTS
        PSCustomObject: per-cache Removed count / FreedBytes / FreedText plus a TotalFreedBytes/Text.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType([PSCustomObject])]
    param(
        [ValidateRange(0, [int]::MaxValue)] [int] $KeepDays = 30,
        [ValidateRange(0, [int]::MaxValue)] [int] $KeepLatest = 0,
        [ValidateSet('All', 'Artifacts', 'Packages', 'BcArtifacts')] [string] $Cache = 'All',
        [switch] $IncludeDocker
    )

    $config = Get-ALbuildConfig
    $targets = [ordered]@{}
    if ($Cache -in @('All', 'Artifacts')) { $targets['Artifacts'] = $config.ArtifactCacheFolder }
    if ($Cache -in @('All', 'Packages')) { $targets['Packages'] = $config.PackageCacheFolder }
    if ($Cache -in @('All', 'BcArtifacts')) { $targets['BcArtifacts'] = $config.BcArtifactCacheFolder }

    $cutoff = (Get-Date).AddDays(-$KeepDays)
    Write-ALbuildLog "Pruning ALbuild caches: keep entries written on/after $($cutoff.ToString('yyyy-MM-dd')) (KeepDays=$KeepDays), always keep the newest $KeepLatest per cache."

    $humanize = {
        param([long] $Bytes)
        $units = 'B', 'KB', 'MB', 'GB', 'TB'; $i = 0; $n = [double]$Bytes
        while ($n -ge 1024 -and $i -lt $units.Count - 1) { $n /= 1024; $i++ }
        '{0:0.##} {1}' -f $n, $units[$i]
    }
    $folderSize = {
        param([string] $Path)
        try { [long](Get-ChildItem -LiteralPath $Path -Recurse -File -Force -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum }
        catch { [long]0 }
    }

    $results = [System.Collections.Generic.List[object]]::new()
    $totalFreed = [long]0

    foreach ($name in $targets.Keys) {
        $root = $targets[$name]
        if ([string]::IsNullOrWhiteSpace($root) -or -not (Test-Path -LiteralPath $root)) {
            Write-ALbuildLog -Level Verbose " [$name] '$root' does not exist; nothing to prune."
            $results.Add([PSCustomObject]@{ Cache = $name; Path = $root; Removed = 0; FreedBytes = [long]0; FreedText = '0 B' })
            continue
        }

        $entries = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending)
        # Safety floor: never consider the newest -KeepLatest entries for removal.
        $candidates = if ($KeepLatest -gt 0 -and $entries.Count -gt $KeepLatest) { $entries[$KeepLatest..($entries.Count - 1)] } elseif ($KeepLatest -gt 0) { @() } else { $entries }
        $stale = @($candidates | Where-Object { $_.LastWriteTime -lt $cutoff })

        $removed = 0; $freed = [long]0
        foreach ($dir in $stale) {
            $size = & $folderSize $dir.FullName
            if ($PSCmdlet.ShouldProcess($dir.FullName, "Remove cached entry (last modified $($dir.LastWriteTime.ToString('yyyy-MM-dd')), $(& $humanize $size))")) {
                try {
                    Remove-Item -LiteralPath $dir.FullName -Recurse -Force -ErrorAction Stop
                    $removed++; $freed += $size
                    Write-ALbuildLog " [$name] removed '$($dir.Name)' (modified $($dir.LastWriteTime.ToString('yyyy-MM-dd')), freed $(& $humanize $size))."
                }
                catch { Write-ALbuildLog -Level Warning " [$name] could not remove '$($dir.FullName)': $($_.Exception.Message)" }
            }
            else { $freed += $size }   # -WhatIf: still report what would be freed
        }
        $totalFreed += $freed
        $entryWord = if ($stale.Count -eq 1) { 'entry' } else { 'entries' }
        Write-ALbuildLog -Level Success " [$name] $($stale.Count) stale $entryWord of $($entries.Count); freed $(& $humanize $freed)."
        $results.Add([PSCustomObject]@{ Cache = $name; Path = $root; Removed = $removed; FreedBytes = $freed; FreedText = (& $humanize $freed) })
    }

    if ($IncludeDocker) {
        if (Get-Command -Name docker -ErrorAction SilentlyContinue) {
            $until = "$([int]($KeepDays * 24))h"
            if ($PSCmdlet.ShouldProcess("docker (until=$until)", 'Prune unused images and stopped containers')) {
                Write-ALbuildLog " [Docker] pruning stopped containers and unused images older than $until..."
                try {
                    (& docker container prune -f --filter "until=$until" 2>&1) | ForEach-Object { Write-ALbuildLog -Level Verbose " $_" }
                    (& docker image prune -a -f --filter "until=$until" 2>&1) | ForEach-Object { Write-ALbuildLog " $_" }
                }
                catch { Write-ALbuildLog -Level Warning " [Docker] prune failed: $($_.Exception.Message)" }
            }
        }
        else { Write-ALbuildLog -Level Warning ' [Docker] -IncludeDocker requested but the docker CLI was not found; skipping.' }
    }

    Write-ALbuildLog -Level Success "ALbuild cache prune complete: reclaimed $(& $humanize $totalFreed) across $($results.Count) cache(s)."
    [PSCustomObject]@{
        Caches          = $results.ToArray()
        TotalFreedBytes = $totalFreed
        TotalFreedText  = (& $humanize $totalFreed)
        KeepDays        = $KeepDays
        KeepLatest      = $KeepLatest
    }
}