public/Remove-BucketObject.ps1

function Remove-BucketObject {
    <#
    .SYNOPSIS
    Removes an object from a bucket.
    .DESCRIPTION
    Deletes a specific object file from a bucket directory. Use -Key to remove a single
    object, -All to clear the entire bucket, or -Match/-Filter for bulk deletion.
    .PARAMETER InputObject
    The object to remove. Accepts pipeline input. If it has _BucketName and _BucketKey metadata,
    bucket and key are auto-resolved. Otherwise -Bucket and -Key are required.
    .PARAMETER Bucket
    Name of the bucket containing the object(s) to remove.
    .PARAMETER Path
    Root directory for bucket storage. Default: $HOME/.buckets.
    .PARAMETER Key
    Object key to remove. Looks for both JSON and binary files. Case-insensitive.
    .PARAMETER All
    Remove all objects from the bucket.
    .PARAMETER Match
    Hashtable of property-value pairs for bulk deletion. All pairs must match. Supports $null values.
    .PARAMETER Filter
    ScriptBlock for custom bulk deletion. Use $_ to reference object properties.
    .PARAMETER PassThru
    Return metadata for removed objects.
    .PARAMETER Quiet
    Suppress progress output.
    .EXAMPLE
    Remove-BucketObject -Bucket logs -Key "log-003"
    .EXAMPLE
    Remove-BucketObject -Bucket temp -All -PassThru
    .EXAMPLE
    Remove-BucketObject -Bucket users -Match @{ Active = $false } -PassThru
    .EXAMPLE
    Remove-BucketObject -Bucket orders -Filter { $_.Status -eq "cancelled" }
    .EXAMPLE
    Remove-BucketObject -Bucket users -Key "Charlie" -WhatIf
    .EXAMPLE
    Get-BucketObject -Bucket users -Match @{Role="guest"} | Remove-BucketObject
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium', DefaultParameterSetName = 'ByKey')]
    param(
        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)][PSObject]$InputObject,
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)][Alias('_BucketName')][string]$Bucket,
        [string]$Path,
        [Parameter(ParameterSetName = 'ByKey', ValueFromPipelineByPropertyName = $true)][Alias('_BucketKey')][string]$Key,
        [Parameter(ParameterSetName = 'All')][switch]$All,
        [Parameter(ParameterSetName = 'ByFilter')][hashtable]$Match,
        [Parameter(ParameterSetName = 'ByFilter')][scriptblock]$Filter,
        [switch]$PassThru,
        [switch]$Quiet
    )

    begin {
        $removedCount = 0; $lastBucket = ''; $removedKeys = [System.Collections.ArrayList]::new()
        $allProcessed = $false; $filterProcessed = $false

        if ([string]::IsNullOrWhiteSpace($Path)) { $Path = Get-DefaultPath }
        $Path = Resolve-SafePath -Path $Path
    }

    process {
        if ($null -ne $InputObject) {
            $hasMeta = $InputObject.PSObject.Properties['_BucketName'] -and $InputObject.PSObject.Properties['_BucketKey']
            if ($hasMeta) {
                if ([string]::IsNullOrWhiteSpace($Bucket)) { $Bucket = $InputObject._BucketName }
                if ([string]::IsNullOrWhiteSpace($Key)) { $Key = $InputObject._BucketKey }
            }
        }

        $bucketPath = Get-BucketPath -Name $Bucket -Path $Path

        if (-not [System.IO.Directory]::Exists($bucketPath)) {
            Write-Verbose "Bucket '$Bucket' not found at '$bucketPath'"
            return
        }

        if ($All) {
            if ($allProcessed) { return }
            $allProcessed = $true

            $allFiles = @()
            $di = [System.IO.DirectoryInfo]::new($bucketPath)
            $allFiles += @($di.GetFiles("*.json")) + @($di.GetFiles("*.dat"))

            if ($allFiles.Count -eq 0) { Write-Verbose "Bucket '$Bucket' is already empty"; return }

            $bucketSize = ($allFiles | Measure-Object -Property Length -Sum).Sum
            $sizeStr = if ($bucketSize) { "$([math]::Round($bucketSize / 1KB, 2)) KB" } else { "0 KB" }

            if ($WhatIfPreference) {
                Write-Host ""
                Write-Host " What if: Remove all " -NoNewline -ForegroundColor $script:CMuted
                Write-Host $allFiles.Count -NoNewline -ForegroundColor $script:CNum
                Write-Host " object(s) from " -NoNewline -ForegroundColor $script:CMuted
                Write-Host $Bucket -NoNewline -ForegroundColor $script:CPath
                Write-Host " ($sizeStr)" -ForegroundColor $script:CMuted
                Write-Host ""
                return
            }

            $target = "$($allFiles.Count) object(s) from bucket '$Bucket'"
            if ($PSCmdlet.ShouldProcess($target, "Remove-BucketObject")) {
                $allFiles | ForEach-Object { [System.IO.File]::Delete($_.FullName) }
                foreach ($d in $di.GetDirectories()) {
                    if ($d.Name -eq ".buckets") { continue }
                    $remaining = @($d.GetFiles()) + @($d.GetDirectories())
                    if ($remaining.Count -eq 0) { [System.IO.Directory]::Delete($d.FullName) }
                }
            }

            if ($PassThru) {
                foreach ($f in $allFiles) {
                    $relPath = $f.FullName.Substring($bucketPath.Length).TrimStart([System.IO.Path]::DirectorySeparatorChar)
                    $keyOnly = [System.IO.Path]::ChangeExtension($relPath, $null).TrimEnd('.')
                    [PSCustomObject]@{ Bucket = $Bucket; Key = $keyOnly }
                }
            }
            elseif (-not $WhatIfPreference -and -not $Quiet) {
                Write-Host "$Bucket" -NoNewline -ForegroundColor $script:CPath
                Write-Host " · " -NoNewline -ForegroundColor $script:CMuted
                $objLabel = if ($allFiles.Count -eq 1) { "1 object" } else { "$($allFiles.Count) objects" }
                Write-Host $objLabel -NoNewline -ForegroundColor $script:CNum
                Write-Host " removed" -ForegroundColor $script:CMuted
            }
            return
        }

        if (-not [string]::IsNullOrWhiteSpace($Key)) {
            $file = Find-ObjectFile -BucketPath $bucketPath -Key $Key
            if ($null -eq $file) {
                Write-Warning "Object with key '$Key' not found in bucket '$Bucket'"
            }
            elseif ($PSCmdlet.ShouldProcess("object '$Key' from bucket '$Bucket'", "Remove-BucketObject")) {
                if ($PassThru) {
                    [PSCustomObject]@{ Bucket = $Bucket; Key = $Key }
                }
                [System.IO.File]::Delete($file.FullName)
                $parentDir = [System.IO.Path]::GetDirectoryName($file.FullName)
                if ($parentDir -ne $bucketPath -and $parentDir.StartsWith($bucketPath)) {
                    $parentDi = [System.IO.DirectoryInfo]::new($parentDir)
                    $remaining = @($parentDi.GetFiles()) + @($parentDi.GetDirectories())
                    if ($remaining.Count -eq 0) {
                        [System.IO.Directory]::Delete($parentDir)
                    }
                }
                $removedCount++
                $lastBucket = $Bucket
                $null = $removedKeys.Add($Key)
            }
            return
        }

        if ($PSCmdlet.ParameterSetName -eq 'ByFilter') {
            if ($filterProcessed) { return }
            $filterProcessed = $true

            $allFiles = @()
            $di = [System.IO.DirectoryInfo]::new($bucketPath)
            $allFiles += @($di.GetFiles("*.json")) + @($di.GetFiles("*.dat"))

            if ($allFiles.Count -eq 0) { Write-Verbose "Bucket '$Bucket' is already empty"; return }

            $matchedFiles = @()
            $matchedKeys = @()
            foreach ($file in $allFiles) {
                $obj = Read-BucketFile -File $file
                if ($null -eq $obj) { continue }
                if ($Match -and -not (Test-MatchFilter -Object $obj -Match $Match)) { continue }
                if ($Filter) {
                    if ($null -eq ($obj | Where-Object $Filter)) { continue }
                }
                $matchedFiles += $file
                $matchedKeys += [System.IO.Path]::GetFileNameWithoutExtension($file.Name)
            }

            if ($matchedFiles.Count -eq 0) { Write-Verbose "No objects matched the filter criteria in bucket '$Bucket'"; return }

            $matchSize = ($matchedFiles | Measure-Object -Property Length -Sum).Sum
            $sizeStr = if ($matchSize) { "$([math]::Round($matchSize / 1KB, 2)) KB" } else { "0 KB" }

            if ($WhatIfPreference) {
                Write-Host ""
                Write-Host " What if: Remove " -NoNewline -ForegroundColor $script:CMuted
                Write-Host $matchedFiles.Count -NoNewline -ForegroundColor $script:CNum
                Write-Host " matching object(s) from " -NoNewline -ForegroundColor $script:CMuted
                Write-Host $Bucket -NoNewline -ForegroundColor $script:CPath
                Write-Host " ($sizeStr)" -ForegroundColor $script:CMuted
                $showKeys = $matchedKeys | Select-Object -First 5
                foreach ($k in $showKeys) {
                    Write-Host " $k" -ForegroundColor $script:CMuted
                }
                if ($matchedKeys.Count -gt 5) {
                    Write-Host " ... and $($matchedKeys.Count - 5) more" -ForegroundColor $script:CMuted
                }
                Write-Host ""
                return
            }

            if (-not $Quiet) {
                Write-Host ""
                Write-Host " Remove " -NoNewline -ForegroundColor $script:CMuted
                Write-Host $matchedFiles.Count -NoNewline -ForegroundColor $script:CNum
                Write-Host " matching object(s) from " -NoNewline -ForegroundColor $script:CMuted
                Write-Host $Bucket -NoNewline -ForegroundColor $script:CPath
                Write-Host " ($sizeStr)" -ForegroundColor $script:CMuted
                $showKeys = $matchedKeys | Select-Object -First 5
                foreach ($k in $showKeys) {
                    Write-Host " " -NoNewline
                    Write-Host $k -ForegroundColor $script:CNum
                }
                if ($matchedKeys.Count -gt 5) {
                    Write-Host " ... and $($matchedKeys.Count - 5) more" -ForegroundColor $script:CMuted
                }
                Write-Host ""
            }

            $target = "$($matchedFiles.Count) matching object(s) from bucket '$Bucket'"
            if ($PSCmdlet.ShouldProcess($target, "Remove-BucketObject")) {
                foreach ($f in $matchedFiles) {
                    if ($PassThru) {
                        $relPath = $f.FullName.Substring($bucketPath.Length).TrimStart([System.IO.Path]::DirectorySeparatorChar)
                        $keyOnly = [System.IO.Path]::ChangeExtension($relPath, $null).TrimEnd('.')
                        [PSCustomObject]@{ Bucket = $Bucket; Key = $keyOnly }
                    }
                    [System.IO.File]::Delete($f.FullName)
                }
                if (-not $PassThru -and -not $Quiet) {
                    Write-Host "$Bucket" -NoNewline -ForegroundColor $script:CPath
                    Write-Host " · " -NoNewline -ForegroundColor $script:CMuted
                    $objLabel = if ($matchedFiles.Count -eq 1) { "1 object" } else { "$($matchedFiles.Count) objects" }
                    Write-Host $objLabel -NoNewline -ForegroundColor $script:CNum
                    Write-Host " removed (matched)" -ForegroundColor $script:CMuted
                }
            }
            elseif (-not $WhatIfPreference) { Write-Verbose "Would remove $($matchedFiles.Count) object(s) from bucket '$Bucket'" }
            return
        }

        throw "Specify either -Key, -All, or -Match/-Filter"
    }

    end {
        if ($removedCount -gt 0 -and -not $Quiet -and -not $WhatIfPreference) {
            Write-Host "$lastBucket" -NoNewline -ForegroundColor $script:CPath
            Write-Host " · " -NoNewline -ForegroundColor $script:CMuted
            $objLabel = if ($removedCount -eq 1) { "1 object" } else { "$removedCount objects" }
            Write-Host $objLabel -NoNewline -ForegroundColor $script:CNum
            Write-Host " removed" -ForegroundColor $script:CMuted
        }
    }
}