PSWatchdog.psm1

# PSWatchdog.psm1
# A modern PowerShell module for monitoring file system events
# Provides a seamless, Python-like experience for file monitoring

#region Module-Level Variables

# Hashtable to store all active file watchers in the current session
# Key: Watcher ID (GUID), Value: PSCustomObject with watcher details
$script:ActiveWatchers = @{}

# Counter for generating unique watcher IDs
$script:WatcherIdCounter = 0

#endregion Module-Level Variables

#region Helper Functions

<#
.SYNOPSIS
    Generates a unique ID for a new file watcher.
#>

function Get-WatcherId {
    $script:WatcherIdCounter++
    return "Watcher_{0}_{1}" -f $script:WatcherIdCounter, [Guid]::NewGuid().ToString("N").Substring(0, 8)
}

<#
.SYNOPSIS
    Converts .NET FileSystemEventArgs to a clean PowerShell object.
.PARAMETER EventArgs
    The .NET event arguments from FileSystemWatcher.
#>

function ConvertTo-PowerShellEvent {
    param(
        [System.IO.FileSystemEventArgs]$EventArgs
    )
    
    return [PSCustomObject]@{
        FullPath    = $EventArgs.FullPath
        Name        = $EventArgs.Name
        ChangeType  = $EventArgs.ChangeType.ToString()
        TimeStamp   = [DateTime]::Now
    }
}

<#
.SYNOPSIS
    Converts .NET RenamedEventArgs to a clean PowerShell object.
.PARAMETER EventArgs
    The .NET event arguments from FileSystemWatcher.
#>

function ConvertTo-PowerShellRenamedEvent {
    param(
        [System.IO.RenamedEventArgs]$EventArgs
    )
    
    return [PSCustomObject]@{
        FullPath    = $EventArgs.FullPath
        Name        = $EventArgs.Name
        ChangeType  = $EventArgs.ChangeType.ToString()
        OldFullPath = $EventArgs.OldFullPath
        OldName     = $EventArgs.OldName
        TimeStamp   = [DateTime]::Now
    }
}

<#
.SYNOPSIS
    Creates a hashtable with watcher info that can be passed to background jobs.
#>

function New-WatcherMessageData {
    param(
        [string]$WatcherId,
        [scriptblock]$Action
    )
    
    # Convert scriptblock to string for serialization
    $actionString = $Action.ToString()
    
    return @{
        WatcherId   = $WatcherId
        ActionString = $actionString
    }
}

#endregion Helper Functions

#region Cmdlets

