SystemPathGroups.psm1

Set-StrictMode -version latest;
$ErrorActionPreference = "Stop";

# Configuration variables
$script:PathsFile = "$env:ProgramData\SystemPathGroups\paths.json"
$script:EnvTarget = [System.EnvironmentVariableTarget]::Machine
$script:EnvVar = "Path"

function Add-ToPath {
    <#
    .SYNOPSIS
    Adds a new path to both to the known paths configuration and optionally to the system path (true by default).
 
    .DESCRIPTION
    This function adds a specified path to the paths configuration file with an associated path group.
    If AddToSystemPath is true (default), also adds the path to the system path.
    If AddToSystemPath is false, the path will only be saved to the configuration file.
 
    .PARAMETER PathGroup
    The group name to associate with the path in the configuration file.
 
    .PARAMETER NewPath
    The file system path to add.
 
    .PARAMETER AddToSystemPath
    Optional. If true (default), adds the path to system path. If false, only stores in config.
 
    .EXAMPLE
    Add-ToPath "DevTools" "C:\Tools\bin"
     
    Adds C:\Tools\bin to the system path and associates it with the "DevTools" group.
 
    .EXAMPLE
    Add-ToPath -PathGroup "DevTools" -NewPath "C:\Tools\bin"
     
    Same as above using named parameters.
 
    .EXAMPLE
    Add-ToPath "DevTools" "C:\Tools\bin" $false
     
    Only adds C:\Tools\bin to the configuration file without modifying the system path.
 
    .EXAMPLE
    Add-ToPath -PathGroup "DevTools" -NewPath "C:\Tools\bin" -AddToSystemPath $false
     
    Same as above using named parameters.
    #>

    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [string]$PathGroup,
        [Parameter(Mandatory = $true, Position = 1)]
        [string]$NewPath,
        [Parameter(Mandatory = $false, Position = 2)]
        [bool]$AddToSystemPath = $true
    )
    if ($AddToSystemPath) {
        Add-ToSystemPath -Paths $NewPath
    }
    Save-PathToJson -Path $NewPath -PathGroup $PathGroup
}

function Remove-PathGroupsFromPath {
    <#
    .SYNOPSIS
    Removes paths associated with specified path groups from the system path.
 
    .DESCRIPTION
    This function removes paths from the system path environment variable based on their associated path groups.
    If no path groups are specified, all paths from the configuration will be removed.
 
    .PARAMETER PathGroups
    An optional array of path group names. If specified, only paths associated with these groups will be removed.
    If not specified, all paths from the configuration will be removed. Can be specified as multiple arguments.
 
    .EXAMPLE
    Remove-PathGroupsFromPath "DevTools","TestTools"
     
    Removes all paths associated with the "DevTools" and "TestTools" groups from the system path.
 
    .EXAMPLE
    Remove-PathGroupsFromPath "DevTools" "TestTools"
     
    Same as above, but using multiple arguments instead of an array.
 
    .EXAMPLE
    Remove-PathGroupsFromPath
     
    Removes all paths from the system path that are defined in the configuration file.
    #>

    param(
        [Parameter(Mandatory = $false, Position = 0, ValueFromRemainingArguments = $true)]
        [string[]]$PathGroups
    )

    $pathsDict = Get-PathDict -ThrowIfNotFound $true
    if ($PathGroups -and $PathGroups.Count -gt 0) {
        $existingGroups = $pathsDict.Values | Select-Object -Unique
        $nonExistentGroups = $PathGroups | Where-Object { $_ -notin $existingGroups }
        if ($nonExistentGroups) {
            throw "Path groups not found: $($nonExistentGroups -join ', ')"
        }
        $pathsToRemove = $pathsDict.Keys | Where-Object { $PathGroups -contains $pathsDict[$_] }
    }
    else {
        $pathsToRemove = $pathsDict.Keys
    }
    if (-not $pathsToRemove) { return }
    Remove-FromSystemPath -Paths $pathsToRemove
}

