systempath.ps1

$systemPathSeparator = [IO.Path]::PathSeparator

class ValidRegexAttribute : System.Management.Automation.ValidateEnumeratedArgumentsAttribute {

    [void] ValidateElement([object] $element) {
        $pattern = [string] $element
        try {
            [void] [regex]::new($pattern)
        }
        catch {
            throw "Invalid regular expression. pattern: '$pattern', reason: $($_.Exception.InnerException.Message)"
        }
    }

    <#
    .SYNOPSIS
        Validates that an argument is a valid regular expression.
    .DESCRIPTION
        Rejects an argument that cannot be parsed as a regular expression, reporting the pattern and the reason.
        Every element of a collection is validated separately.
    .EXAMPLE
        [ValidRegexAttribute()] [string[]] $Match
    #>

}

class SystemPathLocation {

    [string] $Scope
    [ValidateNotNullOrEmpty()] [string] $StoredValue
    [string] $Location

    SystemPathLocation($Scope, $StoredValue, $Location) {
        $this.Scope = $Scope
        $this.StoredValue = $StoredValue
        $this.Location = $Location
    }

    # the entry as the value the Path stores, the form the location commands take it back as
    [string] ToString() {
        return $this.StoredValue
    }

    <#
    .SYNOPSIS
        A folder location on the system Path and the scope it belongs to.
    .DESCRIPTION
        Holds a folder location on the system Path together with its scope:
        'Machine' (local machine), 'User' (current user) or 'Process' (local to the current shell).
        StoredValue is the value as persisted, verbatim: any %...% reference is kept as indirection,
        as is a repeated or trailing backslash and a '..' segment.
        Location is what that value resolves to - expanded and normalized to an absolute folder - or null when
        it cannot be resolved. The two are equal when the stored value is already a normalized absolute folder.
        ToString() returns StoredValue, so an entry piped to a location command names the entry itself,
        an unresolved %...% reference included.
    .EXAMPLE
        $location = [SystemPathLocation]::new("Machine", "%ProgramFiles%\Git\bin", "C:\Program Files\Git\bin")
    #>

}

function Backup-SystemPath {
    <#
    .SYNOPSIS
        Backs up the system Path to a file in the temp folder.
    .DESCRIPTION
        Writes the Path in effect in the current shell - the expanded, effective $env:PATH, not the
        persisted machine and user Paths - to a timestamped file in the temp folder, and returns the
        location of that file. Every write to a scope Path takes one of these first.
    .OUTPUTS
        string - Location of the backup file. Nothing under -WhatIf.
    .EXAMPLE
        Backup-SystemPath
    .EXAMPLE
        $backup = Backup-SystemPath
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([string])]
    param ()

    $backupFile = "$env:TEMP\PATH-$(Get-Timestamp).txt"

    if ($PSCmdlet.ShouldProcess($backupFile, "Backup system Path")) {
        $env:PATH > $backupFile

        return $backupFile
    }
}

function local:ConvertTo-ComparableLocation {
    <#
    .SYNOPSIS
        Reduces a location to the key locations are compared on.
    .DESCRIPTION
        Returns the location with repeated backslashes collapsed to one and a trailing backslash removed,
        so that two spellings of the same folder yield the same key. A leading '\\' is kept, holding a UNC
        root apart from a single leading backslash.
        Case is left as it is: comparison is case-insensitive through the operator, not here.
    .PARAMETER Location
        The location to reduce.
    .OUTPUTS
        The comparison key of the location.
    .EXAMPLE
        ConvertTo-ComparableLocation -Location "C:\Program Files\\Git\bin\"
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param (
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Location
    )

    # every run of backslashes collapses to one; a leading run is a UNC root, so its first backslash is
    # captured and put back
    return ($Location -replace '(^\\)?\\+', '$1\').TrimEnd("\")
}

function local:ConvertTo-NormalizedLocation {
    <#
    .SYNOPSIS
        Resolves a stored Path value to the absolute folder it names.
    .DESCRIPTION
        Expands any %...% reference, resolves the result against the current directory, collapses repeated
        backslashes and '..' segments, and drops a trailing backslash. A root keeps its trailing backslash,
        'C:\' being a folder where 'C:' is a drive-relative reference; a leading '\\' is kept, holding a UNC
        root apart from a single leading backslash. Case is left as it is.
        A value that cannot be resolved - one carrying a %...% reference no variable resolves, or one
        exceeding the path limit - returns null. Reporting the reference is left to the caller.
    .PARAMETER Location
        The location to resolve, treated as expandable.
    .OUTPUTS
        The absolute, normalized location, or null when the value cannot be resolved.
    .EXAMPLE
        ConvertTo-NormalizedLocation -Location "%SystemRoot%\\system32\"
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param (
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Location
    )

    # a reference no variable resolves names no folder: expansion would leave it standing and the
    # current directory would be prefixed to it
    if (Get-UnresolvedVariableName -Value $Location) {
        return $null
    }

    $expanded = [Environment]::ExpandEnvironmentVariables($Location)

    # GetFullPath resolves against [Environment]::CurrentDirectory, which does not follow the shell's
    # location; $PWD is what the current directory means to a caller, where the shell is on a filesystem
    $base = $PWD.Provider.Name -eq "FileSystem" `
        ? $PWD.ProviderPath `
        : [Environment]::CurrentDirectory

    # GetFullPath rejects values it cannot resolve; represent the missing normalized form as null
    try {
        $full = [IO.Path]::GetFullPath($expanded, $base)
        return $full -ne [IO.Path]::GetPathRoot($full) `
            ? $full.TrimEnd([IO.Path]::DirectorySeparatorChar) `
            : $full
    }
    catch {
        return $null
    }
}

function local:ConvertTo-LocationIdentity {
    <#
    .SYNOPSIS
        Reduces a stored value and what it resolves to into the identity entries are matched on.
    .DESCRIPTION
        Returns the resolved location where the stored value resolves, and the comparable stored value
        where it does not, so an entry carrying an unresolved %...% reference still matches the same
        reference spelled the same way. Case is left as it is: comparison is case-insensitive through
        the operator, not here.
    .PARAMETER StoredValue
        The value as persisted, keeping any %...% reference.
    .PARAMETER Location
        What the stored value resolves to, or null where it does not resolve.
    .OUTPUTS
        The identity of the location.
    .EXAMPLE
        ConvertTo-LocationIdentity -StoredValue "%JAVA_HOME%\bin" -Location $null
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param (
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $StoredValue,
        [AllowEmptyString()]
        [AllowNull()]
        [string] $Location
    )

    return [string]::IsNullOrEmpty($Location) `
        ? (ConvertTo-ComparableLocation -Location $StoredValue) `
        : $Location
}

function local:Get-StoredPathString {
    <#
    .SYNOPSIS
        Joins the stored form of Path entries into a semicolon-separated string.
    .DESCRIPTION
        Returns the entries' StoredValue values joined by the path separator, the form persisted to the
        registry. Used to compare two sets of entries for equality.
    .PARAMETER Entries
        The SystemPathLocation entries to join.
    .OUTPUTS
        The semicolon-separated stored path.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [SystemPathLocation[]] $Entries
    )

    return (
        $Entries `
            | ForEach-Object {
                $_.StoredValue
            }
    ) -join $systemPathSeparator
}

function local:Get-ProcessOnlyPathLocations {
    <#
    .SYNOPSIS
        Returns the current process Path locations that no persisted scope contributes.
    .DESCRIPTION
        Takes a snapshot of the locations the current shell added on top of the persisted Path - a virtual
        environment, or the directory the host injected at startup. Read before a scope Path is written:
        afterwards a location just removed from a scope is indistinguishable from one the session added.
        The locations are split by position, so that rebuilding the process Path preserves precedence:
        Leading holds those in front of the first persisted location, Trailing the rest.
    .OUTPUTS
        A hashtable with a LeadingProcessLocations and a TrailingProcessLocations entry, ready to splat
        into Sync-ProcessPath.
    #>

    [CmdletBinding()]
    param ()

    $effective = @(Get-SystemPath)

    $firstPersisted = 0
    while ($firstPersisted -lt $effective.Count -and $effective[$firstPersisted].Scope -eq "Process") {
        $firstPersisted++
    }

    $leading = @($effective | Select-Object -First $firstPersisted)
    $trailing = @(
        $effective `
            | Select-Object -Skip $firstPersisted `
            | Where-Object {
                $_.Scope -eq "Process"
            }
    )

    return @{
        LeadingProcessLocations  = $leading
        TrailingProcessLocations = $trailing
    }
}

function local:Sync-ProcessPath {
    <#
    .SYNOPSIS
        Rebuilds the current process Path from the persisted scopes.
    .DESCRIPTION
        Sets the current process Path to the machine Path followed by the user Path - the order Windows itself
        builds a process Path in, so a shell easypeasy has touched holds what a fresh shell would. A location
        on both scopes therefore appears once per scope, as Windows leaves it.
        Each location is expanded, and expanded only: nothing expands a %...% reference while a command is
        looked up, so a process Path carrying one would name no folder. The spelling is kept otherwise, as
        Windows keeps it, so the process Path holds each location as its scope Path spells it rather than a
        normalized rewrite of it.
        The Path is derived, never patched, so a location added to or removed from one scope cannot disturb the
        other scope's locations.
        Locations only the session knows are passed in, having been captured before the write, and are put back
        around the persisted ones. Without them the process Path holds the persisted scopes alone.
    .PARAMETER LeadingProcessLocations
        Process-only locations to keep in front of the persisted ones.
    .PARAMETER TrailingProcessLocations
        Process-only locations to keep behind the persisted ones.
    .EXAMPLE
        Sync-ProcessPath

    .EXAMPLE
        $processLocations = Get-ProcessOnlyPathLocations
        # ... persist a scope Path ...
        Sync-ProcessPath @processLocations
    #>

    [CmdletBinding()]
    param (
        [AllowEmptyCollection()]
        [SystemPathLocation[]] $LeadingProcessLocations = @(),
        [AllowEmptyCollection()]
        [SystemPathLocation[]] $TrailingProcessLocations = @()
    )

    $persisted = @(Get-SystemPath -Machine) + @(Get-SystemPath -User)

    $locations = @($LeadingProcessLocations) + $persisted + @($TrailingProcessLocations)

    # StoredValue expanded, not Location: expansion is all Windows does to a scope Path location,
    # so the stored spelling survives into the process Path
    $env:PATH = (
        $locations `
            | ForEach-Object {
                [Environment]::ExpandEnvironmentVariables($_.StoredValue)
            }
    ) -join $systemPathSeparator
}

