Utils/ln.ps1

Register-GnuUtil @{
    Name         = 'ln'
    Category     = 'File and directory operations'
    Synopsis     = 'Make links between files.'
    Usage        = @(
        'ln [OPTION]... [-T] TARGET LINK_NAME',
        'ln [OPTION]... TARGET',
        'ln [OPTION]... TARGET... DIRECTORY',
        'ln [OPTION]... -t DIRECTORY TARGET...'
    )
    Description  = @(
        'In the 1st form, create a link to TARGET with the name LINK_NAME.',
        'In the 2nd form, create a link to TARGET in the current directory.',
        'In the 3rd and 4th forms, create links to each TARGET in DIRECTORY.',
        'Create hard links by default, symbolic links with --symbolic.',
        'By default, each destination (name of new link) should not already exist.',
        'When creating hard links, each TARGET must exist. Symbolic links',
        'can hold arbitrary text; if later resolved, a relative link is',
        'interpreted in relation to its parent directory.'
    )
    FileOperands = $true
    Options      = @(
        @{ Short = 'f'; Long = 'force'; Key = 'replace'; Value = 'force'; Help = 'remove existing destination files' }
        @{ Short = 'i'; Long = 'interactive'; Key = 'replace'; Value = 'ask'; Help = 'prompt whether to remove destinations' }
        @{ Short = 'L'; Long = 'logical'; Key = 'follow'; Value = 'logical'; Help = 'dereference TARGETs that are symbolic links' }
        @{ Short = 'n'; Long = 'no-dereference'; Help = 'treat LINK_NAME as a normal file if it is a symbolic link to a directory' }
        @{ Short = 'P'; Long = 'physical'; Key = 'follow'; Value = 'physical'; Help = 'make hard links directly to symbolic links' }
        @{ Short = 'r'; Long = 'relative'; Help = 'with -s, create links relative to link location' }
        @{ Short = 's'; Long = 'symbolic'; Help = 'make symbolic links instead of hard links' }
        @{ Short = 't'; Long = 'target-directory'; Arg = 'DIRECTORY'; Help = 'specify the DIRECTORY in which to create the links' }
        @{ Short = 'T'; Long = 'no-target-directory'; Help = 'treat LINK_NAME as a normal file always' }
        @{ Short = 'v'; Long = 'verbose'; Help = 'print name of each linked file' }
    )
    Notes        = @(
        'PowerShell notes: symbolic links on Windows need admin rights or developer mode. -L and -P are',
        'accepted; hard links always point to the file the TARGET name resolves to. Backups (-b, -S) are not supported.'
    )
}



function script:New-GnuLnLink {
    <#
    .SYNOPSIS
    Implementation of ln.
    #>

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

    $symbolic = Test-GnuOption -Context $Context -Key 'symbolic'
    $relative = Test-GnuOption -Context $Context -Key 'relative'
    if ($relative -and -not $symbolic) {
        Write-GnuUsageError -Context $Context -Message 'cannot do --relative without --symbolic'
        return
    }
    $replace = [string](Get-GnuOptionValue -Context $Context -Key 'replace' -Default 'no')
    $verbose = Test-GnuOption -Context $Context -Key 'verbose'
    $kind = 'hard link'
    if ($symbolic) { $kind = 'symbolic link' }

    $pairs = @(Get-GnuTransferPlan -Context $Context -AllowSingleOperand -NoDereferenceTarget:(Test-GnuOption -Context $Context -Key 'no-dereference'))
    foreach ($pair in $pairs) {
        $targetPath = Resolve-GnuPath -Path $pair.Source
        $linkPath = Resolve-GnuPath -Path $pair.Target
        $describe = "'$($pair.Target)'"
        if (-not $symbolic) { $describe = "'$($pair.Target)' => '$($pair.Source)'" }

        if (-not $symbolic) {
            if ([System.IO.Directory]::Exists($targetPath)) {
                Write-GnuError -Context $Context -Message "$($pair.Source): hard link not allowed for directory"
                continue
            }
            if (-not [System.IO.File]::Exists($targetPath)) {
                Write-GnuError -Context $Context -Message "failed to access '$($pair.Source)': No such file or directory"
                continue
            }
        }

        $createPath = $linkPath
        $existing = Get-Item -LiteralPath $linkPath -Force -ErrorAction SilentlyContinue
        if ($null -ne $existing) {
            if (-not $symbolic -and (Test-GnuSamePath -Left $targetPath -Right $linkPath)) {
                Write-GnuError -Context $Context -Message "'$($pair.Source)' and '$($pair.Target)' are the same file"
                continue
            }
            if ($replace -eq 'no') {
                Write-GnuError -Context $Context -Message "failed to create $kind $($describe): File exists"
                continue
            }
            if ($replace -eq 'ask' -and -not (Read-GnuConfirmation -Context $Context -Question "replace '$($pair.Target)'?")) { continue }
            $isLink = $null -ne (Get-GnuLinkTarget -Path $linkPath)
            if ($existing -is [System.IO.DirectoryInfo] -and -not $isLink) {
                Write-GnuError -Context $Context -Message "$($pair.Target): cannot overwrite directory"
                continue
            }
            # The new link is made beside the destination first: if that fails (no symlink rights, another volume)
            # the existing file must survive.
            $createPath = "$linkPath.$([guid]::NewGuid().ToString('n')).tmp"
        }

        try {
            if ($symbolic) {
                $stored = $pair.Source
                $linkDirectory = [System.IO.Path]::GetDirectoryName([System.IO.Path]::GetFullPath($linkPath))
                if ($relative) { $stored = Get-GnuRelativePath -From $linkDirectory -To $targetPath }
                # A relative link target is resolved from the link's directory, not from the current directory.
                $resolvedTarget = $stored
                if (-not [System.IO.Path]::IsPathRooted($stored)) { $resolvedTarget = [System.IO.Path]::Combine($linkDirectory, $stored) }
                New-GnuSymbolicLink -Path $createPath -Target $stored -Directory:([System.IO.Directory]::Exists($resolvedTarget))
                $report = "'$($pair.Target)' -> '$stored'"
            }
            else {
                $null = New-Item -ItemType HardLink -Path ([System.Management.Automation.WildcardPattern]::Escape($createPath)) -Value $targetPath -ErrorAction Stop
                $report = "'$($pair.Target)' => '$($pair.Source)'"
            }
            if ($createPath -ne $linkPath) { Move-GnuLnLink -From $createPath -To $linkPath }
            if ($verbose) { $report }
        }
        catch {
            Write-GnuError -Context $Context -Message "failed to create $kind $($describe): $(Get-GnuErrorReason -Exception $_.Exception)"
        }
    }
}

function script:Remove-GnuLnEntry {
    <#
    .SYNOPSIS
    Removes one file or link; a link to a directory is removed without touching the directory.
    #>

    param([Parameter(Mandatory)][string]$Path)
    if ([System.IO.Directory]::Exists($Path)) { [System.IO.Directory]::Delete($Path, $false) }
    else { [System.IO.File]::Delete($Path) }
}

function script:Move-GnuLnLink {
    <#
    .SYNOPSIS
    Replaces the entry at To with the link just created at From; the temporary link never stays behind.
    #>

    param([Parameter(Mandatory)][string]$From, [Parameter(Mandatory)][string]$To)
    try {
        Remove-GnuLnEntry -Path $To
        if ([System.IO.Directory]::Exists($From)) { [System.IO.Directory]::Move($From, $To) }
        else { [System.IO.File]::Move($From, $To) }
    }
    catch {
        Remove-GnuLnEntry -Path $From
        throw
    }
}