function Add-PathGroupsToPath {
    <#
    .SYNOPSIS
    Adds paths associated with specified path groups to the system path.
 
    .DESCRIPTION
    This function adds paths to the system path environment variable based on their associated path groups
    from the paths configuration file. If no path groups are specified, all paths from the configuration will be added.
 
    .PARAMETER PathGroups
    An optional array of path group names. If specified, only paths associated with these groups will be added.
    If not specified, all paths from the configuration will be added.
 
    .EXAMPLE
    Add-PathGroupsToPath -PathGroups "DevTools","TestTools"
     
    Adds all paths associated with the "DevTools" and "TestTools" groups to the system path.
 
    .EXAMPLE
    Add-PathGroupsToPath
     
    Adds all paths from the configuration file to the system path.
    #>

    param(
        [Parameter(Mandatory = $false, Position = 0, ValueFromRemainingArguments = $true)]
        [string[]]$PathGroups
    )
    
    $pathsDict = Get-PathDict -ThrowIfNotFound $true
    $pathsToAdd = @()
    if ($PathGroups -and $PathGroups.Count -gt 0) {
        $pathsToAdd = $pathsDict.Keys | Where-Object { $PathGroups -contains $pathsDict[$_] }
    }
    else {
        $pathsToAdd = $pathsDict.Keys
    }
    Add-ToSystemPath -Paths $pathsToAdd
}

function Save-PathToJson {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path,
        [Parameter(Mandatory = $true)]
        [string]$PathGroup
    )
    $pathsDict = Get-PathDict
    $pathsDict[$Path.ToLower()] = $PathGroup
    $directory = Split-Path -Path $script:PathsFile -Parent
    if (-not (Test-Path $directory)) {
        New-Item -ItemType Directory -Path $directory -Force | Out-Null
    }
    $pathsDict | ConvertTo-Json | Set-Content $script:PathsFile
}

function Get-PathDict {
    param(
        [Parameter(Mandatory = $false)]
        [bool]$ThrowIfNotFound = $false
    )
    $pathsDict = @{}
    if (-not (Test-Path $script:PathsFile)) {
        if ($ThrowIfNotFound) {
            throw "Paths configuration file not found at: $script:PathsFile"
        }
    }
    else {
        try {
            $pathsDict = Get-Content $script:PathsFile | ConvertFrom-Json -AsHashtable
        }
        catch {
            Write-Error "Failed to parse paths configuration file: $_"
        }
    }
    return $pathsDict
}

function Add-ToSystemPath {
    param(
        [Parameter(Mandatory = $true)]
        [string[]]$Paths
    )
    $currentPath = [Environment]::GetEnvironmentVariable($script:EnvVar, $script:EnvTarget).Replace('/', '\')
    $pathChanged = $false
    foreach ($path in $Paths) {
        $normalizedPath = $path.Replace('/', '\')
        if (-not ($currentPath.Split(';') -contains $normalizedPath)) {
            $currentPath = $currentPath + ";" + $normalizedPath
            $pathChanged = $true
        }
    }
    if ($pathChanged) {
        [Environment]::SetEnvironmentVariable($script:EnvVar, $currentPath, $script:EnvTarget)
    }
}

function Remove-FromSystemPath {
    param(
        [Parameter(Mandatory = $true)]
        [string[]]$Paths
    )
    if (-not $Paths) { return }
    $currentPath = [Environment]::GetEnvironmentVariable($script:EnvVar, $script:EnvTarget).Replace('/', '\')
    $pathsArray = $currentPath.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries)
    $pathChanged = $false

    foreach ($path in $Paths) {
        $normalizedPath = $path.Replace('/', '\')
        if ($pathsArray -contains $normalizedPath) {
            $pathsArray = $pathsArray | Where-Object { $_ -ne $normalizedPath }
            $pathChanged = $true
        }
    }

    if ($pathChanged) {
        $newPath = $pathsArray -join ';'
        [Environment]::SetEnvironmentVariable($script:EnvVar, $newPath, $script:EnvTarget)
    }
}
Export-ModuleMember -Function Add-PathGroupsToPath, Remove-PathGroupsFromPath, Add-ToPath