function local:Add-PathLocation {
    <#
    .SYNOPSIS
        Adds a location to a list of Path entries.
    .DESCRIPTION
        Adds the specified location to the given SystemPathLocation entries and returns the new entries.
        The location is stored verbatim as the entry's StoredValue, keeping any %...% reference, and what it
        resolves to becomes the entry's Location. Presence is decided on the resolved Location, so an entry
        stored as %SystemRoot% matches the literal folder it resolves to.
        Adding is idempotent: if an entry already resolves to the location and -First is not specified,
        the entries are returned unchanged. If it is present and -First is specified, that entry - keeping its
        stored form - is moved to the beginning.
    .PARAMETER Entries
        The current SystemPathLocation entries to add the location to.
    .PARAMETER Location
        Folder location to add, treated as expandable. A %...% reference is kept as indirection.
    .PARAMETER First
        If specified, the location is added to the beginning of the entries.
        Otherwise, it is added to the end. If the location is already present, -First moves it to the beginning.
    .PARAMETER Scope
        Scope stamped on a newly created entry.
    .OUTPUTS
        The modified SystemPathLocation entries.
    .EXAMPLE
        Add-PathLocation -Entries $entries -Location "%JAVA_HOME%\bin" -First $true -Scope User
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [SystemPathLocation[]] $Entries,
        [Parameter(Mandatory)]
        [Alias("Folder")]
        [string] $Location,
        [Parameter(Mandatory)]
        [bool] $First,
        [Parameter(Mandatory)]
        [string] $Scope
    )

    $normalized = ConvertTo-NormalizedLocation -Location $Location
    $identity = ConvertTo-LocationIdentity -StoredValue $Location -Location $normalized

    $present = @(
        $Entries `
            | Where-Object {
                (ConvertTo-LocationIdentity -StoredValue $_.StoredValue -Location $_.Location) -ieq $identity
            }
    )

    if ($present) {
        if (-not $First) {
            # idempotent: the location is already present, leave the entries unchanged
            return @($Entries)
        }

        # move the existing entry to the front, keeping its stored form
        $remaining = @(
            $Entries `
                | Where-Object {
                    (ConvertTo-LocationIdentity -StoredValue $_.StoredValue -Location $_.Location) -ine $identity
                }
        )
        return $present + $remaining
    }

    $newEntry = [SystemPathLocation]::new($Scope, $Location, $normalized)

    return $First `
        ? (@($newEntry) + @($Entries)) `
        : (@($Entries) + @($newEntry))
}

function local:Remove-PathLocation {
    <#
    .SYNOPSIS
        Removes a location from a list of Path entries and returns the entries.
    .DESCRIPTION
        Removes each entry that resolves to the specified location from the given SystemPathLocation entries.
        The location argument is resolved the same way the entries are and matched on their Location, so either
        the stored (%...%) form or the resolved folder removes the entry.
        Removing is idempotent: if no entry resolves to the location, the entries are returned unchanged.
        Repeated and trailing backslashes on the location argument and on the entries are ignored.
    .PARAMETER Entries
        The current SystemPathLocation entries to remove the location from.
    .PARAMETER Location
        Folder location to remove, treated as expandable.
    .OUTPUTS
        The SystemPathLocation entries with the location removed.
    .EXAMPLE
        Remove-PathLocation -Entries $entries -Location "C:\Program Files\Git\bin"
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [SystemPathLocation[]] $Entries,
        [Parameter(Mandatory)]
        [Alias("Folder")]
        [string] $Location
    )

    $normalized = ConvertTo-NormalizedLocation -Location $Location
    $identity = ConvertTo-LocationIdentity -StoredValue $Location -Location $normalized

    return @(
        $Entries `
            | Where-Object {
                (ConvertTo-LocationIdentity -StoredValue $_.StoredValue -Location $_.Location) -ine $identity
            }
    )
}

function local:Remove-PathLocations {
    <#
    .SYNOPSIS
        Removes several locations from a list of Path entries and returns the entries.
    .DESCRIPTION
        Removes each location from what the ones before it left, so a single write covers them all.
        Removing one location is Remove-PathLocation's business; this is the plural of it, and reports
        a location that was not there in a warning of its own.
    .PARAMETER Entries
        The current SystemPathLocation entries to remove the locations from.
    .PARAMETER Locations
        Folder locations to remove, each treated as expandable.
    .PARAMETER Scope
        Scope the entries belong to, named in the warning.
    .OUTPUTS
        The SystemPathLocation entries with the locations removed.
    .EXAMPLE
        Remove-PathLocations -Entries $entries -Locations @("C:\Program Files\Git\bin", "C:\Tools") -Scope User
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [SystemPathLocation[]] $Entries,
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [string[]] $Locations,
        [Parameter(Mandatory)]
        [string] $Scope
    )

    $remaining = @($Entries)

    foreach ($pathLocation in $Locations) {
        $trimmed = @(Remove-PathLocation -Entries $remaining -Location $pathLocation)

        # idempotent: nothing changed means the location is not present
        if ((Get-StoredPathString -Entries $trimmed) -eq (Get-StoredPathString -Entries $remaining)) {
            Write-Warning "Location is not on the system Path. scope: $Scope, location: '$pathLocation'"
        }

        $remaining = $trimmed
    }

    return $remaining
}

function local:Remove-DuplicatePathLocation {
    <#
    .SYNOPSIS
        Removes duplicate locations from a list of Path entries.
    .DESCRIPTION
        Returns the SystemPathLocation entries with duplicates removed, keeping the first occurrence of each
        location. Duplicates are decided on the resolved Location, case-insensitively, so two entries that
        resolve to the same folder count as one. Entries that do not resolve are compared on their stored
        value instead, so a %...% reference no variable resolves still counts as a duplicate of itself.
        The kept entry retains its stored value.
    .PARAMETER Entries
        The SystemPathLocation entries to deduplicate.
    .OUTPUTS
        The SystemPathLocation entries with duplicates removed.
    .EXAMPLE
        Remove-DuplicatePathLocation -Entries $entries
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [SystemPathLocation[]] $Entries
    )

    $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

    return @(
        $Entries `
            | Where-Object {
                $seen.Add((ConvertTo-LocationIdentity -StoredValue $_.StoredValue -Location $_.Location))
            }
    )
}

