Utils/install.ps1

Register-GnuUtil @{
    Name         = 'install'
    Category     = 'File and directory operations'
    Synopsis     = 'Copy files and set attributes.'
    Usage        = @(
        'install [OPTION]... [-T] SOURCE DEST',
        'install [OPTION]... SOURCE... DIRECTORY',
        'install [OPTION]... -t DIRECTORY SOURCE...',
        'install [OPTION]... -d DIRECTORY...'
    )
    Description  = @(
        'This install program copies files (often just compiled) into destination',
        'locations you choose. If you want to download and install a ready-to-use',
        'package on a GNU/Linux system, you should instead be using a package manager.',
        '',
        'In the first three forms, copy SOURCE to DEST or multiple SOURCE(s) to',
        'the existing DIRECTORY, while setting permission modes and owner/group.',
        'In the 4th form, create all components of the given DIRECTORY(ies).'
    )
    FileOperands = $true
    Options      = @(
        @{ Short = 'c'; Help = '(ignored)' }
        @{ Short = 'C'; Long = 'compare'; Help = 'compare content of source and destination files, and if no change to content, ownership, and permissions, do not modify the destination at all' }
        @{ Short = 'd'; Long = 'directory'; Help = 'treat all arguments as directory names; create all components of the specified directories' }
        @{ Short = 'D'; Help = 'create all leading components of DEST except the last, or all components of --target-directory, then copy SOURCE to DEST' }
        @{ Short = 'g'; Long = 'group'; Arg = 'GROUP'; Help = 'set group ownership, instead of process'' current group' }
        @{ Short = 'm'; Long = 'mode'; Arg = 'MODE'; Help = 'set permission mode (as in chmod), instead of rwxr-xr-x' }
        @{ Short = 'o'; Long = 'owner'; Arg = 'OWNER'; Help = 'set ownership (super-user only)' }
        @{ Short = 'p'; Long = 'preserve-timestamps'; Help = 'apply access/modification times of SOURCE files to corresponding destination files' }
        @{ Short = 't'; Long = 'target-directory'; Arg = 'DIRECTORY'; Help = 'copy all SOURCE arguments into DIRECTORY' }
        @{ Short = 'T'; Long = 'no-target-directory'; Help = 'treat DEST as a normal file' }
        @{ Short = 'v'; Long = 'verbose'; Help = 'print the name of each directory as it is created' }
    )
    Notes        = @(
        'PowerShell note: only octal modes; modes, -o and -g apply on Unix only (-o / -g run',
        'chown / chgrp). No backups (-b, -S) and no stripping (-s).'
    )
}



function script:New-GnuInstallDirectory {
    <#
    .SYNOPSIS
    Creates a directory and its missing parents, reporting each one with -v. Returns $false on failure.
    #>

    param([Parameter(Mandatory)][hashtable]$Context, [Parameter(Mandatory)][string]$Path, [switch]$Report, [Nullable[int]]$Mode)

    $missing = New-Object 'System.Collections.Generic.List[string]'
    $current = Remove-GnuTrailingSeparator -Path $Path
    while (-not (Test-Path -LiteralPath (Resolve-GnuPath -Path $current))) {
        $missing.Insert(0, $current)
        $parent = Get-GnuDirName -Path $current
        if ($parent -eq $current -or $parent -eq '.') { break }
        $current = $parent
    }
    foreach ($directory in $missing) {
        try { $null = [System.IO.Directory]::CreateDirectory((Resolve-GnuPath -Path $directory)) }
        catch {
            Write-GnuError -Context $Context -Message "cannot create directory '$directory': $(Get-GnuErrorReason -Exception $_.Exception)"
            return $false
        }
        if ($Report) { Write-GnuWarning -Context $Context -Message "creating directory '$directory'" }
        if ($null -ne $Mode) { $null = Set-GnuUnixMode -Path (Resolve-GnuPath -Path $directory) -Mode $Mode }
    }
    $true
}

function script:Test-GnuSameContent {
    <#
    .SYNOPSIS
    $true if two files have the same bytes.
    #>

    param([Parameter(Mandatory)][string]$Left, [Parameter(Mandatory)][string]$Right)
    if ((New-Object System.IO.FileInfo -ArgumentList $Left).Length -ne (New-Object System.IO.FileInfo -ArgumentList $Right).Length) { return $false }
    $a = [System.IO.File]::ReadAllBytes($Left)
    $b = [System.IO.File]::ReadAllBytes($Right)
    for ($i = 0; $i -lt $a.Length; $i++) { if ($a[$i] -ne $b[$i]) { return $false } }
    $true
}

