Utils/du.ps1

Register-GnuUtil @{
    Name         = 'du'
    Category     = 'File info and disk usage'
    Synopsis     = 'Estimate file space usage.'
    Usage        = @('du [OPTION]... [FILE]...')
    Description  = @('Summarize disk usage of the set of FILEs, recursively for directories.')
    FileOperands = $true
    Options      = @(
        @{ Short = '0'; Long = 'null'; Help = 'end each output line with NUL, not newline' }
        @{ Short = 'a'; Long = 'all'; Help = 'write counts for all files, not just directories' }
        @{ Long = 'apparent-size'; Help = 'print apparent sizes, rather than disk usage; although the apparent size is usually smaller, it may be larger due to holes in (''sparse'') files, internal fragmentation, indirect blocks, and the like' }
        @{ Short = 'B'; Long = 'block-size'; Key = 'scale'; Arg = 'SIZE'; Help = 'scale sizes by SIZE before printing them; e.g., ''-BM'' prints sizes in units of 1,048,576 bytes; see SIZE format below' }
        @{ Short = 'b'; Long = 'bytes'; Key = 'scale'; Value = '1'; Sets = @{ 'apparent-size' = 1 }; Help = 'equivalent to ''--apparent-size --block-size=1''' }
        @{ Short = 'c'; Long = 'total'; Help = 'produce a grand total' }
        @{ Short = 'd'; Long = 'max-depth'; Arg = 'N'; Help = 'print the total for a directory (or file, with --all) only if it is N or fewer levels below the command line argument; --max-depth=0 is the same as --summarize' }
        @{ Long = 'exclude'; Arg = 'PATTERN'; Multi = $true; Help = 'exclude files that match PATTERN' }
        @{ Short = 'X'; Long = 'exclude-from'; Arg = 'FILE'; Multi = $true; Help = 'exclude files that match any pattern in FILE' }
        @{ Short = 'h'; Long = 'human-readable'; Key = 'scale'; Value = 'human:1024'; Help = 'print sizes in human readable format (e.g., 1K 234M 2G)' }
        @{ Long = 'si'; Key = 'scale'; Value = 'human:1000'; Help = 'like -h, but use powers of 1000 not 1024' }
        @{ Short = 'k'; Key = 'scale'; Value = '1K'; Help = 'like --block-size=1K' }
        @{ Short = 'L'; Long = 'dereference'; Key = 'links'; Value = 'follow'; Help = 'dereference all symbolic links' }
        @{ Short = 'm'; Key = 'scale'; Value = '1M'; Help = 'like --block-size=1M' }
        @{ Short = 'P'; Long = 'no-dereference'; Key = 'links'; Value = 'none'; Help = 'don''t follow any symbolic links (this is the default)' }
        @{ Short = 'S'; Long = 'separate-dirs'; Help = 'for directories do not include size of subdirectories' }
        @{ Short = 's'; Long = 'summarize'; Help = 'display only a total for each argument' }
        @{ Short = 't'; Long = 'threshold'; Arg = 'SIZE'; Help = 'exclude entries smaller than SIZE if positive, or entries greater than SIZE if negative' }
        @{ Short = 'x'; Long = 'one-file-system'; Help = 'skip directories on different file systems' }
    )
    Notes        = @(
        'Display values are in units of the first available SIZE from --block-size,',
        'and the DU_BLOCK_SIZE, BLOCK_SIZE and BLOCKSIZE environment variables.',
        'Otherwise, units default to 1024 bytes (or 512 if POSIXLY_CORRECT is set).',
        '',
        'PowerShell note: -x stops only at links and junctions, not at mount points. Disk usage is the size rounded up to 4 KiB clusters (.NET does not',
        'expose allocation sizes); directories count 0 on Windows and 4 KiB elsewhere. Entries',
        'are listed in name order, hard links are counted every time, and the environment',
        'variables are ignored; SIZE does not take the '' grouping prefix.'
    )
}