function local:Get-PathScopeStoredForms {
    <#
    .SYNOPSIS
        Maps each location on a persisted scope Path to the stored forms it occurs as.
    .DESCRIPTION
        Reads the Path environment variable for the given scope in its stored form and returns a
        case-insensitive dictionary mapping each location's identity to a queue of the stored values
        it occurs as, in order. Used to tag the effective Path's locations with their origin scope and
        recover the value each one is persisted as, by consuming the queues in order. A location
        occurring more than once has one queue entry per occurrence.
        The identity is the one every other comparison uses, so a stored value that cannot be resolved
        is keyed on the reference itself rather than dropped: the process Path carries an unresolved
        %...% reference verbatim, which is how it is found again here.
    .PARAMETER Scope
        The scope to read, either "Machine" or "User".
    .OUTPUTS
        A case-insensitive hashtable of location identity to a queue of stored location values.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateSet("Machine", "User")]
        [string] $Scope
    )

    # a PowerShell hashtable literal is case-insensitive and yields $null (not an error) for absent keys
    $storedForms = @{}

    $context = @{ $Scope = $true }
    (Get-EnvironmentVariable @context -Name Path -Expandable -ErrorAction SilentlyContinue) -split $systemPathSeparator `
        | Where-Object {
            $_
        } `
        | ForEach-Object {
            $identity = ConvertTo-LocationIdentity `
                -StoredValue $_ `
                -Location (ConvertTo-NormalizedLocation -Location $_)
            if (-not $storedForms.ContainsKey($identity)) {
                $storedForms[$identity] = [System.Collections.Generic.Queue[string]]::new()
            }
            $storedForms[$identity].Enqueue($_)
        }

    return $storedForms
}