function script:Invoke-GnuInstallFile {
    <#
    .SYNOPSIS
    Implementation of install.
    #>

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

    $operands = @($Context.Operands)
    $report = Test-GnuOption -Context $Context -Key 'verbose'
    $mode = $null
    if (Test-GnuOption -Context $Context -Key 'mode') {
        $modeText = [string](Get-GnuOptionValue -Context $Context -Key 'mode')
        if ($modeText -notmatch '^[0-7]{1,4}$') { Write-GnuError -Context $Context -Message "invalid mode '$modeText' (only octal modes are supported)"; return }
        $mode = [System.Convert]::ToInt32($modeText, 8)
    }

    if (Test-GnuOption -Context $Context -Key 'directory') {
        if ($operands.Count -eq 0) { Write-GnuUsageError -Context $Context -Message 'missing file operand'; return }
        foreach ($directory in $operands) { $null = New-GnuInstallDirectory -Context $Context -Path $directory -Report:$report -Mode $mode }
        return
    }

    $targetDirectory = $null
    if (Test-GnuOption -Context $Context -Key 'target-directory') { $targetDirectory = [string](Get-GnuOptionValue -Context $Context -Key 'target-directory') }
    $noTarget = Test-GnuOption -Context $Context -Key 'no-target-directory'
    if ($null -ne $targetDirectory -and $noTarget) { Write-GnuUsageError -Context $Context -Message 'cannot combine --target-directory (-t) and --no-target-directory (-T)'; return }
    if ($operands.Count -eq 0) { Write-GnuUsageError -Context $Context -Message 'missing file operand'; return }
    if ($null -eq $targetDirectory -and $operands.Count -eq 1) { Write-GnuUsageError -Context $Context -Message "missing destination file operand after '$($operands[0])'"; return }

    $pairs = New-Object 'System.Collections.Generic.List[object]'
    $leading = Test-GnuOption -Context $Context -Key 'D'
    if ($null -ne $targetDirectory) {
        if ($leading -and -not (New-GnuInstallDirectory -Context $Context -Path $targetDirectory -Report:$report)) { return }
        foreach ($source in $operands) { $pairs.Add(@($source, (Join-GnuDisplayPath -Directory $targetDirectory -Leaf (Get-GnuBaseName -Path $source)))) }
    }
    else {
        $destination = $operands[$operands.Count - 1]
        $sources = @($operands[0..($operands.Count - 2)])
        $isDirectory = [System.IO.Directory]::Exists((Resolve-GnuPath -Path $destination))
        if ($noTarget -and $sources.Count -gt 1) { Write-GnuUsageError -Context $Context -Message "extra operand '$destination'"; return }
        if ($sources.Count -gt 1 -and -not $isDirectory) { Write-GnuError -Context $Context -Message "target '$destination' is not a directory"; return }
        if ($isDirectory -and -not $noTarget) {
            foreach ($source in $sources) { $pairs.Add(@($source, (Join-GnuDisplayPath -Directory $destination -Leaf (Get-GnuBaseName -Path $source)))) }
        }
        else {
            if ($leading -and -not (New-GnuInstallDirectory -Context $Context -Path (Get-GnuDirName -Path $destination) -Report:$report)) { return }
            $pairs.Add(@($sources[0], $destination))
        }
    }

    foreach ($pair in $pairs) {
        $source = $pair[0]
        $target = $pair[1]
        $sourcePath = Resolve-GnuPath -Path $source
        $targetPath = Resolve-GnuPath -Path $target
        if (-not [System.IO.File]::Exists($sourcePath)) {
            if ([System.IO.Directory]::Exists($sourcePath)) { Write-GnuError -Context $Context -Message "omitting directory '$source'" }
            else { Write-GnuError -Context $Context -Message "cannot stat '$source': No such file or directory" }
            continue
        }
        if ([System.IO.Directory]::Exists($targetPath)) { Write-GnuError -Context $Context -Message "cannot overwrite directory '$target' with non-directory"; continue }
        if (Test-GnuSamePath -Left $sourcePath -Right $targetPath) { Write-GnuError -Context $Context -Message "'$source' and '$target' are the same file"; continue }
        if ((Test-GnuOption -Context $Context -Key 'compare') -and [System.IO.File]::Exists($targetPath) -and (Test-GnuSameContent -Left $sourcePath -Right $targetPath)) { continue }
        try {
            [System.IO.File]::Copy($sourcePath, $targetPath, $true)
            if (Test-GnuOption -Context $Context -Key 'preserve-timestamps') {
                [System.IO.File]::SetLastWriteTimeUtc($targetPath, [System.IO.File]::GetLastWriteTimeUtc($sourcePath))
                [System.IO.File]::SetLastAccessTimeUtc($targetPath, [System.IO.File]::GetLastAccessTimeUtc($sourcePath))
            }
        }
        catch {
            Write-GnuError -Context $Context -Message "cannot create regular file '$target': $(Get-GnuErrorReason -Exception $_.Exception)"
            continue
        }
        if ($report) { "'$source' -> '$target'" }
        $fileMode = 493   # 0755
        if ($null -ne $mode) { $fileMode = $mode }
        $null = Set-GnuUnixMode -Path $targetPath -Mode $fileMode
        foreach ($owner in @(@('owner', 'chown'), @('group', 'chgrp'))) {
            if (-not (Test-GnuOption -Context $Context -Key $owner[0])) { continue }
            $value = [string](Get-GnuOptionValue -Context $Context -Key $owner[0])
            if (Test-GnuWindows) { Write-GnuWarning -Context $Context -Message "--$($owner[0]) is ignored on Windows"; continue }
            $process = Start-Process -FilePath $owner[1] -ArgumentList @($value, $targetPath) -NoNewWindow -Wait -PassThru
            if ($process.ExitCode -ne 0) { Write-GnuError -Context $Context -Message "cannot change $($owner[0]) of '$target' to '$value'" }
        }
    }
}