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
    }

    <#
    .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.
    .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 -ErrorAction SilentlyContinue) + @(Get-SystemPath -User -ErrorAction SilentlyContinue)

    $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-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 resolved location's comparison key 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.
        A stored value that cannot be resolved is omitted because it has no comparison key.
    .PARAMETER Scope
        The scope to read, either "Machine" or "User".
    .OUTPUTS
        A case-insensitive hashtable of location key 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 {
            $normalized = ConvertTo-NormalizedLocation -Location $_
            if ($null -eq $normalized) {
                return
            }
            if (-not $storedForms.ContainsKey($normalized)) {
                $storedForms[$normalized] = [System.Collections.Generic.Queue[string]]::new()
            }
            $storedForms[$normalized].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.
        A location that does not resolve is matched on its stored value, so a %...% reference no variable
        resolves is still found by the criteria naming it.
        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
    )

    # every criterion below runs against the resolved location,
    # or against the stored value where nothing resolves, e.g. an unset %...% reference
    $searched = ConvertTo-LocationIdentity -StoredValue $StoredValue -Location $Location

    if ($Exact) {
        $exactNormalized = ConvertTo-NormalizedLocation -Location $Exact
        if ($searched -ine (ConvertTo-LocationIdentity -StoredValue $Exact -Location $exactNormalized)) {
            return $false
        }
    }

    foreach ($substring in $Contains) {
        $comparableSubstring = ConvertTo-ComparableLocation -Location $substring
        if (-not $searched.Contains($comparableSubstring, [System.StringComparison]::OrdinalIgnoreCase)) {
            return $false
        }
    }

    foreach ($pattern in $Filter) {
        if ($searched -inotlike (ConvertTo-ComparableLocation -Location $pattern)) {
            return $false
        }
    }

    foreach ($pattern in $Match) {
        if ($searched -inotmatch $pattern) {
            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.
        A location carrying a %...% reference whose variable is not set is listed with its stored value and an
        empty Location, names the variable in an error of its own, and is selected on that stored value.
    .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 and ignores repeated and trailing
        backslashes.
    .PARAMETER Filter
        Wildcard patterns; only locations matching all of them are returned. Matching is case-insensitive and
        ignores repeated and trailing backslashes.
    .PARAMETER Match
        Regular expressions; only locations matching all of them are returned. Matching is case-insensitive.
        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.
        $machineRemaining = Get-PathScopeStoredForms -Scope Machine
        $userRemaining = Get-PathScopeStoredForms -Scope User

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

                if ($null -ne $normalized) {
                    if ($machineRemaining[$normalized].Count -gt 0) {
                        $scope = "Machine"
                        $stored = $machineRemaining[$normalized].Dequeue()
                    }
                    elseif ($userRemaining[$normalized].Count -gt 0) {
                        $scope = "User"
                        $stored = $userRemaining[$normalized].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
        }

    # reads name every reference the listed locations carry that no variable resolves
    $selectedLocations `
        | ForEach-Object {
            Write-UnresolvedVariableError -Value $_.StoredValue
        }

    # -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 -ErrorAction 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 location to the system Path, either for the current user or for the local machine.
        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 an error 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: if the location is already present, the Path is left unchanged and a warning is reported.
        If the location is already present and -First is specified, it is moved to the beginning of the Path.
    .PARAMETER Location
        Folder location to add to the system Path.
    .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
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Mandatory, Position = 0)]
        [Alias("Folder")]
        [string] $Location,
        [Alias("Front")]
        [switch] $First,
        [Parameter(Mandatory, ParameterSetName = "Machine")]
        [switch] $Machine,
        [Parameter(ParameterSetName = "User")]
        [switch] $User,
        [switch] $Force
    )

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

    # the location is checked before anything is read or written, so -WhatIf reports the error a real run would hit
    $resolvedLocation = ConvertTo-NormalizedLocation -Location $Location

    # 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 $Location)
    Write-UnresolvedVariableError -Value $Location

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

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

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

    # the read stays quiet: this command reports the references of the location it was given, not those
    # the Path already carries
    $currentEntries = @(Get-SystemPath @context -ErrorAction SilentlyContinue)
    $newEntries = Add-PathLocation -Entries $currentEntries -Location $Location -First:$First -Scope $scope

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

    if (-not $PSCmdlet.ShouldProcess($Location, "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, "-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 location from the system Path, either for the current user or for the local machine.
        Removing is idempotent: if the location is not present, the Path is left unchanged and a warning is reported.
    .PARAMETER Location
        Folder location to remove from the system Path.
    .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.)
    .NOTES
        Alias: rmpath
        Default scope is User.
        An unelevated machine write prompts for elevation once and runs the whole removal elevated.
    .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
    #>

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

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

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

    # as in Add-SystemPathLocation: the read reports nothing, the location argument does
    Write-UnresolvedVariableError -Value $Location

    $currentEntries = @(Get-SystemPath @context -ErrorAction SilentlyContinue)
    $newEntries = @(Remove-PathLocation -Entries $currentEntries -Location $Location)

    # idempotent: nothing changed means the location is not present
    if ((Get-StoredPathString -Entries $newEntries) -eq (Get-StoredPathString -Entries $currentEntries)) {
        Write-Warning "Location is not on the system Path: '$Location'"
        return
    }

    if (-not $PSCmdlet.ShouldProcess($Location, "Remove location from system Path")) {
        return
    }

    # as in Add-SystemPathLocation: an unelevated machine write runs the whole removal elevated
    if ($Machine -and -not (Test-Elevated)) {
        $processLocations = Get-ProcessOnlyPathLocations
        Invoke-Elevated Remove-SystemPathLocation $Location -Machine
        Sync-ProcessPath @processLocations
        return
    }

    # Set-SystemPath rebuilds the process Path from both scopes, so the location stays available
    # when the other scope still carries it
    Set-SystemPath @context -Entries $newEntries
}

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: cleanpath
        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 Move-SystemPathLocation {
    <#
    .SYNOPSIS
        Moves a location between the machine and user system Paths.
    .DESCRIPTION
        Moves the specified location from the machine system Path to the user system Path (-ToUser),
        or from the user system Path to the machine system Path (-ToMachine).
        The location is removed from the source Path and added to the target Path.
        If the location is not on the source Path - whether it is already on the target Path or on neither -
        nothing is moved and a warning is reported.
        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 location to move, positional.
    .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
    #>

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

    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: the reads report nothing, the location argument does
    Write-UnresolvedVariableError -Value $Location

    $sourceEntries = @(Get-SystemPath @source -ErrorAction SilentlyContinue)
    $normalized = ConvertTo-NormalizedLocation -Location $Location
    $identity = ConvertTo-LocationIdentity -StoredValue $Location -Location $normalized
    $moved = @(
        $sourceEntries `
            | Where-Object {
                (ConvertTo-LocationIdentity -StoredValue $_.StoredValue -Location $_.Location) -ieq $identity
            }
    )

    # not on the source Path: nothing to move
    if ($moved.Count -eq 0) {
        $onTarget = @(Get-SystemPath @target -ErrorAction SilentlyContinue) `
            | Where-Object {
                (ConvertTo-LocationIdentity -StoredValue $_.StoredValue -Location $_.Location) -ieq $identity
            }

        $reason = $onTarget `
            ? "already on the $targetName Path" `
            : "not on the $sourceName Path"
        Write-Warning "Nothing to move. reason: $reason, location: '$Location'"
        return
    }

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

    $targetEntries = @(Get-SystemPath @target -ErrorAction SilentlyContinue)
    $onTarget = @(
        $targetEntries `
            | Where-Object {
                (ConvertTo-LocationIdentity -StoredValue $_.StoredValue -Location $_.Location) -ieq $identity
            }
    )
    # append the moved entry, keeping its stored (%...%) form, unless the target already has it
    $targetChanged = $onTarget.Count -eq 0
    $newTarget = $targetChanged `
        ? (@($targetEntries) + @($moved[0])) `
        : $targetEntries

    if (-not $targetChanged) {
        Write-Verbose "Target Path already holds location. scope: $targetName, location: '$Location'"
    }

    # 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($Location, "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 -ToUser
        }
        else {
            Invoke-Elevated Move-SystemPathLocation $Location -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.
        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 location to look for, positional. It 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.
    .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
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param (
        [Parameter(Mandatory, Position = 0)]
        [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
    )

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

    return @($locations).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 cleanpath -Value Remove-DuplicateSystemPathLocations -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