function local:Test-LocationCriteria {
    <#
    .SYNOPSIS
        Tests a location against the exact, substring, wildcard and regex criteria.
    .DESCRIPTION
        Returns $true when the location satisfies every given criterion. Criteria of different kinds, and multiple
        values of the same kind, are combined with AND. An absent criterion is not applied; when no criterion is
        given at all, every location satisfies them.
        Matching is case-insensitive throughout. -Exact is a location and is resolved the way the tested
        location was, so any spelling of the same folder equals it. Repeated and trailing backslashes are
        ignored on the -Contains and -Filter criteria, which are a substring and a wildcard pattern and are
        not resolved; the -Match patterns are applied as given, since a backslash is meaningful in a regular
        expression.
        -Contains, -Filter and -Match are text, not locations, and are tested against both forms the entry
        carries: the value as stored, %...% reference and all, and the location it resolves to. Either form
        satisfies the criterion, so a reference is found by its own spelling as well as by the folder behind
        it. A location that does not resolve carries the stored form alone.
        A leading '\\' is the one run that carries meaning and is kept, holding a UNC root apart from a single
        leading backslash.
    .PARAMETER Location
        The location to test, already resolved, or null when it could not be resolved.
    .PARAMETER StoredValue
        The value the location is stored as, matched on where the location does not resolve.
    .PARAMETER Exact
        Location the tested location must equal, resolved before comparing.
    .PARAMETER Contains
        Substrings the location must contain. Taken literally: wildcard and regex characters carry no meaning.
    .PARAMETER Filter
        Wildcard patterns the location must match.
    .PARAMETER Match
        Regular expressions the location must match.
    .OUTPUTS
        Boolean indicating whether the location satisfies every given criterion.
    .EXAMPLE
        Test-LocationCriteria -Location "C:\Program Files\Git\bin" -Contains "Git"
    .EXAMPLE
        Test-LocationCriteria -Location "C:\Program Files\Git\bin" -Contains "Git", "bin" -Match "\\bin$"
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param (
        [Parameter(Mandatory)]
        [AllowNull()]
        $Location,
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $StoredValue,
        [string] $Exact,
        [string[]] $Contains,
        [string[]] $Filter,
        [string[]] $Match
    )

    # -Exact is a location: it is compared on the identity, which resolves both sides,
    # so any spelling of the same folder equals it
    if ($Exact) {
        $identity = ConvertTo-LocationIdentity -StoredValue $StoredValue -Location $Location
        $exactNormalized = ConvertTo-NormalizedLocation -Location $Exact
        if ($identity -ine (ConvertTo-LocationIdentity -StoredValue $Exact -Location $exactNormalized)) {
            return $false
        }
    }

    # the text criteria below search both forms of the entry: the value as stored, %...% reference and all,
    # and the location it resolves to. A value that does not resolve carries the stored form alone
    $searchedForms = @(ConvertTo-ComparableLocation -Location $StoredValue)
    if (-not [string]::IsNullOrEmpty($Location)) {
        $searchedForms += $Location
    }

    foreach ($substring in $Contains) {
        $comparableSubstring = ConvertTo-ComparableLocation -Location $substring
        $matched = @(
            $searchedForms `
                | Where-Object {
                    $_.Contains($comparableSubstring, [System.StringComparison]::OrdinalIgnoreCase)
                }
        )
        if ($matched.Count -eq 0) {
            return $false
        }
    }

    foreach ($pattern in $Filter) {
        $comparablePattern = ConvertTo-ComparableLocation -Location $pattern
        $matched = @(
            $searchedForms `
                | Where-Object {
                    $_ -ilike $comparablePattern
                }
        )
        if ($matched.Count -eq 0) {
            return $false
        }
    }

    foreach ($pattern in $Match) {
        $matched = @(
            $searchedForms `
                | Where-Object {
                    $_ -imatch $pattern
                }
        )
        if ($matched.Count -eq 0) {
            return $false
        }
    }

    return $true
}

function Get-SystemPath {
    <#
    .SYNOPSIS
        Retrieves the system Path.
    .DESCRIPTION
        Retrieves the system Path, either for the current user, for the local machine
        or the system Path in effect in the current context.
        The Path is returned as an array of SystemPathLocation objects by default, each carrying its Scope, its
        StoredValue - the value as persisted, keeping any %...% reference - and its Location, the absolute
        normalized folder that value resolves to, or null when it cannot be resolved.
        For the effective Path (the default) each location is tagged with its origin scope: 'Machine' or 'User' when the
        location is on the corresponding persisted Path, or 'Process' when it is only on the current shell's Path.
        For -Machine or -User every location carries that scope.
        If the -Join switch is specified, the Path is returned as a semicolon-separated string of the stored
        values instead.
        The -Exact, -Contains, -Filter and -Match criteria select locations. Multiple criteria, of the same kind or
        of different kinds, must all be satisfied. Without any criterion, every location is returned.
        -Contains, -Filter and -Match search both forms a location carries, its stored value and what that
        resolves to, so an entry stored as %ProgramFiles%\Tool is found by the reference as well as by the
        folder behind it.
        A location carrying a %...% reference whose variable is not set is listed with its stored value and an
        empty Location, and is selected on that stored value. The listing marks it the way it marks a
        location naming no existing folder, so it needs no report of its own.
    .PARAMETER Machine
        If specified, the system Path for the local machine is returned.
    .PARAMETER User
        If specified, the system Path for the current user is returned.
    .PARAMETER Effective
        Default; if specified, the effective system Path is returned. The effective system Path is the Path in effect in the current shell.
    .PARAMETER Process
        If specified, only the locations local to the current shell are returned, those on neither persisted Path.
    .PARAMETER Join
        If specified, the system Path is returned as a semicolon-separated string of the stored values.
        Otherwise, it is returned as an array of SystemPathLocation objects.
    .PARAMETER Exact
        Exact folder location; only a location equal to it is returned. It is resolved the way the Path's own
        locations are, so any spelling of the same folder matches; comparison is case-insensitive. A leading
        '\\' holds a UNC root apart from a single leading backslash.
        Aliases: Location, Folder.
    .PARAMETER Contains
        Substrings, positional; only locations containing all of them are returned. Taken literally: wildcard and
        regex characters carry no meaning. Matching is case-insensitive, ignores repeated and trailing
        backslashes, and takes the stored value as well as the location it resolves to.
    .PARAMETER Filter
        Wildcard patterns; only locations matching all of them are returned. Matching is case-insensitive,
        ignores repeated and trailing backslashes, and takes the stored value as well as the location it
        resolves to.
    .PARAMETER Match
        Regular expressions; only locations matching all of them are returned. Matching is case-insensitive and
        takes the stored value as well as the location it resolves to.
        An invalid regular expression is a terminating error.
    .OUTPUTS
        SystemPathLocation objects with a Scope, a StoredValue and a Location property, or a
        semicolon-separated string of the stored values when -Join is specified.
    .NOTES
        Alias: path
    .EXAMPLE
        Get-SystemPath
    .EXAMPLE
        Get-SystemPath -Machine
    .EXAMPLE
        Get-SystemPath -User -Join
    .EXAMPLE
        Get-SystemPath -Process
    .EXAMPLE
        Get-SystemPath -Exact "C:\Program Files\Git\bin"
    .EXAMPLE
        Get-SystemPath Git
    .EXAMPLE
        Get-SystemPath Git bin
    .EXAMPLE
        Get-SystemPath -Filter "*\Git\*"
    .EXAMPLE
        Get-SystemPath -Match "\\Git\\(cmd|bin)$"
    .EXAMPLE
        Get-SystemPath Git -Filter "*\bin" -Machine
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ParameterSetName = "Machine")]
        [switch] $Machine,
        [Parameter(Mandatory, ParameterSetName = "User")]
        [switch] $User,
        [Parameter(ParameterSetName = "Effective")]
        [switch] $Effective,
        [Parameter(Mandatory, ParameterSetName = "Process")]
        [switch] $Process,
        [switch] $Join,
        [Alias("Location", "Folder")]
        [string] $Exact,
        [Parameter(Position = 0, ValueFromRemainingArguments = $true)]
        [string[]] $Contains,
        [string[]] $Filter,
        [ValidRegexAttribute()]
        [string[]] $Match
    )

    $allLocations =
    if ($Machine) {
        # read the stored form so a %...% reference is preserved, then resolve it for Location
        (Get-EnvironmentVariable -Machine -Name Path -Expandable -ErrorAction SilentlyContinue) -split $systemPathSeparator `
            | Where-Object {
                $_
            } `
            | ForEach-Object {
                [SystemPathLocation]::new(
                    "Machine",
                    $_,
                    (ConvertTo-NormalizedLocation -Location $_)
                )
            }
    }
    elseif ($User) {
        (Get-EnvironmentVariable -User -Name Path -Expandable -ErrorAction SilentlyContinue) -split $systemPathSeparator `
            | Where-Object {
                $_
            } `
            | ForEach-Object {
                [SystemPathLocation]::new(
                    "User",
                    $_,
                    (ConvertTo-NormalizedLocation -Location $_)
                )
            }
    }
    else {
        # effective and process: the live shell Path, each location tagged with the persisted scope it originates from.
        # The process Path lists machine locations before user locations, so consume the machine occurrences
        # first, then user; a location on both scopes therefore appears once as Machine and once as User.
        # Windows expands the process block, so the stored %...% form is recovered from the originating scope;
        # a process-only location has no persisted form and keeps the expanded one.
        # An unresolved %...% reference is looked up like any other location: expansion leaves it standing,
        # so the process Path carries the very value its scope stores.
        $machineRemaining = Get-PathScopeStoredForms -Scope Machine
        $userRemaining = Get-PathScopeStoredForms -Scope User

        $env:PATH -split $systemPathSeparator `
            | Where-Object {
                $_
            } `
            | ForEach-Object {
                $normalized = ConvertTo-NormalizedLocation -Location $_
                $identity = ConvertTo-LocationIdentity -StoredValue $_ -Location $normalized
                $scope = "Process"
                $stored = $_

                if ($machineRemaining[$identity].Count -gt 0) {
                    $scope = "Machine"
                    $stored = $machineRemaining[$identity].Dequeue()
                }
                elseif ($userRemaining[$identity].Count -gt 0) {
                    $scope = "User"
                    $stored = $userRemaining[$identity].Dequeue()
                }

                [SystemPathLocation]::new($scope, $stored, $normalized)
            }
    }

    # -Process keeps what the scope tagging above found on neither persisted Path
    if ($Process) {
        $allLocations = $allLocations | Where-Object {
            $_.Scope -eq "Process"
        }
    }

    $criteria = @{
        Exact    = $Exact
        Contains = $Contains
        Filter   = $Filter
        Match    = $Match
    }

    $selectedLocations = $allLocations `
        | Where-Object {
            Test-LocationCriteria -Location $_.Location -StoredValue $_.StoredValue @criteria
        }

    # -Join reproduces the stored form (StoredValue), keeping %...% references
    return $Join `
        ? ((
            $selectedLocations `
                | ForEach-Object {
                    $_.StoredValue
                }
        ) -join $systemPathSeparator) `
        : $selectedLocations
}

New-Alias -Name path -Value Get-SystemPath -ErrorAction SilentlyContinue | Out-Null

function Sync-SystemPath {
    <#
    .SYNOPSIS
        Updates the system Path of the current shell to the persisted Path.
    .DESCRIPTION
        Rebuilds the Path of the current shell from the machine Path followed by the user Path, the way a
        fresh shell is given one, so a change made elsewhere - in the Windows settings, in another shell,
        by an installer - takes effect without opening a new shell. A location carried by both scopes is
        listed once per scope, as Windows leaves it, and every location is resolved to its absolute folder.
        Locations only this shell knows, such as those a virtual environment added, are kept in place.
        A location no scope carries any more is one of those as far as this shell can tell, so a removal
        made elsewhere is not picked up - open a new shell for that. An addition is.
        The system-path functions rebuild the Path themselves, so this is only needed for a change
        easypeasy did not make.
    .NOTES
        Alias: syncpath
    .EXAMPLE
        Sync-SystemPath
    .EXAMPLE
        syncpath
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param ()

    if ($PSCmdlet.ShouldProcess("system Path of the current shell", "Update to the persisted Path")) {
        # nothing is being written, so what is process-only now is what should stay process-only
        $processLocations = Get-ProcessOnlyPathLocations

        Sync-ProcessPath @processLocations
    }
}

New-Alias -Name syncpath -Value Sync-SystemPath -ErrorAction SilentlyContinue | Out-Null

function local:Set-SystemPath {
    <#
    .SYNOPSIS
        Modifies the system Path.
    .DESCRIPTION
        Sets the system Path to the given SystemPathLocation entries, either for the current user or for the
        local machine. The entries' StoredValue is persisted, so a %...% reference is kept as indirection;
        the Path is written as an expandable (REG_EXPAND_SZ) value.
        The current process Path is rebuilt from both scopes afterwards, keeping the locations only the session
        knows, which are captured before the write.
    .PARAMETER Entries
        The SystemPathLocation entries to persist.
    .PARAMETER Machine
        If specified, the system Path for the local machine is used.
    .PARAMETER User
        If specified, the system Path for the current user is used.
    .EXAMPLE
        Set-SystemPath -Entries $entries -Machine
    .EXAMPLE
        Set-SystemPath -Entries $entries -User
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [SystemPathLocation[]] $Entries,
        [Parameter(Mandatory, ParameterSetName = "Machine")]
        [switch] $Machine,
        [Parameter(Mandatory, ParameterSetName = "User")]
        [switch] $User
    )

    # the backup location goes to the caller of Backup-SystemPath, not into this function's output
    Backup-SystemPath | Out-Null

    $context = $Machine `
        ? @{ Machine = $true } `
        : @{ User = $true }

    # persist the stored form so %...% references survive, as an expandable (REG_EXPAND_SZ) value
    $value = (
        $Entries `
            | ForEach-Object {
                $_.StoredValue
            }
    ) -join $systemPathSeparator

    # capture what only the session knows before the write, while a removed location is still
    # distinguishable from one the session added
    $processLocations = Get-ProcessOnlyPathLocations

    # the write stays quiet about the Path's own references: the command that took the location reported them
    Set-EnvironmentVariable @context -Name Path -Value $value -Expandable -WarningAction SilentlyContinue

    # derive the process Path from both scopes; runs after the write, so it is the authoritative one
    Sync-ProcessPath @processLocations
}

