venvlink-autoenv.psm1

# Copyright 2018 Nick Cox
# Copyright 2020 Niko Pasanen
#
# About this file
# ===============
# This file has been forked from ps-autovent v.0.5.0 by Nick Cox
# https://github.com/nickcox/ps-autoenv
#
# Added since:
# * Modify for venvlink purposes (check for ./venv/ v file)
# * Check all the parent folders for virtual environments
# * Change logic: no action on every change in pwd, but only when there
# is pwd change to project folder from outside project folder, or
# vice versa.
# * Also check the initial folder.
# * For authorization, check also hash of the file

Set-StrictMode -Version latest

# Keeps track of current ("old") directory.
$script:currentDir = $pwd

# If pwd inside project directory (or subfolder),
# this is non-null and points to the project folder root.
# Both objects below are always System.IO.DirectoryInfo or null.
$script:currentProjectDir = $null
$script:lastfoundProjectDir = $null

$global:venvlink_autoenv = New-Object PSObject -Property ([ordered]@{
  AUTH_FILE = '~/venvlink-autoenv-auth'
  ENV_FILENAME = 'venvlink-autoenv.ps1'
  ENV_LEAVE_FILENAME = 'venvlink-autoenv.leave.ps1'
  ENABLE_LEAVE = $true
  ASSUME_YES = $false
})


function AuthorizeFile($filePath) {
  if (-not (Test-Path $venvlink_autoenv.AUTH_FILE)) {
    New-Item $venvlink_autoenv.AUTH_FILE
  }

  $content = Get-Content $filePath -Raw
  $hash = (Get-FileHash $filePath  -Algorithm MD5).Hash

  $authline = "$filePath $hash"
  if ((Get-Content $venvlink_autoenv.AUTH_FILE) -contains $authline) {
    return $true
  }

  Write-Warning 'venvlink-autoenv wants to authorize the following script:'
  Write-Host ('=' * 60) -ForegroundColor Red
  Write-Host $content -ForegroundColor Green
  Write-Host ('=' * 60) -ForegroundColor Red

  if ($venvlink_autoenv.ASSUME_YES -eq $true) {
    Write-Host "$([char]0x2713) Auto authorized `n" -ForegroundColor DarkYellow
    $authline >> $venvlink_autoenv.AUTH_FILE
    return $true
  }

  switch (Read-Host "Authorize file ($filePath) ( y / n )") {
    "y" {
      $authline >> $venvlink_autoenv.AUTH_FILE
      return $true
    }
    Default {
      return $false
    }
  }
}

function RunScript {
  [CmdletBinding()]
  param ($scriptFile)

  $scriptFile = Get-Item $scriptFile
  $scriptDir = $scriptFile.Directory 
  if (AuthorizeFile $scriptFile.FullName) {
    Write-Verbose "Running script: $scriptFile"
    
    #Set $PSScriptRoot for convenience
    $block = "param (`$PSScriptRoot)`n" 
    #Give ./venv/venvlink-autoenv.ps1 access to
    # $workdir. This is needed so that
    # 1) virtual environment can be activated by cd'ing
    # into any subdirectory if project root
    # 2) Hardcoding directories in venvlink-autoenv
    # files is not needed -> Projects can be relocated.
    $block += "`$workdir = '$scriptDir'`n"
    $block += (Get-Content $scriptFile.FullName -Raw)
    
    $output = Invoke-Command `
      -ScriptBlock ([scriptblock]::Create(($block))) `
      -ArgumentList $scriptFile.DirectoryName
  }
}

function LeaveProjectDir {
  [CmdletBinding()]
  
  param ($project_dir)

  Write-Verbose "Leaving project directory '$project_dir'"

  if (-not $script:currentProjectDir) {
      return 
  }
  if (
    $venvlink_autoenv.ENABLE_LEAVE -and (
    $leaveFile = GetVenvlinkFile $project_dir $venvlink_autoenv.ENV_LEAVE_FILENAME)) {
    RunScript $leaveFile
    $script:currentProjectDir = $null
  }
}

function EnterProjectDir {
  [CmdletBinding()]
  param ($project_dir)

  Write-Verbose "Entered project directory '$project_dir'"
  if ($enterFile = GetVenvlinkFile $project_dir $venvlink_autoenv.ENV_FILENAME) {
    RunScript $enterFile
    $script:currentProjectDir = $project_dir
  }
}



function GetVenvlinkFile($Dir, [string]$filename) {
    
    try {
        $venv = Join-Path -Path (Get-Item $Dir -Force) -ChildPath 'venv'
        $file = Join-Path -Path $venv -ChildPath $filename
            if (-not (Test-Path $file)) {
                $file = $false 
            } 
    } catch {
        $file = $false
    }

    return $file
}