function Start-FileWatcher {
    <#
    .SYNOPSIS
        Starts monitoring a file system path for changes.
    .DESCRIPTION
        Creates a FileSystemWatcher to monitor Create, Modify, Delete, and Rename events
        on a specified path. The watcher runs asynchronously and fires a custom action
        when specified events occur.
    .PARAMETER Path
        The path to monitor. This parameter is mandatory.
    .PARAMETER Filter
        The file filter to monitor (e.g., *.txt, *.*). Defaults to *.*.
    .PARAMETER IncludeSubdirectories
        If specified, monitors subdirectories as well.
    .PARAMETER ChangeType
        An array of event types to monitor: Created, Changed, Deleted, Renamed.
        Defaults to all event types.
    .PARAMETER Action
        A ScriptBlock to execute when an event fires. The script block receives
        a custom PSObject with FullPath, ChangeType, and TimeStamp properties.
    .EXAMPLE
        Start-FileWatcher -Path "C:\Logs" -Filter "*.log" -Action { param($event) Write-Host "File $($event.Name) was $($event.ChangeType)" }
    .EXAMPLE
        Start-FileWatcher -Path "C:\Data" -IncludeSubdirectories -ChangeType Created,Changed
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateScript({ Test-Path $_ -PathType Container })]
        [string]$Path,
        
        [Parameter(Position = 1)]
        [string]$Filter = "*.*",
        
        [Parameter(Position = 2)]
        [switch]$IncludeSubdirectories,
        
        [Parameter(Position = 3)]
        [ValidateSet("Created", "Changed", "Deleted", "Renamed")]
        [string[]]$ChangeType = @("Created", "Changed", "Deleted", "Renamed"),
        
        [Parameter(Position = 4)]
        [scriptblock]$Action
    )
    
    begin {
        # Resolve the path to an absolute path
        $Path = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
    }
    
    process {
        try {
            # Create the FileSystemWatcher instance
            # The InternalBufferSize is set to 65536 bytes (64KB) to prevent buffer overflow
            # during bulk file operations - a common .NET pitfall
            $watcher = [System.IO.FileSystemWatcher]::new()
            
            # Set watcher properties
            $watcher.Path = $Path
            $watcher.Filter = $Filter
            $watcher.IncludeSubdirectories = $IncludeSubdirectories.IsPresent
            $watcher.NotifyFilter = [System.IO.NotifyFilters]::FileName -bor [System.IO.NotifyFilters]::DirectoryName -bor [System.IO.NotifyFilters]::LastWrite -bor [System.IO.NotifyFilters]::Size
            
            # IMPORTANT: Increase InternalBufferSize to prevent crashes during bulk operations
            # Default is 8192 bytes (8KB), which is too small for many scenarios
            # Setting to 65536 (64KB) handles thousands of files more reliably
            $watcher.InternalBufferSize = 65536
            
            # Enable raising events
            $watcher.EnableRaisingEvents = $true
            
            # Generate a unique ID for this watcher
            $watcherId = Get-WatcherId
            
            # Store event subscriptions for cleanup
            $eventSubscriptions = @()
            
            # Create message data that includes the action string (for passing to background jobs)
            $messageData = @{
                WatcherId   = $watcherId
                ActionString = if ($Action) { $Action.ToString() } else { $null }
            }
            
            # Wire up events based on ChangeType parameter
            # Using Register-ObjectEvent to subscribe to .NET events
            foreach ($type in $ChangeType) {
                switch ($type) {
                    "Created" {
                        $createdEvent = Register-ObjectEvent -InputObject $watcher `
                            -EventName "Created" `
                            -SourceIdentifier "${watcherId}_Created" `
                            -MessageData $messageData `
                            -SupportEvent `
                            -Action {
                                param($sender, $eventArgs)
                                
                                # Convert to clean PowerShell object
                                $psEvent = [PSCustomObject]@{
                                    FullPath    = $eventArgs.FullPath
                                    Name        = $eventArgs.Name
                                    ChangeType  = $eventArgs.ChangeType.ToString()
                                    TimeStamp   = [DateTime]::Now
                                }
                                
                                # Get action from message data and execute
                                $actionString = $event.MessageData.ActionString
                                if ($actionString) {
                                    $action = [scriptblock]::Create($actionString)
                                    & $action $psEvent
                                }
                            }
                        $eventSubscriptions += $createdEvent
                        Write-Verbose "Registered Created event handler"
                    }
                    
                    "Changed" {
                        $changedEvent = Register-ObjectEvent -InputObject $watcher `
                            -EventName "Changed" `
                            -SourceIdentifier "${watcherId}_Changed" `
                            -MessageData $messageData `
                            -SupportEvent `
                            -Action {
                                param($sender, $eventArgs)
                                
                                # Convert to clean PowerShell object
                                $psEvent = [PSCustomObject]@{
                                    FullPath    = $eventArgs.FullPath
                                    Name        = $eventArgs.Name
                                    ChangeType  = $eventArgs.ChangeType.ToString()
                                    TimeStamp   = [DateTime]::Now
                                }
                                
                                # Get action from message data and execute
                                $actionString = $event.MessageData.ActionString
                                if ($actionString) {
                                    $action = [scriptblock]::Create($actionString)
                                    & $action $psEvent
                                }
                            }
                        $eventSubscriptions += $changedEvent
                        Write-Verbose "Registered Changed event handler"
                    }
                    
                    "Deleted" {
                        $deletedEvent = Register-ObjectEvent -InputObject $watcher `
                            -EventName "Deleted" `
                            -SourceIdentifier "${watcherId}_Deleted" `
                            -MessageData $messageData `
                            -SupportEvent `
                            -Action {
                                param($sender, $eventArgs)
                                
                                # Convert to clean PowerShell object
                                $psEvent = [PSCustomObject]@{
                                    FullPath    = $eventArgs.FullPath
                                    Name        = $eventArgs.Name
                                    ChangeType  = $eventArgs.ChangeType.ToString()
                                    TimeStamp   = [DateTime]::Now
                                }
                                
                                # Get action from message data and execute
                                $actionString = $event.MessageData.ActionString
                                if ($actionString) {
                                    $action = [scriptblock]::Create($actionString)
                                    & $action $psEvent
                                }
                            }
                        $eventSubscriptions += $deletedEvent
                        Write-Verbose "Registered Deleted event handler"
                    }
                    
                    "Renamed" {
                        $renamedEvent = Register-ObjectEvent -InputObject $watcher `
                            -EventName "Renamed" `
                            -SourceIdentifier "${watcherId}_Renamed" `
                            -MessageData $messageData `
                            -SupportEvent `
                            -Action {
                                param($sender, $eventArgs)
                                
                                # Convert to clean PowerShell object (with Old* properties for rename)
                                $psEvent = [PSCustomObject]@{
                                    FullPath    = $eventArgs.FullPath
                                    Name        = $eventArgs.Name
                                    ChangeType  = $eventArgs.ChangeType.ToString()
                                    OldFullPath = $eventArgs.OldFullPath
                                    OldName     = $eventArgs.OldName
                                    TimeStamp   = [DateTime]::Now
                                }
                                
                                # Get action from message data and execute
                                $actionString = $event.MessageData.ActionString
                                if ($actionString) {
                                    $action = [scriptblock]::Create($actionString)
                                    & $action $psEvent
                                }
                            }
                        $eventSubscriptions += $renamedEvent
                        Write-Verbose "Registered Renamed event handler"
                    }
                }
            }
            
            # Create the tracking object to return to the user
            $watcherObject = [PSCustomObject]@{
                Id                    = $watcherId
                Path                  = $Path
                Filter                = $Filter
                IncludeSubdirectories = $IncludeSubdirectories.IsPresent
                ChangeType            = $ChangeType
                Action                = $Action
                Watcher               = $watcher
                EventSubscriptions    = $eventSubscriptions
                CreatedAt             = [DateTime]::Now
            }
            
            # Add the watcher to the module-level hashtable for tracking
            # This allows Get-FileWatcher to enumerate all active watchers
            # and Stop-FileWatcher to find and dispose of specific watchers
            $script:ActiveWatchers[$watcherId] = $watcherObject
            
            # Return the tracking object to the user
            # The user can store this and use the Id or Path to stop the watcher later
            return $watcherObject
            
        }
        catch {
            # Clean up any partial registration on failure
            if ($watcher) {
                $watcher.EnableRaisingEvents = $false
                $watcher.Dispose()
            }
            
            foreach ($sub in $eventSubscriptions) {
                if ($sub) {
                    Unregister-Event -SourceIdentifier $sub.Name -ErrorAction SilentlyContinue
                    Remove-Job -Id $sub.Id -ErrorAction SilentlyContinue
                }
            }
            
            throw "Failed to start file watcher: $_"
        }
    }
    
    end {
    }
}