function Add-SystemPathLocation {
    <#
    .SYNOPSIS
        Adds a location to the system Path.
    .DESCRIPTION
        Adds the specified locations to the system Path, either for the current user or for the local machine.
        Locations are taken as arguments or from the pipeline, and added in one write, keeping the order
        they were given in.
        A location naming no existing folder is reported as a terminating error and nothing is written,
        unless -Force is given. The location is checked resolved.
        A %...% reference whose variable is not set names the variable in a warning of its own and is added
        anyway, keeping the reference as indirection: what it resolves to once the variable is set is not
        this command's business.
        Adding is idempotent: a location already present leaves the Path unchanged and is reported in a
        warning of its own.
        If the location is already present and -First is specified, it is moved to the beginning of the Path.
    .PARAMETER Location
        Folder locations to add to the system Path. Taken from the pipeline as well.
    .PARAMETER Machine
        If specified, the system Path for the local machine is used.
    .PARAMETER User
        If specified, the system Path for the current user is used. (Default.)
    .PARAMETER First
        If specified, the location is added to the beginning of the Path. Otherwise, it is added to the end.
        If the location is already present, -First moves it to the beginning.
        Alias: Front.
    .PARAMETER Force
        Add the location even when it names no existing folder, e.g. to put a folder on the Path
        before whatever creates it runs.
    .NOTES
        Alias: addpath
        Default scope is User.
        An unelevated machine write prompts for elevation once and runs the whole addition elevated.
    .EXAMPLE
        Add-SystemPathLocation -Location "C:\Program Files\Git\bin"
    .EXAMPLE
        Add-SystemPathLocation -Location "C:\Program Files\Git\bin" -Machine
    .EXAMPLE
        Add-SystemPathLocation -Location "C:\Program Files\Git\bin" -User
    .EXAMPLE
        Add-SystemPathLocation -Location "C:\Program Files\Git\bin" -First
    .EXAMPLE
        Add-SystemPathLocation -Location "%JAVA_HOME%\bin" -Force
    .EXAMPLE
        Get-SystemPath -Contains Git -User | Add-SystemPathLocation -Machine
    #>

    # pipeline input picks parameter set before first object arrives, so default set needed
    [CmdletBinding(DefaultParameterSetName = "User", SupportsShouldProcess)]
    param (
        [Parameter(Mandatory, Position = 0, ValueFromPipeline)]
        [Alias("Folder")]
        [string[]] $Location,
        [Alias("Front")]
        [switch] $First,
        [Parameter(Mandatory, ParameterSetName = "Machine")]
        [switch] $Machine,
        [Parameter(ParameterSetName = "User")]
        [switch] $User,
        [switch] $Force
    )

    begin {
        # fail fast: a machine write that cannot elevate stops before anything is read or written
        if ($Machine -and -not (Test-Elevated)) {
            Assert-SudoAvailable
        }

        $locations = @()
    }

    process {
        $locations += $Location
    }

    end {
        # an empty pipeline has nothing to add
        if (-not $locations) {
            return
        }

        # every location is checked before anything is read or written, so -WhatIf reports the error a real
        # run would hit and one location naming no folder leaves the Path untouched
        foreach ($pathLocation in $locations) {
            $resolvedLocation = ConvertTo-NormalizedLocation -Location $pathLocation

            # a reference no variable resolves is reported and added anyway, keeping the reference as
            # indirection: what it will resolve to once the variable is set is no business of this command
            $unresolved = @(Get-UnresolvedVariableName -Value $pathLocation)
            Write-UnresolvedVariableWarning -Value $pathLocation

            if (-not $Force -and -not $unresolved -and ($null -eq $resolvedLocation -or -not (Test-Path -LiteralPath $resolvedLocation -PathType Container))) {
                $detail =
                if ($null -eq $resolvedLocation) {
                    "location: '$pathLocation', resolved: `$null"
                }
                elseif ($resolvedLocation -ceq $pathLocation) {
                    "location: '$pathLocation'"
                }
                else {
                    "location: '$pathLocation', resolved: '$resolvedLocation'"
                }

                Write-Error "Location is not an existing folder, use -Force to add it anyway. $detail" `
                    -ErrorId "PathLocationNotFound" `
                    -Category ObjectNotFound `
                    -TargetObject $pathLocation `
                    -ErrorAction Stop
            }
        }

        $context = $Machine `
            ? @{ Machine = $true } `
            : @{ User = $true }
        $scope = $Machine `
            ? "Machine" `
            : "User"

        $currentEntries = @(Get-SystemPath @context)

        # -First puts each location before the ones added so far, so adding them back to front leaves the
        # given order at the beginning of the Path
        $ordered = $First `
            ? ($locations[($locations.Count - 1)..0]) `
            : $locations

        # each location is added to what the ones before it left, so one write covers them all
        $newEntries = $currentEntries
        foreach ($pathLocation in $ordered) {
            $extendedEntries = @(Add-PathLocation -Entries $newEntries -Location $pathLocation -First:$First -Scope $scope)

            # idempotent: nothing changed means the location is already present
            if ((Get-StoredPathString -Entries $extendedEntries) -eq (Get-StoredPathString -Entries $newEntries)) {
                Write-Warning "Location is already on the system Path. scope: $scope, location: '$pathLocation'"
            }

            $newEntries = $extendedEntries
        }

        # no location changed anything: the Path is left unchanged
        if ((Get-StoredPathString -Entries $newEntries) -eq (Get-StoredPathString -Entries $currentEntries)) {
            return
        }

        if (-not $PSCmdlet.ShouldProcess(($locations -join ", "), "Add location to system Path")) {
            return
        }

        # when not already elevated, the whole addition runs in an elevated session instead of in-process,
        # so the Path is read and written on the same side of the boundary and never crosses it
        if ($Machine -and -not (Test-Elevated)) {
            $processLocations = Get-ProcessOnlyPathLocations

            $command = @("Add-SystemPathLocation", "-Location", $locations, "-Machine")
            if ($First) { $command += "-First" }
            if ($Force) { $command += "-Force" }
            Invoke-Elevated $command

            Sync-ProcessPath @processLocations
            return
        }

        # Set-SystemPath rebuilds the process Path, so the new location takes effect immediately
        Set-SystemPath @context -Entries $newEntries
    }
}