function script:Get-GnuDuSize {
    <#
    .SYNOPSIS
    The size an entry adds: apparent bytes, or the disk usage estimate (4 KiB clusters; directories 0 on Windows).
    .PARAMETER Resolved
    With -L, the path the entry resolves to: that file is measured instead of the link.
    #>

    param([Parameter(Mandatory)][System.IO.FileSystemInfo]$Item, [Parameter(Mandatory)][bool]$Apparent, [string]$Resolved)
    if ($Resolved) { $Item = Get-Item -LiteralPath $Resolved -Force }
    # A link's own size: its target text when apparent, no blocks otherwise (checked first: a link to a directory is a DirectoryInfo).
    $target = Get-GnuLinkTarget -Path $Item.FullName
    if ($null -ne $target) {
        if ($Apparent) { return [double]$target.Length }
        return [double]0
    }
    if ($Item -is [System.IO.DirectoryInfo]) {
        # GNU counts only files and links as apparent size.
        if ($Apparent -or (Test-GnuWindows)) { return [double]0 }
        return [double]4096
    }
    $length = [double]$Item.Length
    if ($Apparent) { return $length }
    [Math]::Ceiling($length / 4096) * 4096
}

function script:Measure-GnuDuEntry {
    <#
    .SYNOPSIS
    Measures one entry (recursively), adding output records for the entries that are printed. Returns the size.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][System.IO.FileSystemInfo]$Item, [Parameter(Mandatory)][string]$Display, [Parameter(Mandatory)][int]$Depth)

    $canonical = ''
    if ($State.Follow) {
        $resolved = Get-GnuCanonicalPath -Path $Item.FullName -Require 'all'
        if ($resolved.ContainsKey('Error')) { Write-GnuError -Context $State.Context -Message "cannot access '$Display': $($resolved.Error)"; return [double]0 }
        $canonical = $resolved.Path
        # A link back to a directory being measured: GNU skips it silently, and without this du -L would never end.
        if ($State.Active.Contains($canonical)) { return [double]0 }
    }
    $size = Get-GnuDuSize -Item $Item -Apparent $State.Apparent -Resolved $canonical
    $isLink = $null -ne (Get-GnuLinkTarget -Path $Item.FullName)
    $descend = $Item -is [System.IO.DirectoryInfo] -and (-not $isLink -or $State.Follow)
    $children = [double]0
    if ($descend) {
        if ($State.Follow) { $null = $State.Active.Add($canonical) }
        try { $entries = @(Get-ChildItem -LiteralPath $Item.FullName -Force -ErrorAction Stop | Sort-Object -Property Name) }
        catch {
            Write-GnuError -Context $State.Context -Message "cannot read directory '$Display': $(Get-GnuErrorReason -Exception $_.Exception)"
            $entries = @()
        }
        foreach ($entry in $entries) {
            if (Test-GnuDuExcluded -State $State -Name $entry.Name) { continue }
            if ($State.OneFileSystem -and $entry -is [System.IO.DirectoryInfo] -and $null -ne (Get-GnuLinkTarget -Path $entry.FullName)) { continue }
            $childSize = Measure-GnuDuEntry -State $State -Item $entry -Display (Join-GnuDisplayPath -Directory $Display -Leaf $entry.Name) -Depth ($Depth + 1)
            if ($State.Separate -and $entry -is [System.IO.DirectoryInfo]) { continue }
            $children += $childSize
        }
        if ($State.Follow) { $null = $State.Active.Remove($canonical) }
    }
    $total = $size + $children
    $print = $Depth -eq 0 -or ($Depth -le $State.MaxDepth -and ($descend -or $State.All))
    if ($print -and (Test-GnuDuThreshold -State $State -Bytes $total)) {
        $State.Records.Add("$(Format-GnuScaledSize -Bytes $total -Scale $State.Scale)`t$Display")
    }
    $total
}

function script:Test-GnuDuExcluded {
    <#
    .SYNOPSIS
    $true if a name matches an --exclude / -X pattern.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][string]$Name)
    foreach ($pattern in $State.Exclude) { if ($pattern.IsMatch($Name)) { return $true } }
    $false
}

function script:Test-GnuDuThreshold {
    <#
    .SYNOPSIS
    The --threshold rule: positive SIZE hides smaller entries, negative SIZE hides larger ones.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][double]$Bytes)
    if ($State.Threshold -gt 0) { return $Bytes -ge $State.Threshold }
    if ($State.Threshold -lt 0) { return $Bytes -le -$State.Threshold }
    $true
}