function Get-FileWatcher {
    <#
    .SYNOPSIS
        Gets all active file watchers in the current session.
    .DESCRIPTION
        Returns a list of all file watchers that were created by Start-FileWatcher
        and are still active in the current PowerShell session.
    .EXAMPLE
        Get-FileWatcher
    .EXAMPLE
        Get-FileWatcher | Format-List
    #>

    [CmdletBinding()]
    param()
    
    process {
        # Create a snapshot of the hashtable keys to avoid enumeration issues
        $watcherIds = @($script:ActiveWatchers.Keys)
        $results = @()
        
        if ($watcherIds.Count -gt 0) {
            foreach ($id in $watcherIds) {
                # Skip if watcher was removed between iterations
                if (-not $script:ActiveWatchers.ContainsKey($id)) {
                    continue
                }
                
                $watcher = $script:ActiveWatchers[$id]
                $results += [PSCustomObject]@{
                    Id                    = $watcher.Id
                    Path                  = $watcher.Path
                    Filter                = $watcher.Filter
                    IncludeSubdirectories = $watcher.IncludeSubdirectories
                    ChangeType            = $watcher.ChangeType
                    CreatedAt             = $watcher.CreatedAt
                }
            }
        }
        
        # Always return an array (empty or with results)
        return $results
    }
}


