Utils/rm.ps1

Register-GnuUtil @{
    Name         = 'rm'
    Category     = 'File and directory operations'
    Synopsis     = 'Remove files or directories.'
    Usage        = @('rm [OPTION]... [FILE]...')
    Description  = @('Remove (unlink) the FILE(s).')
    FileOperands = $true
    Options      = @(
        @{ Short = 'f'; Long = 'force'; Key = 'interactive'; Value = 'never'; Sets = @{ force = 1 }; Help = 'ignore nonexistent files and arguments, never prompt' }
        @{ Short = 'i'; Key = 'interactive'; Value = 'always'; Help = 'prompt before every removal' }
        @{ Short = 'I'; Key = 'interactive'; Value = 'once'; Help = 'prompt once before removing more than three files, or when removing recursively; less intrusive than -i, while still giving protection against most mistakes' }
        @{ Long = 'interactive'; Key = 'interactive-when'; Arg = 'WHEN'; OptionalArg = $true; Help = 'prompt according to WHEN: never, once (-I), or always (-i); without WHEN, prompt always' }
        @{ Long = 'one-file-system'; Help = 'when removing a hierarchy recursively, skip any directory that is on a file system different from that of the corresponding command line argument (accepted; links are never followed anyway)' }
        @{ Long = 'no-preserve-root'; Key = 'preserve-root'; Value = 'no'; Help = 'do not treat ''/'' specially' }
        @{ Long = 'preserve-root'; Arg = 'all'; OptionalArg = $true; Help = 'do not remove ''/'' (default); with ''all'', reject any command line argument on a separate device from its parent' }
        @{ Short = 'r'; Long = 'recursive'; Key = 'recursive'; Help = 'remove directories and their contents recursively' }
        @{ Short = 'R'; Key = 'recursive'; Help = 'same as -r' }
        @{ Short = 'd'; Long = 'dir'; Help = 'remove empty directories' }
        @{ Short = 'v'; Long = 'verbose'; Help = 'explain what is being done' }
    )
    Notes        = @(
        'By default, rm does not remove directories. Use the --recursive (-r or -R)',
        'option to remove each listed directory, too, along with all of its contents.',
        '',
        'To remove a file whose name starts with a ''-'', for example ''-foo'',',
        'use one of these commands:',
        ' rm -- -foo',
        '',
        ' rm ./-foo',
        '',
        'Symbolic links and junctions are removed themselves; rm never descends into their targets.',
        '',
        'PowerShell notes: read-only files are removed (like GNU rm when standard input is not a terminal).',
        'File system objects from the pipeline (Get-ChildItem *.tmp | rm) are removed as extra FILE operands;',
        'other pipeline input is ignored. Drive roots such as C:\ are protected like ''/''.'
    )
}