function script:Invoke-GnuDuReport {
    <#
    .SYNOPSIS
    Implementation of du.
    #>

    param([Parameter(Mandatory)][hashtable]$Context)

    $scale = Get-GnuSizeScale -Context $Context
    if ($null -eq $scale) { return }
    $maxDepth = [long]::MaxValue
    $depthGiven = Test-GnuOption -Context $Context -Key 'max-depth'
    if ($depthGiven) {
        $text = [string](Get-GnuOptionValue -Context $Context -Key 'max-depth')
        if (-not [long]::TryParse($text, [System.Globalization.NumberStyles]::None, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$maxDepth)) {
            Write-GnuUsageError -Context $Context -Message "invalid maximum depth '$text'"
            return
        }
    }
    $summarize = Test-GnuOption -Context $Context -Key 'summarize'
    if ($summarize -and (Test-GnuOption -Context $Context -Key 'all')) {
        Write-GnuUsageError -Context $Context -Message 'cannot both summarize and show all entries'
        return
    }
    if ($summarize -and $depthGiven) {
        if ($maxDepth -ne 0) { Write-GnuUsageError -Context $Context -Message "warning: summarizing conflicts with --max-depth=$maxDepth"; return }
        Write-GnuWarning -Context $Context -Message 'warning: summarizing is the same as using --max-depth=0'
    }
    if ($summarize) { $maxDepth = 0 }
    $threshold = [double]0
    if (Test-GnuOption -Context $Context -Key 'threshold') {
        $text = [string](Get-GnuOptionValue -Context $Context -Key 'threshold')
        $negative = $text.StartsWith('-', [System.StringComparison]::Ordinal)
        $magnitude = $text
        if ($negative) { $magnitude = $text.Substring(1) }
        $parsed = ConvertFrom-GnuBlockSize -Text $magnitude -AllowZero
        if ($parsed.ContainsKey('Error')) { Write-GnuSizeArgumentError -Context $Context -Key 'threshold' -Text $text -Kind $parsed.Error; return }
        if ($negative -and $parsed.Bytes -eq 0) { Write-GnuError -Context $Context -Message "invalid --threshold argument '-0'"; return }
        $threshold = $parsed.Bytes
        if ($negative) { $threshold = -$threshold }
    }
    $patterns = New-Object 'System.Collections.Generic.List[System.Management.Automation.WildcardPattern]'
    foreach ($pattern in @(Get-GnuOptionValue -Context $Context -Key 'exclude' -Default @())) { $patterns.Add((New-Object System.Management.Automation.WildcardPattern -ArgumentList $pattern)) }
    foreach ($file in @(Get-GnuOptionValue -Context $Context -Key 'exclude-from' -Default @())) {
        $lines = Get-GnuInputLine -Context $Context -Operand $file
        foreach ($line in @($lines)) { if ($line.Length -gt 0) { $patterns.Add((New-Object System.Management.Automation.WildcardPattern -ArgumentList $line)) } }
    }

    $state = @{
        Context = $Context; Apparent = Test-GnuOption -Context $Context -Key 'apparent-size'; Scale = $scale
        MaxDepth = $maxDepth; All = Test-GnuOption -Context $Context -Key 'all'; Separate = Test-GnuOption -Context $Context -Key 'separate-dirs'
        Follow = (Get-GnuOptionValue -Context $Context -Key 'links' -Default 'none') -eq 'follow'; OneFileSystem = Test-GnuOption -Context $Context -Key 'one-file-system'
        Threshold = $threshold; Exclude = $patterns; Records = New-Object 'System.Collections.Generic.List[string]'
        Active = New-Object 'System.Collections.Generic.HashSet[string]'
    }
    $operands = @($Context.Operands)
    if ($operands.Count -eq 0) { $operands = @('.') }
    $grand = [double]0
    $zero = Test-GnuOption -Context $Context -Key 'null'
    foreach ($operand in $operands) {
        $item = Get-Item -LiteralPath (Resolve-GnuPath -Path $operand) -Force -ErrorAction SilentlyContinue
        if ($null -eq $item) { Write-GnuError -Context $Context -Message "cannot access '$operand': No such file or directory"; continue }
        $grand += Measure-GnuDuEntry -State $state -Item $item -Display $operand -Depth 0
        # Written per operand so results and errors keep GNU's order; -0 output is one string.
        if (-not $zero) { ConvertTo-GnuRecordOutput -Record $state.Records.ToArray(); $state.Records.Clear() }
    }
    if (Test-GnuOption -Context $Context -Key 'total') { $state.Records.Add("$(Format-GnuScaledSize -Bytes $grand -Scale $scale)`ttotal") }
    ConvertTo-GnuRecordOutput -Record $state.Records.ToArray() -Zero:$zero
}