function Remove-SystemPathLocation {
    <#
    .SYNOPSIS
        Removes a location from the system Path.
    .DESCRIPTION
        Removes the specified locations from the system Path, either for the current user or for the local machine.
        Locations are taken as arguments or from the pipeline, and each scope is written once.
        A location given as text is removed from the scope the switches name, the current user by default.
        A SystemPathLocation piped in - what Get-SystemPath returns - is removed from the scope it carries,
        so a location an effective read found on both scopes is removed from both. An entry on neither
        persisted Path, its scope being Process, is dropped from the Path of the current shell alone.
        -Machine and -User select which piped entries are removed rather than where from; an entry of
        another scope is left alone.
        Removing is idempotent: a location that is not present leaves the Path unchanged and is reported
        in a warning of its own.
    .PARAMETER Location
        Folder locations to remove from the system Path, as text. Taken from the pipeline as well.
    .PARAMETER Entry
        System Path locations to remove, each from the scope it carries. Taken from the pipeline.
    .PARAMETER Machine
        With -Location, the system Path for the local machine is written.
        With -Entry, the entries of the machine scope are removed and the others left alone.
    .PARAMETER User
        With -Location, the system Path for the current user is written. (Default.)
        With -Entry, the entries of the user scope are removed and the others left alone.
    .NOTES
        Alias: rmpath
        Default scope of a location given as text is User.
        An unelevated machine write prompts for elevation once and runs the whole removal elevated.
        Inside ForEach-Object an entry passed as $_ arrives as its location alone; pass it as -Entry $_
        to keep its scope.
    .EXAMPLE
        Remove-SystemPathLocation -Location "C:\Program Files\Git\bin"
    .EXAMPLE
        Remove-SystemPathLocation -Location "C:\Program Files\Git\bin" -Machine
    .EXAMPLE
        Remove-SystemPathLocation -Location "C:\Program Files\Git\bin" -User
    .EXAMPLE
        Get-SystemPath -Contains Git | Remove-SystemPathLocation
    .EXAMPLE
        Get-SystemPath -Contains Git | Remove-SystemPathLocation -User
    #>

    # the scope switches cannot be shared across the two pipeline parameters - a parameter set holds one
    # of those - so each switch joins both sets, and the type of the first piped object picks the set
    [CmdletBinding(DefaultParameterSetName = "LocationUser", SupportsShouldProcess)]
    param (
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ParameterSetName = "LocationUser")]
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ParameterSetName = "LocationMachine")]
        [Alias("Folder")]
        [string[]] $Location,
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = "EntryUser")]
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = "EntryMachine")]
        [SystemPathLocation[]] $Entry,
        [Parameter(Mandatory, ParameterSetName = "LocationMachine")]
        [Parameter(Mandatory, ParameterSetName = "EntryMachine")]
        [switch] $Machine,
        [Parameter(ParameterSetName = "LocationUser")]
        [Parameter(ParameterSetName = "EntryUser")]
        [switch] $User
    )

    begin {
        # fail fast: a machine write that cannot elevate stops before anything is read or written.
        # A piped entry decides its own scope, so that machine write is only known once the pipeline ends
        if ($Machine -and $PSCmdlet.ParameterSetName -eq "LocationMachine" -and -not (Test-Elevated)) {
            Assert-SudoAvailable
        }

        $locations = @()
        $entries = @()
    }

    process {
        # the type of the input picked the parameter set, so only one of the two is bound
        if ($null -ne $Location) {
            $locations += $Location
        }
        if ($null -ne $Entry) {
            $entries += $Entry
        }
    }

    end {
        # an empty pipeline has nothing to remove
        if (-not $locations -and -not $entries) {
            return
        }

        # a switch filters the piped entries; without one each entry names its own scope
        $requestedScope = $Machine `
            ? "Machine" `
            : ($User ? "User" : $null)

        # every input is reduced to a location and the scope to remove it from
        $requested = @()

        foreach ($pathLocation in $locations) {
            $requested += @{ Location = $pathLocation; Scope = $requestedScope ?? "User" }
        }

        foreach ($pathEntry in $entries) {
            if ($requestedScope -and $pathEntry.Scope -ne $requestedScope) {
                Write-Verbose "Entry is of another scope, left alone. scope: $($pathEntry.Scope), location: '$($pathEntry.StoredValue)'"
                continue
            }

            $requested += @{ Location = $pathEntry.StoredValue; Scope = $pathEntry.Scope }
        }

        # every entry was filtered out: nothing to remove
        if (-not $requested) {
            return
        }

        # a removal takes a value off the Path; a reference it carries that no variable resolves is the
        # reason to be rid of it, not something to report
        $machineLocations = @($requested | Where-Object { $_.Scope -eq "Machine" } | ForEach-Object { $_.Location })
        $userLocations = @($requested | Where-Object { $_.Scope -eq "User" } | ForEach-Object { $_.Location })
        $shellLocations = @($requested | Where-Object { $_.Scope -eq "Process" } | ForEach-Object { $_.Location })

        # a scope no location names is left unread
        $machineEntries = $machineLocations `
            ? @(Get-SystemPath -Machine) `
            : @()
        $userEntries = $userLocations `
            ? @(Get-SystemPath -User) `
            : @()

        $machineTrimmed = @(Remove-PathLocations -Entries $machineEntries -Locations $machineLocations -Scope Machine)
        $userTrimmed = @(Remove-PathLocations -Entries $userEntries -Locations $userLocations -Scope User)

        # a process-only location is on no persisted Path: it goes from the Path of this shell alone,
        # from whichever side of the persisted ones it sits on
        $shellLeading = @()
        $shellTrailing = @()
        $shellChanged = $false
        if ($shellLocations) {
            $processLocations = Get-ProcessOnlyPathLocations
            $shellLeading = @($processLocations.LeadingProcessLocations)
            $shellTrailing = @($processLocations.TrailingProcessLocations)

            # both sides are filtered, but only the two together tell whether a location was there at
            # all, so the combined run reports the warnings and each side is then filtered in silence
            $shellEntries = @($shellLeading) + @($shellTrailing)
            $shellTrimmed = @(Remove-PathLocations -Entries $shellEntries -Locations $shellLocations -Scope Process)
            $shellChanged = (Get-StoredPathString -Entries $shellTrimmed) -ne (Get-StoredPathString -Entries $shellEntries)

            $shellLeading = @(Remove-PathLocations -Entries $shellLeading -Locations $shellLocations -Scope Process -WarningAction SilentlyContinue)
            $shellTrailing = @(Remove-PathLocations -Entries $shellTrailing -Locations $shellLocations -Scope Process -WarningAction SilentlyContinue)
        }

        $machineChanged = (Get-StoredPathString -Entries $machineTrimmed) -ne (Get-StoredPathString -Entries $machineEntries)

        # fail fast: a machine write that cannot elevate stops before the first gate is asked and before
        # any Path is written. A piped entry names its own scope, so this is where that write is known
        if ($machineChanged -and -not (Test-Elevated)) {
            Assert-SudoAvailable
        }

        # as in Remove-DuplicateSystemPathLocations: every gate is asked before any write, so -WhatIf
        # reports every scope a real run would write
        $writeMachine = $machineChanged `
            -and $PSCmdlet.ShouldProcess("machine", "Remove location from system Path")
        $writeUser = (Get-StoredPathString -Entries $userTrimmed) -ne (Get-StoredPathString -Entries $userEntries) `
            -and $PSCmdlet.ShouldProcess("user", "Remove location from system Path")
        $writeShell = $shellChanged `
            -and $PSCmdlet.ShouldProcess("current shell", "Remove location from system Path")

        # the machine Path goes first: an elevation the user declines ends the command with the other
        # scopes whole, rather than half applied
        if ($writeMachine -and -not (Test-Elevated)) {
            # as in Add-SystemPathLocation: an unelevated machine write runs the whole removal elevated
            $processLocations = Get-ProcessOnlyPathLocations
            Invoke-Elevated Remove-SystemPathLocation -Location $machineLocations -Machine
            Sync-ProcessPath @processLocations
        }
        elseif ($writeMachine) {
            # Set-SystemPath rebuilds the process Path from both scopes, so the location stays available
            # when the other scope still carries it
            Set-SystemPath -Machine -Entries $machineTrimmed
        }

        if ($writeUser) {
            Set-SystemPath -User -Entries $userTrimmed
        }

        # last, so the snapshot each persisted write takes of the process-only locations is untouched
        if ($writeShell) {
            Sync-ProcessPath -LeadingProcessLocations $shellLeading -TrailingProcessLocations $shellTrailing
        }
    }
}

function Remove-DuplicateSystemPathLocations {
    <#
    .SYNOPSIS
        Removes duplicate locations from the system Path.
    .DESCRIPTION
        Removes duplicate locations from the system Path, for the local machine, for the current user, or both combined.
        Within a scope, only the first occurrence of each location is kept.
        When both scopes are cleaned (the default, when neither -Machine nor -User is specified), a location present on
        both scopes is kept on only one: the machine Path by default, or the user Path if -KeepUser is specified.
        Removing duplicates is idempotent: if there are no duplicates, the Path is left unchanged.
        A run that changes the machine Path elevates through User Account Control when the session is not
        already elevated: the whole cleanup runs in the elevated session, so it is applied as a whole or
        not at all.
    .PARAMETER Machine
        If specified, only the local machine system Path is cleaned.
    .PARAMETER User
        If specified, only the current user system Path is cleaned.
    .PARAMETER KeepMachine
        Default. When cleaning both scopes, a location present on both is kept on the machine Path and removed from the user Path.
    .PARAMETER KeepUser
        When cleaning both scopes, a location present on both is kept on the user Path and removed from the machine Path.
    .NOTES
        Alias: deduppath
        An unelevated run that changes the machine Path prompts for elevation once, before either Path
        is written. Each scope Path is written on its own, leaving one backup file per write.
    .EXAMPLE
        Remove-DuplicateSystemPathLocations
    .EXAMPLE
        Remove-DuplicateSystemPathLocations -Machine
    .EXAMPLE
        Remove-DuplicateSystemPathLocations -KeepUser
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Mandatory, ParameterSetName = "Machine")]
        [switch] $Machine,
        [Parameter(Mandatory, ParameterSetName = "User")]
        [switch] $User,

        # -KeepMachine and -KeepUser decide a cross-scope duplicate, so they belong to the both-scopes set alone
        [Parameter(ParameterSetName = "BothScopes")]
        [switch] $KeepMachine,
        [Parameter(ParameterSetName = "BothScopes")]
        [switch] $KeepUser
    )

    if ($KeepMachine -and $KeepUser) {
        Write-Error "Specify only one of -KeepMachine and -KeepUser." `
            -ErrorId "ConflictingKeepScope" `
            -Category InvalidArgument `
            -TargetObject "-KeepMachine, -KeepUser" `
            -ErrorAction Stop
    }

    # fail fast: a machine cleanup that cannot elevate stops before any Path is read
    if ($Machine -and -not (Test-Elevated)) {
        Assert-SudoAvailable
    }

    # clean both scopes when neither scope switch is given
    if (-not $Machine -and -not $User) {
        $machineEntries = @(Get-SystemPath -Machine)
        $userEntries = @(Get-SystemPath -User)

        $machineDeduped = @(Remove-DuplicatePathLocation -Entries $machineEntries)
        $userDeduped = @(Remove-DuplicatePathLocation -Entries $userEntries)

        # cross-scope: drop from the non-kept scope every location present in the kept scope
        if ($KeepUser) {
            foreach ($entry in $userDeduped) {
                if ($null -ne $entry.Location) {
                    $machineDeduped = @(Remove-PathLocation -Entries $machineDeduped -Location $entry.Location)
                }
            }
        }
        else {
            foreach ($entry in $machineDeduped) {
                if ($null -ne $entry.Location) {
                    $userDeduped = @(Remove-PathLocation -Entries $userDeduped -Location $entry.Location)
                }
            }
        }

        $machineChanged = (Get-StoredPathString -Entries $machineDeduped) -ne (Get-StoredPathString -Entries $machineEntries)

        # fail fast: a cleanup that cannot elevate stops before the first gate is asked
        if ($machineChanged -and -not (Test-Elevated)) {
            Assert-SudoAvailable
        }

        # both gates are asked before either write, so -WhatIf reports every scope a real run would write
        $writeMachine = $machineChanged `
            -and $PSCmdlet.ShouldProcess("machine", "Remove duplicate locations from system Path")
        $writeUser = (Get-StoredPathString -Entries $userDeduped) -ne (Get-StoredPathString -Entries $userEntries) `
            -and $PSCmdlet.ShouldProcess("user", "Remove duplicate locations from system Path")

        # when not already elevated, the whole cleanup runs in an elevated session instead of in-process:
        # one prompt for both writes, and no half-applied cleanup when it is declined
        if ($writeMachine -and -not (Test-Elevated)) {
            # capture what only the session knows before the elevated writes, while a removed location is
            # still distinguishable from one the session added
            $processLocations = Get-ProcessOnlyPathLocations

            if ($KeepUser) {
                Invoke-Elevated Remove-DuplicateSystemPathLocations -KeepUser
            }
            else {
                Invoke-Elevated Remove-DuplicateSystemPathLocations -KeepMachine
            }

            # the elevated session synced its own process Path; this one derives its own from both scopes
            Sync-ProcessPath @processLocations
            return
        }

        if ($writeMachine) {
            Set-SystemPath -Machine -Entries $machineDeduped
        }

        if ($writeUser) {
            Set-SystemPath -User -Entries $userDeduped
        }
    }
    else {
        $context = $Machine `
            ? @{ Machine = $true } `
            : @{ User = $true }
        $scope = $Machine `
            ? "machine" `
            : "user"

        $currentEntries = @(Get-SystemPath @context)
        $deduped = @(Remove-DuplicatePathLocation -Entries $currentEntries)

        if (-not ((Get-StoredPathString -Entries $deduped) -ne (Get-StoredPathString -Entries $currentEntries) `
                    -and $PSCmdlet.ShouldProcess($scope, "Remove duplicate locations from system Path"))) {
            return
        }

        # as above: an unelevated machine cleanup runs in an elevated session, passing no Path across
        if ($Machine -and -not (Test-Elevated)) {
            $processLocations = Get-ProcessOnlyPathLocations
            Invoke-Elevated Remove-DuplicateSystemPathLocations -Machine
            Sync-ProcessPath @processLocations
            return
        }

        Set-SystemPath @context -Entries $deduped
    }
    # Set-SystemPath rebuilds the process Path from the deduplicated scopes
}

