Public/Scan/Get-SPCVersionWaste.ps1

function Get-SPCVersionWaste {
    <#
    .SYNOPSIS
        Analyzes document library file version sprawl and calculates recoverable storage capacity.
 
    .DESCRIPTION
        Get-SPCVersionWaste inspects document libraries within a SharePoint Online site collection to measure
        the storage footprint consumed by historical file versions. It calculates total current file size versus
        historical version size and determines the bloat ratio.
 
        Based on simulated retention policies (-KeepVersions and -OlderThanDays), the cmdlet calculates exact
        recoverable storage in megabytes and identifies bloated individual files that consume disproportionate
        storage capacity.
 
    .PARAMETER SiteUrl
        The full URL of the SharePoint Online site collection to inspect.
        Supports pipeline input by value and property name.
 
    .PARAMETER LibraryTitle
        Optional list of specific document library titles to analyze. When omitted, all visible, non-system
        document libraries are evaluated.
 
    .PARAMETER KeepVersions
        The number of major historical versions to simulate retaining for each file. Default is 50.
        Versions exceeding this count are calculated as prunable/recoverable. Range: 1 to 50,000.
 
    .PARAMETER OlderThanDays
        The age threshold in days for historical versions to be eligible for pruning. Default is 90 days.
        Versions newer than this threshold are preserved in simulation calculations. Range: 0 to 3,650.
 
    .PARAMETER TopFiles
        Maximum number of bloated files to include in the TopBloatedFiles property per library. Default is 50.
        Range: 1 to 500.
 
    .INPUTS
        System.String
            Accepts site collection URLs from the pipeline.
 
    .OUTPUTS
        SPC.VersionWasteDetail
            Returns custom objects containing library-level metrics, item count, current vs history storage,
            bloat ratio, simulated recoverable storage (MB and percentage), and a collection of top bloated files.
 
    .EXAMPLE
        Get-SPCVersionWaste -SiteUrl 'https://contoso.sharepoint.com/sites/Finance'
 
        Analyzes all document libraries in the Finance site using default thresholds (keep 50 versions, older than 90 days).
 
    .EXAMPLE
        Get-SPCVersionWaste -SiteUrl 'https://contoso.sharepoint.com/sites/Engineering' -LibraryTitle 'Project CAD Files' -KeepVersions 20 -OlderThanDays 60
 
        Analyzes the 'Project CAD Files' library, simulating an aggressive version retention policy keeping 20 versions older than 60 days.
 
    .EXAMPLE
        Get-SPCVersionWaste -SiteUrl 'https://contoso.sharepoint.com/sites/Legal' | Where-Object BloatRatio -gt 3.0 | ForEach-Object {
            Optimize-SPCFileVersion -SiteUrl $_.SiteUrl -LibraryTitle $_.LibraryTitle -KeepVersions 50 -OlderThanDays 90 -DryRun
        }
 
        Identifies document libraries where version history is more than 3x larger than the active files, and pipes them to Optimize-SPCFileVersion for preview.
 
    .NOTES
        Requires an active SPClean connection initialized via Connect-SPCTenant.
        Does NOT modify or trim any file versions; use Optimize-SPCFileVersion for remediation.
 
    .LINK
        Optimize-SPCFileVersion
    .LINK
        Get-SPCStorageWaste
    .LINK
        Export-SPCStorageReport
    #>

    [CmdletBinding()]
    [OutputType('SPC.VersionWasteDetail')]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$SiteUrl,

        [Parameter()]
        [string[]]$LibraryTitle,

        [Parameter()]
        [ValidateRange(1, 50000)]
        [int]$KeepVersions = 50,

        [Parameter()]
        [ValidateRange(0, 3650)]
        [int]$OlderThanDays = 90,

        [Parameter()]
        [ValidateRange(1, 500)]
        [int]$TopFiles = 50
    )

    begin {
        Test-SPCConnection
    }

    process {
        $siteConn = Connect-SPCSiteInternal -SiteUrl $SiteUrl -Context $script:SPCContext
        $lists = Get-PnPList -Connection $siteConn

        $docLibs = @()
        if ($null -ne $lists) {
            $docLibs = @($lists | Where-Object {
                $_.BaseTemplate -eq 101 -and -not $_.Hidden -and
                $_.Title -notin @('FormServerTemplates', 'Style Library', 'PreservationHoldLibrary', 'Preservation Hold Library', 'Site Assets', 'Site Pages')
            })
        }

        if ($LibraryTitle) {
            $docLibs = @($docLibs | Where-Object { $_.Title -in $LibraryTitle })
        }

        foreach ($lib in $docLibs) {
            Write-Verbose "Analyzing Library: $($lib.Title) on $SiteUrl"
            $items = Get-PnPListItem -List $lib -Connection $siteConn -PageSize 500
            
            $currentSizeTotal = 0.0
            $historySizeTotal = 0.0
            $recoverableTotal = 0.0
            $bloatedFilesList = [System.Collections.Generic.List[PSCustomObject]]::new()
            $now = (Get-Date).ToUniversalTime()

            if ($null -ne $items) {
                foreach ($item in $items) {
                    $isItemFile = if ($item.FileSystemObjectType) {
                        $item.FileSystemObjectType.ToString() -eq 'File'
                    } else { $true }

                    if ($isItemFile) {
                        $file = Get-PnPProperty -ClientObject $item -Property File -Connection $siteConn
                        $targetObj = if ($null -ne $file) { $file } else { $item }
                        $versions = Get-PnPProperty -ClientObject $targetObj -Property Versions -Connection $siteConn
                        
                        $fileLength = [int64]0
                        if ($null -ne $file -and $null -ne $file.Length) {
                            $fileLength = [int64]$file.Length
                        } elseif ($null -ne $item.Length) {
                            $fileLength = [int64]$item.Length
                        }
                        $fileSizeMB = [Math]::Round(($fileLength / 1MB), 4)
                        $currentSizeTotal += $fileSizeMB

                        $verCount = if ($null -ne $versions) { $versions.Count } else { 0 }
                        if ($verCount -gt 0) {
                            $verSizeMB = [Math]::Round((($versions | Measure-Object -Property Length -Sum).Sum / 1MB), 4)
                            $historySizeTotal += $verSizeMB

                            # Calculate recoverable based on simulated KeepVersions and OlderThanDays
                            $prunableVersions = @($versions | Where-Object {
                                if ($null -ne $_.Created) {
                                    $vAgeDays = ($now - $_.Created.ToUniversalTime()).TotalDays
                                    $vAgeDays -ge $OlderThanDays
                                } else { $false }
                            })

                            $prunableByCount = if ($verCount -gt $KeepVersions) {
                                @($versions | Select-Object -First ($verCount - $KeepVersions))
                            } else { @() }

                            $combinedPrune = [System.Collections.Generic.List[object]]::new()
                            foreach ($pv in $prunableVersions) { $combinedPrune.Add($pv) }
                            foreach ($pc in $prunableByCount) {
                                if (-not ($combinedPrune | Where-Object { $_.VersionLabel -eq $pc.VersionLabel })) {
                                    $combinedPrune.Add($pc)
                                }
                            }

                            $recoverableFileMB = [Math]::Round((($combinedPrune | Measure-Object -Property Length -Sum).Sum / 1MB), 4)
                            $recoverableTotal += $recoverableFileMB

                            if ($verSizeMB -gt 1.0) {
                                $bloatedFilesList.Add([PSCustomObject]@{
                                    FileUrl             = $item['FileRef']
                                    FileName            = $item['FileLeafRef']
                                    VersionCount        = $verCount
                                    CurrentSizeMB       = $fileSizeMB
                                    VersionsSizeMB      = $verSizeMB
                                    RecoverableMB       = $recoverableFileMB
                                })
                            }
                        }
                    }
                }
            }

            $totalLibMB = [Math]::Round(($currentSizeTotal + $historySizeTotal), 2)
            $bloatRatio = if ($currentSizeTotal -gt 0) { [Math]::Round(($totalLibMB / $currentSizeTotal), 2) } else { 1.0 }
            $recovPct   = if ($totalLibMB -gt 0) { [Math]::Round(($recoverableTotal / $totalLibMB * 100), 2) } else { 0.0 }

            $topBloated = @($bloatedFilesList | Sort-Object -Property RecoverableMB -Descending | Select-Object -First $TopFiles)

            [PSCustomObject][ordered]@{
                PSTypeName               = 'SPC.VersionWasteDetail'
                SiteUrl                  = $SiteUrl
                LibraryTitle             = $lib.Title
                ItemCount                = $lib.ItemCount
                CurrentFilesSizeMB       = [Math]::Round($currentSizeTotal, 2)
                HistoricalVersionsSizeMB = [Math]::Round($historySizeTotal, 2)
                TotalLibrarySizeMB       = $totalLibMB
                BloatRatio               = $bloatRatio
                ConfiguredMaxVersions    = $lib.MajorVersionLimit
                SimulatedKeepVersions    = $KeepVersions
                RecoverableStorageMB     = [Math]::Round($recoverableTotal, 2)
                RecoverablePercent       = $recovPct
                TopBloatedFiles          = $topBloated
            }
        }
    }
}