targets/File.ps1

@{
    Name          = 'File'
    Configuration = @{
        Path           = @{Required = $true; Type = [string]; Default = $null }
        PrintBody      = @{Required = $false; Type = [bool]; Default = $false }
        PrintException = @{Required = $false; Type = [bool]; Default = $false }
        Append         = @{Required = $false; Type = [bool]; Default = $true }
        Encoding       = @{Required = $false; Type = [string]; Default = 'ascii' }
        Level          = @{Required = $false; Type = [string]; Default = $Logging.Level }
        Format         = @{Required = $false; Type = [string]; Default = $Logging.Format }
        # Rotation
        ## Rotate after the directory contains the given amount of files. A value that is less than or equal to 0 is treated as not configured.
        RotateAfterAmount   = @{Required = $false; Type = [int]; Default = -1}
        ## Amount of files to be rotated, when RotateAfterAmount is used.
        ## In general max(|Files| - RotateAfterAmount, RotateAmount) files are rotated.
        RotateAmount        = @{Required = $false; Type = [int]; Default = -1}
        ## Rotate after the difference between the current datetime and the datetime of the file(s) are greater then the given timespan. A value of 0 is treated as not configured.
        RotateAfterDate     = @{Required = $false; Type = [timespan]; Default = [timespan]::Zero}
        ## Rotate after the file(s) are greater than the given size in BYTES. A value that is less than or equal to 0 is treated as not configured.
        RotateAfterSize     = @{Required = $false; Type = [int]; Default = -1}
        ## Optionally all rotated files can be compressed. Uses patterns, however only datetimes are allows
        CompressionPath     = @{Required = $false; Type = [string]; Default = [String]::Empty}
    }
    Init          = {
        param(
            [hashtable] $Configuration
        )

        [string] $directoryPath = [System.IO.Path]::GetDirectoryName($Configuration.Path)
        [string] $wildcardBasePath = Format-Pattern -Pattern ([System.IO.Path]::GetFileName($Configuration.Path)) -Wildcard

        # We (try to) create the directory if it is not yet given
        if (-not [System.IO.Directory]::Exists($directoryPath)){
            # "Creates all directories and subdirectories in the specified path unless they already exist."
            # https://docs.microsoft.com/en-us/dotnet/api/system.io.directory.createdirectory?view=net-5.0#System_IO_Directory_CreateDirectory_System_String_
            [System.IO.Directory]::CreateDirectory($directoryPath) | Out-Null
        }

        # Allow for the rolling of log files
        $mtx = New-Object System.Threading.Mutex($false, 'FileMtx')
        [void] $mtx.WaitOne()
        try{
            # Get existing files
            if (-not [System.IO.Directory]::Exists($directoryPath)){
                return
            }

            $rotationDate = $Configuration.RotateAfterDate.Duration()
            $currentDateUtc = [datetime]::UtcNow

            [string[]] $logFiles = [System.IO.Directory]::GetFiles($directoryPath, $wildcardBasePath)
            $toBeRolled = @()
            $givenFiles = [System.IO.FileInfo[]]::new($logFiles.Count)

            for ([int] $i = 0; $i -lt $logFiles.Count; $i++){
                $fileInfo = [System.IO.FileInfo]::new($logFiles[$i])

                # 1. Based on file size
                if ($Configuration.RotateAfterSize -gt 0 -and $fileInfo.Length -gt $Configuration.RotateAfterSize){
                    $toBeRolled += $fileInfo
                }
                # 2. Based on date
                elseif ($rotationDate.TotalSeconds -gt 0 -and ($currentDateUtc - $fileInfo.CreationTimeUtc).TotalSeconds -gt $rotationDate.TotalSeconds){
                    $toBeRolled += $fileInfo
                }
                # 3. Based on number
                else{
                    $givenFiles[$i] = $fileInfo
                }
            }

            # 3. Based on number
            if ($Configuration.RotateAfterAmount -gt 0 -and $givenFiles.Count -gt $Configuration.RotateAfterAmount){
                if ($Configuration.RotateAmount -le 0){
                    $Configuration.RotateAmount = $Configuration.RotateAfterAmount / 2
                }

                $sortedFiles = $givenFiles | Sort-Object -Property CreationTimeUtc

                # Rotate
                # a) until sortedFiles = RotateAfterAmount
                # b) until RotateAmount files are rotated
                for ([int] $i = 0; ($i -lt ($sortedFiles.Count - $Configuration.RotateAfterAmount)) -or ($i -le $Configuration.RotateAmount); $i++){
                    $toBeRolled += $sortedFiles[$i]
                }
            }

            [string[]] $paths = @()
            foreach ($fileInfo in $toBeRolled){
                $paths += $fileInfo.FullName
            }

            if ($paths.Count -eq 0){
                return
            }

            # (opt) compress old files
            if (-not [string]::IsNullOrWhiteSpace($Configuration.CompressionPath)){
                try{
                    Add-Type -As System.IO.Compression.FileSystem
                }catch{
                    $ParentHost.UI.WriteErrorLine("ERROR: You need atleast .Net 4.5 for the compression feature.")
                    return
                }

                [string] $compressionDirectory = [System.IO.Path]::GetDirectoryName($Configuration.CompressionPath)
                [string] $compressionFile = Format-Pattern -Pattern $Configuration.CompressionPath -Source @{
                    timestamp    = [datetime]::now
                    timestamputc = [datetime]::UtcNow
                    pid          = $PID
                }

                # We (try to) create the directory if it is not yet given
                if (-not [System.IO.Directory]::Exists($compressionDirectory)){
                   [System.IO.Directory]::CreateDirectory($compressionDirectory) | Out-Null
                }

                # Compress-Archive not supported for PS < 5
                [string] $temporary = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ([guid]::NewGuid().ToString())
                [System.IO.DirectoryInfo] $tempDir = [System.IO.Directory]::CreateDirectory($temporary)

                if ([System.IO.File]::Exists($compressionFile)){
                    [IO.Compression.ZipFile]::ExtractToDirectory($compressionFile, $tempDir.FullName)
                    Remove-Item -Path $compressionFile -Force
                }

                Move-Item -Path $paths -Destination $tempDir.FullName -Force
                [IO.Compression.ZipFile]::CreateFromDirectory($tempDir.FullName, $compressionFile, [System.IO.Compression.CompressionLevel]::Fastest, $false)
                Remove-Item -Path $tempDir.FullName -Recurse -Force
            }else{
                Remove-Item -Path $paths -Force
            }
        }finally{
            [void] $mtx.ReleaseMutex()
            $mtx.Dispose()
        }
    }
    Logger        = {
        param(
            [hashtable] $Log,
            [hashtable] $Configuration
        )

        if ($Configuration.PrintBody -and $Log.Body) {
            $Log.Body = $Log.Body | ConvertTo-Json -Compress
        }
        elseif (-not $Configuration.PrintBody -and $Log.Body) {
            $Log.Remove('Body')
        }

        $Text = Format-Pattern -Pattern $Configuration.Format -Source $Log

        if (![String]::IsNullOrWhiteSpace($Log.ExecInfo) -and $Configuration.PrintException) {
            $Text += "`n{0}" -f $Log.ExecInfo.Exception.Message
            $Text += "`n{0}" -f (($Log.ExecInfo.ScriptStackTrace -split "`r`n" | % { "`t{0}" -f $_ }) -join "`n")
        }

        $Params = @{
            Append   = $Configuration.Append
            FilePath = Format-Pattern -Pattern $Configuration.Path -Source $Log
            Encoding = $Configuration.Encoding
        }

        $mtx = New-Object System.Threading.Mutex($false, 'FileMtx')
        [void] $mtx.WaitOne()
        try{
            $Text | Out-File @Params
        }finally{
            [void] $mtx.ReleaseMutex()
            $mtx.Dispose()
        }
    }
}