function Optimize-SystemPath {
    <#
    .SYNOPSIS
        Cleans up the system Path.
    .DESCRIPTION
        Cleans up the system Path of the local machine and of the current user.
        So far, cleaning up removes duplicate locations, as Remove-DuplicateSystemPathLocations does:
        within a scope only the first occurrence of each location is kept, and a location present on both
        scopes is kept on the machine Path.
        A run that changes the machine Path elevates through User Account Control when the session is not
        already elevated.
        Later versions clean up more, so a run does more than it does today. Do not rely on the current
        set of steps: adding one is not a breaking change. Where the exact behavior matters, call the
        single-purpose commands, e.g. Remove-DuplicateSystemPathLocations.
    .NOTES
        Alias: cleanpath
    .EXAMPLE
        Optimize-SystemPath
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param ()

    # -WhatIf and -Confirm reach the cleanup steps through the preference variables they set
    Remove-DuplicateSystemPathLocations
}

function Move-SystemPathLocation {
    <#
    .SYNOPSIS
        Moves a location between the machine and user system Paths.
    .DESCRIPTION
        Moves the specified locations from the machine system Path to the user system Path (-ToUser),
        or from the user system Path to the machine system Path (-ToMachine).
        Locations are taken as arguments or from the pipeline, and moved in one write per scope.
        Each location is removed from the source Path and added to the target Path.
        A location that is not on the source Path - whether it is already on the target Path or on neither -
        is not moved and is reported in a warning of its own.
        A %...% reference whose variable is not set names the variable in a warning of its own and is moved
        anyway, keeping the reference as indirection.
        A move that changes the machine Path elevates through User Account Control when the session is not
        already elevated: the whole move runs in the elevated session, so it is applied as a whole or not
        at all. Moving to the machine Path a location the machine Path already holds changes the user Path
        alone and does not elevate.
    .PARAMETER Location
        Folder locations to move, positional. Taken from the pipeline as well.
    .PARAMETER ToUser
        Move the location from the machine system Path to the user system Path.
    .PARAMETER ToMachine
        Move the location from the user system Path to the machine system Path.
    .NOTES
        Alias: movepath
        An unelevated move prompts for elevation once, before either Path is written.
        Both scope Paths are written, each on its own, leaving one backup file per write.
    .EXAMPLE
        Move-SystemPathLocation "C:\Program Files\Git\bin" -ToUser
    .EXAMPLE
        Move-SystemPathLocation "C:\Program Files\Git\bin" -ToMachine
    .EXAMPLE
        Get-SystemPath -Contains Git -Machine | Move-SystemPathLocation -ToUser
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Mandatory, Position = 0, ValueFromPipeline)]
        [Alias("Folder")]
        [string[]] $Location,
        [Parameter(Mandatory, ParameterSetName = "ToUser")]
        [switch] $ToUser,
        [Parameter(Mandatory, ParameterSetName = "ToMachine")]
        [switch] $ToMachine
    )

    begin {
        $locations = @()
    }

    process {
        $locations += $Location
    }

    end {
        # an empty pipeline has nothing to move
        if (-not $locations) {
            return
        }

        if ($ToUser) {
            $source = @{ Machine = $true }; $sourceName = "machine"
            $target = @{ User = $true }; $targetName = "user"
        }
        else {
            $source = @{ User = $true }; $sourceName = "user"
            $target = @{ Machine = $true }; $targetName = "machine"
        }

        # as in Add-SystemPathLocation: a move puts the location on a scope that did not carry it, so a
        # reference no variable resolves is reported the way an addition reports it
        foreach ($pathLocation in $locations) {
            Write-UnresolvedVariableWarning -Value $pathLocation
        }

        $sourceEntries = @(Get-SystemPath @source)
        $targetEntries = @(Get-SystemPath @target)

        # each location moves off what the ones before it left, so one write per scope covers them all
        $newSource = $sourceEntries
        $newTarget = $targetEntries
        $targetChanged = $false

        foreach ($pathLocation in $locations) {
            $normalized = ConvertTo-NormalizedLocation -Location $pathLocation
            $identity = ConvertTo-LocationIdentity -StoredValue $pathLocation -Location $normalized

            $moved = @(
                $newSource `
                    | Where-Object {
                        (ConvertTo-LocationIdentity -StoredValue $_.StoredValue -Location $_.Location) -ieq $identity
                    }
            )
            $onTarget = @(
                $newTarget `
                    | Where-Object {
                        (ConvertTo-LocationIdentity -StoredValue $_.StoredValue -Location $_.Location) -ieq $identity
                    }
            )

            # not on the source Path: nothing to move
            if ($moved.Count -eq 0) {
                $reason = $onTarget `
                    ? "already on the $targetName Path" `
                    : "not on the $sourceName Path"
                Write-Warning "Nothing to move. reason: $reason, location: '$pathLocation'"
                continue
            }

            $newSource = @(Remove-PathLocation -Entries $newSource -Location $pathLocation)

            # append the moved entry, keeping its stored (%...%) form, unless the target already has it
            if ($onTarget.Count -eq 0) {
                $newTarget = @($newTarget) + @($moved[0])
                $targetChanged = $true
            }
            else {
                Write-Verbose "Target Path already holds location. scope: $targetName, location: '$pathLocation'"
            }
        }

        # no location was on the source Path: neither Path is written
        if ((Get-StoredPathString -Entries $newSource) -eq (Get-StoredPathString -Entries $sourceEntries)) {
            return
        }

        # the machine Path is written whenever it is the source, and as the target only when it changes
        $writesMachine = $ToUser -or $targetChanged

        # fail fast: a move that cannot elevate stops before the gate is asked and before any Path is written
        if ($writesMachine -and -not (Test-Elevated)) {
            Assert-SudoAvailable
        }

        if (-not $PSCmdlet.ShouldProcess(($locations -join ", "), "Move location from the $sourceName to the $targetName system Path")) {
            return
        }

        # when not already elevated, the whole move runs in an elevated session instead of in-process:
        # one prompt for both writes, and no half-applied move when it is declined
        if ($writesMachine -and -not (Test-Elevated)) {
            # capture what only the session knows before the elevated writes, while a removed location is
            # still distinguishable from one the session added
            $processLocations = Get-ProcessOnlyPathLocations

            if ($ToUser) {
                Invoke-Elevated Move-SystemPathLocation -Location $locations -ToUser
            }
            else {
                Invoke-Elevated Move-SystemPathLocation -Location $locations -ToMachine
            }

            # the elevated session synced its own process Path; this one derives its own from both scopes
            Sync-ProcessPath @processLocations
            return
        }

        # target first, so a failing write leaves the location on its source Path rather than on neither
        if ($targetChanged) {
            Set-SystemPath @target -Entries $newTarget
        }

        Set-SystemPath @source -Entries $newSource
    }
}