function IsProjectDir {
    <#
    .SYNOPSIS
        Check if a directory is a project directory
 
    .DESCRIPTION
        Check if a directory is a venvlink project directory.
        A venvlink project directory has file ./venv/ENV_FILENAME
 
        This function returns true or false.
        This function modifies $script:lastfoundProjectDir
    #>

    [CmdletBinding()]
    param (
        # The directory to be checked.
        $Dir
    )   

    Write-Verbose "Checking if $Dir is a project directory"
    $venvlink_env = GetVenvlinkFile $Dir $venvlink_autoenv.ENV_FILENAME
    if ($venvlink_env -eq $false)
    {
        $ret = $false     
    } else {
        $ret = $true   
        $script:lastfoundProjectDir = (Get-Item (Get-Item $venvlink_env).Directory.parent.FullName)
    }
    
    return $ret 
  }

  
  function InProject {
    <#
    .SYNOPSIS
        Check if a directory is within a project directory
 
    .DESCRIPTION
        Check if a directory or one of it's subdirectories is a
        venvlink project directory. A venvlink project directory has
        file ./venv/venvlink-autoenv.ps1
 
        This function returns true or false.
        This function modifies $script:lastfoundProjectDir
    #>

    [CmdletBinding()]
    param (
        # The directory to be checked.
        $Dir,
        # Just for verbose output
        [bool]$suppressverbose
    )   

    if (!($suppressverbose))    
    {
        Write-Verbose "Checking if $Dir is a project directory or one of it's subfolders"
    }
    
    $project_found = IsProjectDir $Dir
    if (-not $project_found) {
        # -Force is needed since some special directories such as
        # "C:\Users\All Users" do not have a parent.
        if ((Get-Item $Dir -Force).parent) {
            $project_found = InProject (Get-Item $Dir -Force).parent.FullName $true
        }
    }   
    
    return $project_found
    
  }


  function AutoEnv {
    [CmdletBinding()]
    param (
        # The directory that user is entering
        $newDir
    )   

    Write-Verbose "Running AutoEnv, currentDir: $currentDir, newDir: $newDir, currentProjectDir:$script:currentProjectDir, lastfoundProjectDir:$script:lastfoundProjectDir"

    try {
      if ($newDir.Path -eq $currentDir.Path) {
        return
      }
      
      $current_in = InProject $currentDir
      $new_in = InProject $newDir
      Write-Verbose "Current folder ($currentDir) in a project: $current_in"
      Write-Verbose "New folder ($newDir) in a project: $new_in"
      Write-Verbose "currentProjectDir:$script:currentProjectDir, lastfoundProjectDir:$script:lastfoundProjectDir"

      if ($current_in -eq $new_in) {
            # If current and new directory are both
            # inside or outside the project folder, there
            # is no need to run anything.
            
            # But there is one exception which must be checked:
            # User changes directly from one project to another
            if ($script:lastfoundProjectDir -and $script:currentProjectDir) {
                $samefolder = ($script:lastfoundProjectDir.FullName -eq  $script:currentProjectDir.FullName)
            } else {
                # dirs can be null (e.g. after deactivation).
                $samefolder = $false 
            }

            if (($current_in -and $new_in) -and (-not $samefolder)) {
                # User changed directly from one project to another.
                LeaveProjectDir $script:currentProjectDir
                EnterProjectDir $script:lastfoundProjectDir

            }
      } elseif ($new_in) {
            # newDir is inside project
            # currentDir is outside project
            # -> Just entered project folder
            EnterProjectDir $script:lastfoundProjectDir
          
       } elseif ($current_in) {
            # newDir is outside project
            # currentDir is inside project
            # -> Just left project folder
            LeaveProjectDir $script:currentProjectDir
      }

      $script:currentDir = $newDir
      return 
    }
    catch {
      Write-Warning "Could not execute venvlink_autoenv script. `n$_.Exception.Message"
    }
  }

  
# Add validator to PWD which is called every time directory is changed.
# The validator checks if shell has left or entered a project folder.
# DEBUG TIP: Use "AutoEnv $_ -Verbose" instead of "AutoEnv $_"
$validateAttr = (new-object ValidateScript { AutoEnv $_; return $true })
(Get-Variable PWD).Attributes.Add($validateAttr)

$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {
  $null = (Get-Variable pwd).attributes.Remove($validateAttr)
  $global:venvlink_autoenv = $null
}

# This is only ran when this module is imported
# Needed for the case, when powershell is launched in a folder that has environment to
# be activated.
if (InProject $pwd) {
    EnterProjectDir $script:lastfoundProjectDir
}

Export-ModuleMember -Variable $venvlink_autoenv