function Stop-FileWatcher {
    <#
    .SYNOPSIS
        Stops an active file watcher.
    .DESCRIPTION
        Stops and cleans up a file watcher that was started with Start-FileWatcher.
        This properly disposes of the FileSystemWatcher, unregisters events,
        and removes associated jobs to prevent memory leaks.
    .PARAMETER Id
        The ID of the watcher to stop (returned by Start-FileWatcher).
    .PARAMETER Path
        The path of the watcher to stop (the path that was being monitored).
    .EXAMPLE
        Stop-FileWatcher -Id "Watcher_1_abc12345"
    .EXAMPLE
        $watcher = Start-FileWatcher -Path "C:\Logs"
        Stop-FileWatcher -Id $watcher.Id
    .EXAMPLE
        Stop-FileWatcher -Path "C:\Logs"
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, ParameterSetName = "ById", ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string]$Id,
        
        [Parameter(Mandatory = $true, ParameterSetName = "ByPath", ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string]$Path
    )
    
    process {
        $watcherToStop = $null
        $watcherIdToStop = $null
        
        # Find the watcher based on the parameter set
        if ($PSCmdlet.ParameterSetName -eq "ById") {
            # Look up by ID
            if ($script:ActiveWatchers.ContainsKey($Id)) {
                $watcherToStop = $script:ActiveWatchers[$Id]
                $watcherIdToStop = $Id
                Write-Verbose "Found watcher with ID: $Id"
            }
            else {
                Write-Error "No active watcher found with ID: $Id. Use Get-FileWatcher to see all active watchers."
                return
            }
        }
        elseif ($PSCmdlet.ParameterSetName -eq "ByPath") {
            # Resolve the path to absolute form
            $resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
            
            # Create snapshot of keys to avoid enumeration issues
            $watcherIds = @($script:ActiveWatchers.Keys)
            
            # Look up by path - find first matching watcher
            foreach ($id in $watcherIds) {
                if ($script:ActiveWatchers.ContainsKey($id) -and $script:ActiveWatchers[$id].Path -eq $resolvedPath) {
                    $watcherToStop = $script:ActiveWatchers[$id]
                    $watcherIdToStop = $id
                    Write-Verbose "Found watcher for path: $resolvedPath"
                    break
                }
            }
            
            if (-not $watcherToStop) {
                Write-Error "No active watcher found monitoring path: $Path. Use Get-FileWatcher to see all active watchers."
                return
            }
        }
        
        # Safety check
        if (-not $watcherToStop) {
            Write-Error "Unable to locate watcher to stop."
            return
        }
        
        try {
            # Step 1: Disable the watcher to stop new events
            if ($watcherToStop.Watcher) {
                $watcherToStop.Watcher.EnableRaisingEvents = $false
                Write-Verbose "Disabled event raising on watcher"
            }
            
            # Step 2: Unregister all event subscriptions
            # This disconnects the event handlers from the .NET objects
            if ($watcherToStop.EventSubscriptions) {
                foreach ($subscription in $watcherToStop.EventSubscriptions) {
                    if ($subscription) {
                        # Unregister the event
                        Unregister-Event -SourceIdentifier $subscription.Name -ErrorAction SilentlyContinue
                        Write-Verbose "Unregistered event: $($subscription.Name)"
                        
                        # Remove the associated job
                        # Jobs are created by Register-ObjectEvent -Action
                        Remove-Job -Id $subscription.Id -Force -ErrorAction SilentlyContinue
                        Write-Verbose "Removed job: $($subscription.Id)"
                    }
                }
            }
            
            # Step 3: Properly dispose of the FileSystemWatcher
            # This is critical to prevent memory leaks
            # Calling Dispose() releases all unmanaged resources
            if ($watcherToStop.Watcher) {
                $watcherToStop.Watcher.Dispose()
                Write-Verbose "Disposed FileSystemWatcher"
            }
            
            # Step 4: Remove from the active watchers hashtable
            $script:ActiveWatchers.Remove($watcherIdToStop)
            Write-Verbose "Removed watcher from active watchers list"
            
            # Return a success message object
            return [PSCustomObject]@{
                Id      = $watcherIdToStop
                Path    = $watcherToStop.Path
                Status  = "Stopped"
                Message = "File watcher successfully stopped and cleaned up."
            }
        }
        catch {
            Write-Error "Failed to stop file watcher: $_"
        }
    }
}