function Test-SystemPathLocation {
    <#
    .SYNOPSIS
        Tests whether a location is on the system Path.
    .DESCRIPTION
        Returns $true if the specified location is present on the system Path, either for the current user,
        for the local machine or the system Path in effect in the current context.
        Locations are taken as arguments or from the pipeline, one result reported per location.
        The location is compared exactly and case-insensitively, resolved the way the Path's own locations
        are, so any spelling of the same folder matches; a substring, a wildcard pattern or a regular
        expression selects nothing. Use Get-SystemPath -Contains, -Filter or -Match for those.
    .PARAMETER Location
        Exact folder locations to look for, positional. Taken from the pipeline as well.
        Each is resolved before comparing, so any spelling of the same folder matches;
        comparison is case-insensitive.
        Alias: Folder.
    .PARAMETER Machine
        If specified, the system Path for the local machine is searched.
    .PARAMETER User
        If specified, the system Path for the current user is searched.
    .PARAMETER Effective
        Default; if specified, the system Path in effect in the current shell is searched.
    .PARAMETER Process
        If specified, only the locations local to the current shell are searched, those on neither persisted Path.
    .OUTPUTS
        Boolean indicating whether the location is present, one per location.
    .NOTES
        Alias: testpath
    .EXAMPLE
        Test-SystemPathLocation "C:\Program Files\Git\bin"
    .EXAMPLE
        Test-SystemPathLocation "C:\Program Files\Git\bin" -Machine
    .EXAMPLE
        Test-SystemPathLocation -Location "C:\Temp\session" -Process
    .EXAMPLE
        Get-SystemPath -Contains Git | Test-SystemPathLocation -Machine
    #>

    # the default set has to be named: with the location coming from the pipeline it is not yet bound when
    # the set is resolved, leaving the scope switches to decide it alone
    [CmdletBinding(DefaultParameterSetName = "Effective")]
    [OutputType([bool])]
    param (
        [Parameter(Mandatory, Position = 0, ValueFromPipeline)]
        [Alias("Folder")]
        [string[]] $Location,
        [Parameter(Mandatory, ParameterSetName = "Machine")]
        [switch] $Machine,
        [Parameter(Mandatory, ParameterSetName = "User")]
        [switch] $User,
        [Parameter(ParameterSetName = "Effective")]
        [switch] $Effective,
        [Parameter(Mandatory, ParameterSetName = "Process")]
        [switch] $Process
    )

    process {
        foreach ($pathLocation in $Location) {
            # the exact comparison lives in Get-SystemPath -Exact; the scope switch picks its parameter set
            $found =
            if ($Machine) {
                Get-SystemPath -Exact $pathLocation -Machine
            }
            elseif ($User) {
                Get-SystemPath -Exact $pathLocation -User
            }
            elseif ($Process) {
                Get-SystemPath -Exact $pathLocation -Process
            }
            else {
                Get-SystemPath -Exact $pathLocation -Effective
            }

            @($found).Count -gt 0
        }
    }
}

New-Alias -Name addpath -Value Add-SystemPathLocation -ErrorAction SilentlyContinue | Out-Null
New-Alias -Name rmpath -Value Remove-SystemPathLocation -ErrorAction SilentlyContinue | Out-Null
New-Alias -Name deduppath -Value Remove-DuplicateSystemPathLocations -ErrorAction SilentlyContinue `
    | Out-Null
New-Alias -Name cleanpath -Value Optimize-SystemPath -ErrorAction SilentlyContinue | Out-Null
New-Alias -Name movepath -Value Move-SystemPathLocation -ErrorAction SilentlyContinue | Out-Null
New-Alias -Name testpath -Value Test-SystemPathLocation -ErrorAction SilentlyContinue | Out-Null