function script:Test-GnuRootPath {
    <#
    .SYNOPSIS
    $true if Path is a file system root ('/', 'C:\', '\\server\share\').
    #>

    param([Parameter(Mandatory)][string]$Path)

    $full = [System.IO.Path]::GetFullPath($Path)
    $root = [System.IO.Path]::GetPathRoot($full)
    -not [string]::IsNullOrEmpty($root) -and [string]::Equals($full.TrimEnd((Get-GnuPathSeparator)), $root.TrimEnd((Get-GnuPathSeparator)), [System.StringComparison]::OrdinalIgnoreCase)
}

function script:Invoke-GnuRemove {
    <#
    .SYNOPSIS
    Implementation of rm: validates options and removes every operand.
    #>

    param(
        [Parameter(Mandatory)][hashtable]$Context,
        [AllowEmptyCollection()][string[]]$Extra = @()
    )

    $mode = [string](Get-GnuOptionValue -Context $Context -Key 'interactive' -Default 'default')
    if (Test-GnuOption -Context $Context -Key 'interactive-when') {
        $when = [string](Get-GnuOptionValue -Context $Context -Key 'interactive-when')
        switch -Regex ($when) {
            '^$|^(always|yes)$' { $mode = 'always' }
            '^(never|no|none)$' { $mode = 'never' }
            '^once$' { $mode = 'once' }
            default {
                Write-GnuUsageError -Context $Context -Message "invalid argument '$when' for '--interactive'`nValid arguments are: 'never', 'no', 'none', 'once', 'always', 'yes'"
                return
            }
        }
    }

    $settings = @{
        Mode      = $mode
        Force     = Test-GnuOption -Context $Context -Key 'force'
        Recursive = Test-GnuOption -Context $Context -Key 'recursive'
        Dirs      = Test-GnuOption -Context $Context -Key 'dir'
        Verbose   = Test-GnuOption -Context $Context -Key 'verbose'
        Preserve  = (Get-GnuOptionValue -Context $Context -Key 'preserve-root' -Default 'yes') -ne 'no'
        Output    = New-Object 'System.Collections.Generic.List[string]'   # -v messages (statuses use the pipeline)
    }

    $operands = @(@($Context.Operands) + @($Extra))
    if ($operands.Count -eq 0) {
        if (-not $settings.Force) { Write-GnuUsageError -Context $Context -Message 'missing operand' }
        return
    }

    if ($mode -eq 'once' -and ($operands.Count -gt 3 -or $settings.Recursive)) {
        $plural = ''
        if ($operands.Count -ne 1) { $plural = 's' }
        $how = ''
        if ($settings.Recursive) { $how = ' recursively' }
        if (-not (Read-GnuConfirmation -Context $Context -Question "remove $($operands.Count) argument$plural$($how)?")) { return }
    }

    foreach ($operand in $operands) {
        if ([string]::IsNullOrEmpty($operand)) {
            Write-GnuError -Context $Context -Message "cannot remove '': No such file or directory"
            continue
        }
        $leaf = Get-GnuBaseName -Path $operand
        if (($settings.Recursive -or $settings.Dirs) -and ($leaf -eq '.' -or $leaf -eq '..')) {
            Write-GnuError -Context $Context -Message "refusing to remove '.' or '..' directory: skipping '$operand'"
            continue
        }

        $path = Resolve-GnuPath -Path $operand
        $item = Get-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
        if ($null -eq $item) {
            if (-not $settings.Force) { Write-GnuError -Context $Context -Message "cannot remove '$operand': No such file or directory" }
            continue
        }
        if ($settings.Preserve -and $settings.Recursive -and (Test-GnuRootPath -Path $path)) {
            Write-GnuError -Context $Context -Message "it is dangerous to operate recursively on '$operand'`nrm: use --no-preserve-root to override this failsafe"
            continue
        }
        $null = Remove-GnuRmItem -Context $Context -Item $item -Display $operand -Settings $settings
        foreach ($line in $settings.Output) { $line }
        $settings.Output.Clear()
    }
}

function script:Remove-GnuRmItem {
    <#
    .SYNOPSIS
    Removes one file, link or directory tree. Returns 'removed', 'skipped' or 'failed'.
    #>

    param(
        [Parameter(Mandatory)][hashtable]$Context,
        [Parameter(Mandatory)][System.IO.FileSystemInfo]$Item,
        [Parameter(Mandatory)][string]$Display,
        [Parameter(Mandatory)][hashtable]$Settings
    )

    $path = $Item.FullName
    $isLink = $Item.LinkType -eq 'SymbolicLink' -or $Item.LinkType -eq 'Junction'
    $always = $Settings.Mode -eq 'always'

    try {
        if ($Item -is [System.IO.DirectoryInfo] -and -not $isLink) {
            $entries = @([System.IO.Directory]::GetFileSystemEntries($path))
            if (-not $Settings.Recursive) {
                if (-not $Settings.Dirs) {
                    Write-GnuError -Context $Context -Message "cannot remove '$Display': Is a directory"
                    return 'failed'
                }
                if ($entries.Count -gt 0) {
                    Write-GnuError -Context $Context -Message "cannot remove '$Display': Directory not empty"
                    return 'failed'
                }
            }

            if ($entries.Count -gt 0) {
                if ($always -and -not (Read-GnuConfirmation -Context $Context -Question "descend into directory '$Display'?")) { return 'skipped' }
                [System.Array]::Sort($entries, [System.StringComparer]::Ordinal)
                $kept = $false
                foreach ($entry in $entries) {
                    $child = Get-Item -LiteralPath $entry -Force
                    $childDisplay = $Display.TrimEnd((Get-GnuPathSeparator)) + '/' + $child.Name
                    $status = Remove-GnuRmItem -Context $Context -Item $child -Display $childDisplay -Settings $Settings
                    if ($status -ne 'removed') { $kept = $true }
                }
                if ($kept) { return 'skipped' }   # children were kept; GNU leaves the directory alone
            }

            if ($always -and -not (Read-GnuConfirmation -Context $Context -Question "remove directory '$Display'?")) { return 'skipped' }
            $Item.Attributes = [System.IO.FileAttributes]::Directory
            [System.IO.Directory]::Delete($path, $false)
            if ($Settings.Verbose) { $Settings.Output.Add("removed directory '$Display'") }
            return 'removed'
        }

        if ($always) {
            $kind = 'regular file'
            if ($isLink) { $kind = 'symbolic link' }
            elseif ($Item -is [System.IO.FileInfo] -and $Item.Length -eq 0) { $kind = 'regular empty file' }
            if (-not (Read-GnuConfirmation -Context $Context -Question "remove $kind '$Display'?")) { return 'skipped' }
        }

        if ($Item -is [System.IO.DirectoryInfo]) {
            # A link to a directory: delete the link only, never the target.
            [System.IO.Directory]::Delete($path, $false)
        }
        else {
            if (-not $isLink -and ($Item.Attributes -band [System.IO.FileAttributes]::ReadOnly)) { $Item.Attributes = [System.IO.FileAttributes]::Normal }
            [System.IO.File]::Delete($path)
        }
        if ($Settings.Verbose) { $Settings.Output.Add("removed '$Display'") }
        'removed'
    }
    catch {
        Write-GnuError -Context $Context -Message "cannot remove '$Display': $(Get-GnuErrorReason -Exception $_.Exception)"
        'failed'
    }
}