function Stop-AllFileWatchers {
    <#
    .SYNOPSIS
        Stops all active file watchers.
    .DESCRIPTION
        Convenience function to stop all file watchers that were created
        by Start-FileWatcher in the current session.
    .EXAMPLE
        Stop-AllFileWatchers
    #>

    [CmdletBinding()]
    param()
    
    process {
        $stoppedCount = 0
        
        # Get all active watcher IDs as a snapshot
        $watcherIds = @($script:ActiveWatchers.Keys)
        
        if ($watcherIds.Count -eq 0) {
            Write-Verbose "No active watchers to stop"
            return [PSCustomObject]@{
                Count   = 0
                Status  = "No watchers were active"
            }
        }
        
        # Stop each watcher
        foreach ($id in $watcherIds) {
            # Skip if already removed
            if (-not $script:ActiveWatchers.ContainsKey($id)) {
                continue
            }
            
            try {
                $watcher = $script:ActiveWatchers[$id]
                
                # Disable events
                if ($watcher.Watcher) {
                    $watcher.Watcher.EnableRaisingEvents = $false
                }
                
                # Unregister events and remove jobs
                if ($watcher.EventSubscriptions) {
                    foreach ($subscription in $watcher.EventSubscriptions) {
                        if ($subscription) {
                            Unregister-Event -SourceIdentifier $subscription.Name -ErrorAction SilentlyContinue
                            Remove-Job -Id $subscription.Id -Force -ErrorAction SilentlyContinue
                        }
                    }
                }
                
                # Dispose watcher
                if ($watcher.Watcher) {
                    $watcher.Watcher.Dispose()
                }
                
                # Remove from hashtable
                $script:ActiveWatchers.Remove($id)
                $stoppedCount++
                
                Write-Verbose "Stopped watcher: $id"
            }
            catch {
                Write-Warning "Failed to stop watcher $id : $_"
            }
        }
        
        return [PSCustomObject]@{
            Count   = $stoppedCount
            Status  = if ($stoppedCount -gt 0) { "All watchers stopped" } else { "No watchers stopped" }
        }
    }
}

#endregion Cmdlets

#region Module Export

# Export module members
Export-ModuleMember -Function Start-FileWatcher, Get-FileWatcher, Stop-FileWatcher, Stop-AllFileWatchers

#endregion Module Export