ElektoCalendarPosh.psm1

#region Private/Calendar.ps1
# Calendar core: building calendars and the day arithmetic every public function relies on.
#
# A calendar is a plain object (PSTypeName ElektoCalendarPosh.Calendar) holding the non-working
# weekdays and the holidays. As in RiskSystem, a calendar covers from its first holiday to its last:
# asking about a date outside them is an error, so that a file missing next year's holidays never
# silently treats them as workdays. A single holiday covers from a year before it to a year after it
# (RiskSystem goes 25 years back and 75 ahead, more than a scripting tool needs); a calendar without
# holidays covers every date.
#
# Counting never walks day by day: whole weeks are counted arithmetically and the holidays that fall
# on working weekdays are found by binary search, so the cost does not grow with the distance between
# the dates. The results are the same as those of the Calendar class of Elekto's RiskSystem.

$script:WeekdayNames = [Enum]::GetNames([DayOfWeek])
$script:DefaultWeekend = [DayOfWeek[]]@('Saturday', 'Sunday')

# Parsed holiday files, by full path, reused while the file keeps the same content; a process that
# goes through many files starts over at FileCacheLimit of them, so the cache never grows unbounded
$script:FileCache = [System.Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal)
$script:FileCacheLimit = 64

# What Set-WorkdayCalendar stored: calendar objects and/or file paths, resolved on every use
$script:DefaultCalendar = $null

# The paths in WORKDAY_CALENDAR, made absolute when the module first sees the variable's value, so
# that a later Set-Location does not change the calendar (as with Set-WorkdayCalendar)
$script:EnvironmentCalendar = @{ Value = $null; Paths = @() }

# The last joint calendar, reused while the same calendars are combined, as in a loop over dates
$script:LastJoint = $null

# The ids of the errors the module raises itself
$script:ErrorIds = @('CalendarFileNotFound', 'InvalidCalendarLine', 'NoWorkingWeekday', 'NoCommonPeriod', 'InvalidCalendar',
    'DateOutsideCalendar', 'DateOutOfRange', 'NoWorkdayInMonth')

function ConvertTo-CalendarDate([datetime]$Date) {
    # Calendars work with dates only: midnight, and no time zone kind
    [datetime]::SpecifyKind($Date.Date, [DateTimeKind]::Unspecified)
}

function Format-CalendarText([string]$Text, [int]$MaxLength = [int]::MaxValue) {
    # Text that came from a file, a file name or the caller, made safe to print in a message: control
    # and formatting characters (terminal escapes, bidirectional overrides) are escaped as \uXXXX, and
    # anything past MaxLength is cut
    $builder = [Text.StringBuilder]::new()
    foreach ($char in $Text.ToCharArray()) {
        if ($builder.Length -ge $MaxLength) { [void]$builder.Append('...'); break }
        if ([char]::IsControl($char) -or [Globalization.CharUnicodeInfo]::GetUnicodeCategory($char) -eq 'Format') {
            [void]$builder.AppendFormat('\u{0:X4}', [int]$char)
        }
        else {
            [void]$builder.Append($char)
        }
    }
    $builder.ToString()
}

function New-CalendarError {
    # An error record that scripts can catch by exception type and tell apart by its id
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Only creates an object in memory.')]
    param([string]$Message, [string]$Id, [Management.Automation.ErrorCategory]$Category, [object]$Target,
        [type]$ExceptionType = [ArgumentException])

    $exception = $ExceptionType::new($Message, [Exception]$null)
    [Management.Automation.ErrorRecord]::new($exception, $Id, $Category, $Target)
}

function ConvertTo-CalendarErrorRecord([Management.Automation.ErrorRecord]$ErrorRecord) {
    # The module's own errors pass unchanged; date arithmetic beyond what .NET represents (years 1 to
    # 9999, or a number of days too large) becomes DateOutOfRange instead of a .NET method error
    if (($ErrorRecord.FullyQualifiedErrorId -split ',')[0] -in $script:ErrorIds) { return $ErrorRecord }

    $exception = $ErrorRecord.Exception
    while ($exception -is [Management.Automation.MethodInvocationException] -and $exception.InnerException) {
        $exception = $exception.InnerException
    }
    if ($exception -is [ArgumentOutOfRangeException] -or $exception -is [OverflowException]) {
        return New-CalendarError 'The answer falls outside the dates that can be represented, from 0001-01-01 to 9999-12-31.' `
            'DateOutOfRange' InvalidArgument $ErrorRecord.TargetObject ([ArgumentOutOfRangeException])
    }
    $ErrorRecord
}

function Write-CalendarError($Cmdlet, [Management.Automation.ErrorRecord]$ErrorRecord) {
    # An error about one date stops the command: the safe default, so that a date the calendar cannot
    # answer for never silently drops out of a list. When the caller passed -ErrorAction, it is an
    # ordinary error instead, under the action chosen: Stop still stops, SilentlyContinue skips the date.
    #
    # The commands check the dates they need with Get-CalendarCoverageError before working, so that
    # the usual error reaches here without an exception: PowerShell also records, in the caller's
    # -ErrorVariable, every exception caught inside a command, once per function it crossed.
    $record = ConvertTo-CalendarErrorRecord $ErrorRecord
    if ($Cmdlet.MyInvocation.BoundParameters.ContainsKey('ErrorAction')) { $Cmdlet.WriteError($record) }
    else { $Cmdlet.ThrowTerminatingError($record) }
}

function New-CalendarObject {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Only creates an object in memory.')]
    param(
        [string]$Name,
        [string]$Path,
        [datetime[]]$Holidays = @(),
        [DayOfWeek[]]$Weekend = $script:DefaultWeekend,
        # Coverage; computed from the holidays unless given
        [Nullable[datetime]]$MinDate,
        [Nullable[datetime]]$MaxDate
    )

    $isWeekend = [bool[]]::new(7)
    foreach ($day in $Weekend) { $isWeekend[[int]$day] = $true }
    $workdaysPerWeek = @($isWeekend -eq $false).Count
    if ($workdaysPerWeek -eq 0) {
        throw (New-CalendarError "Calendar '$(Format-CalendarText $Name)' has no working weekday: at least one day of the week must be a workday." `
                'NoWorkingWeekday' InvalidArgument $Weekend)
    }

    $holidaySet = [System.Collections.Generic.HashSet[datetime]]::new()
    foreach ($holiday in $Holidays) { [void]$holidaySet.Add([datetime]::SpecifyKind($holiday.Date, [DateTimeKind]::Unspecified)) }
    $sortedHolidays = [datetime[]]::new($holidaySet.Count)
    $holidaySet.CopyTo($sortedHolidays)
    [Array]::Sort($sortedHolidays)

    # Only the holidays on working weekdays change a count; weekends are counted apart
    $workingHolidays = [System.Collections.Generic.List[datetime]]::new()
    foreach ($holiday in $sortedHolidays) {
        if (-not $isWeekend[[int]$holiday.DayOfWeek]) { $workingHolidays.Add($holiday) }
    }

    if (-not $PSBoundParameters.ContainsKey('MinDate')) {
        if ($sortedHolidays.Count -eq 1) {
            $MinDate = $sortedHolidays[0].AddYears(-1)
            $MaxDate = $sortedHolidays[0].AddYears(1)
        }
        elseif ($sortedHolidays.Count -gt 1) {
            $MinDate = $sortedHolidays[0]
            $MaxDate = $sortedHolidays[-1]
        }
    }

    [pscustomobject]@{
        PSTypeName      = 'ElektoCalendarPosh.Calendar'
        Name            = $Name
        Weekend         = [DayOfWeek[]]@($Weekend | Sort-Object -Unique { ([int]$_ + 6) % 7 }) # Monday first
        MinDate         = $MinDate
        MaxDate         = $MaxDate
        Path            = $Path
        Holidays        = $sortedHolidays
        HolidaySet      = $holidaySet
        IsWeekend       = $isWeekend
        WorkdaysPerWeek = $workdaysPerWeek
        WorkingHolidays = $workingHolidays.ToArray()
    }
}

function Read-CalendarFile([string]$Path) {
    # Reads a holiday file: one yyyy-MM-dd date per line, the rest of the line free; '#' starts a
    # comment. Before the first holiday, lines of weekday names declare the non-working weekdays, or
    # NoWeekend, as the first line that is not a comment, declares that there are none. Returns the
    # holidays and the weekend, if declared. The parse is cached by path and content.
    $file = [IO.FileInfo]::new($Path)
    if (-not $file.Exists) {
        throw (New-CalendarError "Calendar file '$(Format-CalendarText $Path)' not found." 'CalendarFileNotFound' ObjectNotFound $Path `
                ([IO.FileNotFoundException]))
    }

    # The content decides, not the write time: a rewrite that keeps the size and the time (a copy that
    # preserves it, a coarse file system) must still be seen. Holiday files are small; hashing is cheap.
    # All bytes at once, too: a lazy reader left open by an error would keep the file locked on Windows.
    $bytes = [IO.File]::ReadAllBytes($file.FullName)
    $hash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes))
    $cached = $null
    if ($script:FileCache.TryGetValue($file.FullName, [ref]$cached) -and $cached.Hash -eq $hash) {
        return $cached
    }

    $reader = [IO.StreamReader]::new([IO.MemoryStream]::new($bytes), [Text.UTF8Encoding]::new($false), $true)
    try { $text = $reader.ReadToEnd() } finally { $reader.Dispose() }

    $holidays = [System.Collections.Generic.List[datetime]]::new()
    $weekend = [System.Collections.Generic.List[DayOfWeek]]::new()
    $noWeekend = $false
    $lineNumber = 0
    $contentLines = 0
    foreach ($line in $text -split '\r\n|\n|\r') {
        $lineNumber++
        $content = $line.Trim()
        if ($content.Length -eq 0 -or $content[0] -eq '#') { continue }
        $contentLines++

        # A date line, by far the most common, is told by its first ten characters and what follows them
        $date = [datetime]::MinValue
        if ($content.Length -ge 10 -and [char]::IsDigit($content[0]) -and
            [datetime]::TryParseExact($content.Substring(0, 10), 'yyyy-MM-dd', [cultureinfo]::InvariantCulture,
                [Globalization.DateTimeStyles]::None, [ref]$date) -and
            ($content.Length -eq 10 -or [char]::IsWhiteSpace($content[10]) -or ',#'.Contains($content[10]))) {
            $holidays.Add($date)
            continue
        }

        $tokens = ($content -replace '#.*$', '').Trim() -split '[\s,]+'
        $isNoWeekend = $tokens.Count -eq 1 -and $tokens[0] -eq 'NoWeekend'
        $isWeekdays = @($tokens | Where-Object { $_ -notin $script:WeekdayNames }).Count -eq 0
        $problem = $null
        if ($isNoWeekend -and $contentLines -gt 1) { $problem = 'NoWeekend must be the first line that is not a comment' }
        elseif ($isNoWeekend) { $noWeekend = $true }
        elseif ($isWeekdays -and $noWeekend) { $problem = 'weekday names cannot follow NoWeekend' }
        elseif ($isWeekdays -and $holidays.Count -gt 0) { $problem = 'the non-working weekdays must come before the first holiday' }
        elseif ($isWeekdays) { foreach ($token in $tokens) { $weekend.Add([DayOfWeek]$token) } }
        else { $problem = 'this is neither a date (yyyy-MM-dd), nor weekday names, nor NoWeekend' }

        if ($problem) {
            # The line goes into the message escaped and cut short: the file may hold anything
            $found = Format-CalendarText $line 80
            throw (New-CalendarError "Line $lineNumber of '$(Format-CalendarText $file.FullName)': $problem. Found '$found'." `
                    'InvalidCalendarLine' InvalidData $found ([FormatException]))
        }
    }

    $parsed = [pscustomobject]@{
        Hash            = $hash
        Path            = $file.FullName
        Name            = [IO.Path]::GetFileNameWithoutExtension($file.Name)
        Holidays        = $holidays.ToArray()
        Weekend         = $weekend.ToArray()
        WeekendDeclared = $noWeekend -or $weekend.Count -gt 0
        Calendar        = $null  # the calendar of the file as it is, built on first use
    }
    if ($script:FileCache.Count -ge $script:FileCacheLimit -and -not $script:FileCache.ContainsKey($file.FullName)) {
        $script:FileCache.Clear()
    }
    $script:FileCache[$file.FullName] = $parsed
    $parsed
}

function Import-CalendarFile([string]$Path) {
    # The calendar of a holiday file, built once for each version of the file
    $parsed = Read-CalendarFile $Path
    if (-not $parsed.Calendar) {
        $weekend = if ($parsed.WeekendDeclared) { $parsed.Weekend } else { $script:DefaultWeekend }
        $parsed.Calendar = New-CalendarObject -Name $parsed.Name -Path $parsed.Path -Holidays $parsed.Holidays -Weekend $weekend
    }
    $parsed.Calendar
}

function Get-FullPath([string]$Path) {
    # Relative paths are relative to the PowerShell location, not to the process directory
    $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
}

function Join-Calendar([object[]]$Calendars) {
    # A day is a workday only if it is a workday in every calendar: holidays and weekends add up, and
    # the coverage is what all of them cover
    $holidays = foreach ($calendar in $Calendars) { $calendar.Holidays }
    $weekend = foreach ($calendar in $Calendars) { $calendar.Weekend }
    $bounded = @($Calendars | Where-Object { $null -ne $_.MinDate })
    $minDate = $null
    $maxDate = $null
    if ($bounded.Count -gt 0) {
        $minDate = ($bounded | ForEach-Object MinDate | Sort-Object)[-1]
        $maxDate = ($bounded | ForEach-Object MaxDate | Sort-Object)[0]
        if ($minDate -gt $maxDate) {
            throw (New-CalendarError "Calendars '$(Format-CalendarText ($Calendars.Name -join "', '"))' have no period in common." `
                    'NoCommonPeriod' InvalidArgument $Calendars)
        }
    }

    New-CalendarObject -Name ($Calendars.Name -join '+') -Holidays $holidays -Weekend $weekend `
        -MinDate $minDate -MaxDate $maxDate
}

function Get-DefaultCalendar {
    if ($script:DefaultCalendar) { return Resolve-Calendar $script:DefaultCalendar }

    $value = $env:WORKDAY_CALENDAR
    if ($value -cne $script:EnvironmentCalendar.Value) {
        $paths = @($value -split [IO.Path]::PathSeparator | Where-Object { $_.Trim() } | ForEach-Object { Get-FullPath $_.Trim() })
        $script:EnvironmentCalendar = @{ Value = $value; Paths = $paths }
    }
    if ($script:EnvironmentCalendar.Paths.Count -gt 0) { return Resolve-Calendar $script:EnvironmentCalendar.Paths }
    $script:WeekendsOnly
}

function Resolve-Calendar([object[]]$Calendar) {
    # Turns what the -Calendar parameter received (calendar objects, file paths, or nothing) into a
    # single calendar: the default one when nothing was given, a joint one when several were
    $items = @($Calendar | Where-Object { $null -ne $_ -and ($_ -isnot [string] -or $_.Trim()) })
    if ($items.Count -eq 0) { return Get-DefaultCalendar }

    $calendars = @(foreach ($item in $items) {
            if ($item.PSObject.TypeNames -contains 'ElektoCalendarPosh.Calendar') { $item }
            elseif ($item -is [IO.FileInfo]) { Import-CalendarFile $item.FullName }
            elseif ($item -is [System.Management.Automation.PathInfo]) { Import-CalendarFile $item.ProviderPath }
            elseif ($item -is [string]) { Import-CalendarFile (Get-FullPath $item) }
            else {
                throw (New-CalendarError "A calendar must be an object from New-WorkdayCalendar or the path of a holiday file, not '$(Format-CalendarText "$item" 80)'." `
                        'InvalidCalendar' InvalidArgument $item)
            }
        })
    if ($calendars.Count -eq 1) { return $calendars[0] }

    $last = $script:LastJoint
    if ($last -and $last.Parts.Count -eq $calendars.Count) {
        $same = $true
        for ($i = 0; $i -lt $calendars.Count; $i++) {
            if (-not [object]::ReferenceEquals($last.Parts[$i], $calendars[$i])) { $same = $false; break }
        }
        if ($same) { return $last.Calendar }
    }
    $joint = Join-Calendar $calendars
    $script:LastJoint = @{ Parts = $calendars; Calendar = $joint }
    $joint
}

function Assert-CalendarCoverage($Calendar, [datetime]$Date) {
    # On every day a computation touches, so the check that passes stays inline and cheap
    if (($null -eq $Calendar.MinDate -or $Date -ge $Calendar.MinDate) -and
        ($null -eq $Calendar.MaxDate -or $Date -le $Calendar.MaxDate)) { return }
    throw (Get-CalendarCoverageError $Calendar $Date)
}

function Get-CalendarCoverageError($Calendar, [datetime]$Date) {
    # The error for a date the calendar does not cover, or nothing when it does
    if (($null -eq $Calendar.MinDate -or $Date -ge $Calendar.MinDate) -and
        ($null -eq $Calendar.MaxDate -or $Date -le $Calendar.MaxDate)) { return }

    $template = "Calendar '{0}' covers {1:yyyy-MM-dd} to {2:yyyy-MM-dd} and cannot tell whether {3:yyyy-MM-dd} is a workday."
    $message = $template -f (Format-CalendarText $Calendar.Name), $Calendar.MinDate, $Calendar.MaxDate, $Date
    if ($Calendar.Path) {
        $message += if ($Date -gt $Calendar.MaxDate) {
            " Add the holidays after {0:yyyy-MM-dd} to '{1}'." -f $Calendar.MaxDate, (Format-CalendarText $Calendar.Path)
        }
        else {
            " Add the holidays before {0:yyyy-MM-dd} to '{1}'." -f $Calendar.MinDate, (Format-CalendarText $Calendar.Path)
        }
    }
    New-CalendarError $message 'DateOutsideCalendar' InvalidArgument $Date ([ArgumentOutOfRangeException])
}

function Test-CalendarDay($Calendar, [datetime]$Date) {
    Assert-CalendarCoverage $Calendar $Date
    -not $Calendar.IsWeekend[[int]$Date.DayOfWeek] -and -not $Calendar.HolidaySet.Contains($Date)
}

function Find-Workday($Calendar, [datetime]$Date, [int]$Direction, [switch]$OrSame) {
    # The first workday after Date (Direction 1) or before it (Direction -1); Date itself with OrSame
    Assert-CalendarCoverage $Calendar $Date
    if (-not $OrSame) { $Date = $Date.AddDays($Direction) }
    while (-not (Test-CalendarDay $Calendar $Date)) { $Date = $Date.AddDays($Direction) }
    $Date
}

function Find-LowerBound([datetime[]]$Sorted, [datetime]$Value) {
    # Index of the first element not less than Value (the elements are unique)
    $index = [Array]::BinarySearch($Sorted, $Value)
    if ($index -lt 0) { -bnot $index } else { $index }
}

function Measure-CalendarHoliday($Calendar, [datetime]$Start, [datetime]$End) {
    # Holidays on working weekdays in [Start, End)
    (Find-LowerBound $Calendar.WorkingHolidays $End) - (Find-LowerBound $Calendar.WorkingHolidays $Start)
}

function Measure-CalendarWorkday($Calendar, [datetime]$Start, [datetime]$End) {
    # Workdays in [Start, End), for Start <= End
    $days = ($End - $Start).Days
    if ($days -le 0) { return 0 }
    Assert-CalendarCoverage $Calendar $Start
    Assert-CalendarCoverage $Calendar $End.AddDays(-1)

    $weeks = [int][Math]::Floor($days / 7)
    $count = $weeks * $Calendar.WorkdaysPerWeek
    for ($day = $Start.AddDays(7 * $weeks); $day -lt $End; $day = $day.AddDays(1)) {
        if (-not $Calendar.IsWeekend[[int]$day.DayOfWeek]) { $count++ }
    }
    $count - (Measure-CalendarHoliday $Calendar $Start $End)
}

function Step-WorkingWeekday($Calendar, [datetime]$Date, [long]$Count) {
    # The Count-th working weekday after Date (before it, when negative), ignoring holidays
    $direction = [Math]::Sign($Count)
    $left = [Math]::Abs($Count)
    $weeks = [long][Math]::Floor(($left - 1) / $Calendar.WorkdaysPerWeek)
    $Date = $Date.AddDays(7 * $weeks * $direction)
    $left -= $weeks * $Calendar.WorkdaysPerWeek
    while ($left -gt 0) {
        $Date = $Date.AddDays($direction)
        if (-not $Calendar.IsWeekend[[int]$Date.DayOfWeek]) { $left-- }
    }
    $Date
}

function Add-CalendarWorkday($Calendar, [datetime]$Date, [int]$Days) {
    # The Days-th workday after Date (before it, when negative); Date itself when Days is 0, workday
    # or not, without consulting the calendar, whose holidays could not change that answer. As
    # RiskSystem's Calendar.AddWorkDays.
    if ($Days -eq 0) { return $Date }
    Assert-CalendarCoverage $Calendar $Date

    $direction = [Math]::Sign($Days)
    $pending = [Math]::Abs([long]$Days)
    # More workdays than the calendar has days: the answer is beyond it, whatever its holidays
    if ($null -ne $Calendar.MaxDate -and $pending -gt ($Calendar.MaxDate - $Calendar.MinDate).Days) {
        Assert-CalendarCoverage $Calendar $(if ($direction -gt 0) { $Calendar.MaxDate.AddDays(1) } else { $Calendar.MinDate.AddDays(-1) })
    }
    while ($pending -gt 0) {
        $target = Step-WorkingWeekday $Calendar $Date ($pending * $direction)
        Assert-CalendarCoverage $Calendar $target
        # The holidays stepped over did not count: go on for as many days as they were
        $pending = if ($direction -gt 0) {
            Measure-CalendarHoliday $Calendar $Date.AddDays(1) $target.AddDays(1)
        }
        else {
            Measure-CalendarHoliday $Calendar $target $Date
        }
        $Date = $target
    }
    $Date
}

function Get-CalendarPeriodDelta($Calendar, [datetime]$From, [datetime]$To) {
    # Workdays in (From, To]; negative, and in (To, From], when To comes first. The difference of
    # RiskSystem's cumulative workday counts at To and at From.
    if ($From -le $To) { Measure-CalendarWorkday $Calendar $From.AddDays(1) $To.AddDays(1) }
    else { -(Measure-CalendarWorkday $Calendar $To.AddDays(1) $From.AddDays(1)) }
}

function Get-CalendarDelta($Calendar, [datetime]$Start, [datetime]$End, [string]$Mode) {
    # Workdays between Start and End under Mode, as RiskSystem's Calendar.GetDeltaWorkDays
    if ($Start -eq $End) { return [int]($Mode -eq 'Full' -and (Test-CalendarDay $Calendar $Start)) }
    if ($Start -gt $End) { return -(Get-CalendarDelta $Calendar $End $Start $Mode) }

    switch ($Mode) {
        'StartOnNextWork' { $Start = Find-Workday $Calendar $Start 1 -OrSame }
        'StartOnPrevWork' { $Start = Find-Workday $Calendar $Start -1 -OrSame }
        'EndOnNextWork' { $End = Find-Workday $Calendar $End 1 -OrSame }
        'EndOnPrevWork' { $End = Find-Workday $Calendar $End -1 -OrSame }
        'StartAndEndOnNext' {
            $Start = Find-Workday $Calendar $Start 1 -OrSame
            $End = Find-Workday $Calendar $End 1 -OrSame
        }
        'StartAndEndOnPrev' {
            $Start = Find-Workday $Calendar $Start -1 -OrSame
            $End = Find-Workday $Calendar $End -1 -OrSame
        }
        'StartAndEndExpanding' {
            $Start = Find-Workday $Calendar $Start -1 -OrSame
            $End = Find-Workday $Calendar $End 1 -OrSame
        }
        'StartAndEndCollapsing' {
            $Start = Find-Workday $Calendar $Start 1 -OrSame
            $End = Find-Workday $Calendar $End -1 -OrSame
        }
    }
    if ($Start -eq $End) { return [int]($Mode -eq 'Full' -and (Test-CalendarDay $Calendar $Start)) }

    $startIsWorkday = Test-CalendarDay $Calendar $Start
    $endIsWorkday = Test-CalendarDay $Calendar $End
    $delta = Get-CalendarPeriodDelta $Calendar $Start $End

    # (Start, End] becomes [Start, End], or [Start, End) in every mode but Full. RiskSystem has one more
    # case, delta 0 with a working Start and a non-working End, already covered by the last one here.
    if ($Mode -eq 'Full') {
        if ($startIsWorkday) { $delta++ }
    }
    elseif (-not $startIsWorkday -and $endIsWorkday) { $delta-- }
    elseif ($startIsWorkday -and -not $endIsWorkday) { $delta++ }
    $delta
}

function Get-AdjustedDate($Calendar, [datetime]$Date, [string]$Adjust) {
    # Moves a non-working Date to a workday under one of the usual business day conventions
    switch ($Adjust) {
        'Following' { Find-Workday $Calendar $Date 1 -OrSame }
        'Preceding' { Find-Workday $Calendar $Date -1 -OrSame }
        'ModifiedFollowing' {
            $adjusted = Find-Workday $Calendar $Date 1 -OrSame
            if ($adjusted.Month -ne $Date.Month) { $adjusted = Find-Workday $Calendar $adjusted -1 }
            $adjusted
        }
        'ModifiedPreceding' {
            $adjusted = Find-Workday $Calendar $Date -1 -OrSame
            if ($adjusted.Month -ne $Date.Month) { $adjusted = Find-Workday $Calendar $adjusted 1 }
            $adjusted
        }
        default { $Date }
    }
}

function Get-NthCalendarWorkdayInMonth($Calendar, [datetime]$Date, [int]$Nth) {
    # The Nth workday of Date's month, counting from its last one when Nth is negative. A month with
    # fewer workdays gives its last one (its first, from the end), so that a task meant for the end of
    # the month still runs: the results of RiskSystem's GetNthWorkingDay and GetNthToLastWorkingDay
    # (and GetWorkingMonthHead and GetWorkingMonthTail for 1 and -1). The search never leaves the
    # month, so it needs no coverage beyond it, and a month without any workday is an error: an answer
    # from another month would mislead a comparison with a date of this one.
    $first = [datetime]::new($Date.Year, $Date.Month, 1)
    $last = $first.AddMonths(1).AddDays(-1)
    $day, $end, $step = if ($Nth -gt 0) { $first, $last, 1 } else { $last, $first, -1 }

    $answer = $null
    $found = 0
    while ($true) {
        if (Test-CalendarDay $Calendar $day) {
            $answer = $day
            if (++$found -eq [Math]::Abs($Nth)) { break }
        }
        if ($day -eq $end) { break }
        $day = $day.AddDays($step)
    }

    if ($null -eq $answer) {
        throw (New-CalendarError ("Calendar '{0}' has no workday in {1:yyyy-MM}." -f (Format-CalendarText $Calendar.Name), $first) `
                'NoWorkdayInMonth' ObjectNotFound $first ([InvalidOperationException]))
    }
    $answer
}

$script:WeekendsOnly = New-CalendarObject -Name 'Weekends'

# Format-List shows what matters too (the table view is in the format file); the lookup structures stay out of sight
Update-TypeData -TypeName 'ElektoCalendarPosh.Calendar' -DefaultDisplayPropertySet Name, Weekend, MinDate, MaxDate, Path -Force
#endregion

#region Public/Add-CalendarDay.ps1
<#
.SYNOPSIS
Adds calendar days to a date and, optionally, moves the result to a workday.

.DESCRIPTION
The days are plain calendar days. When the result is not a workday, -Adjust says what to do with it,
following the usual business day conventions:

- None (default): keep it.
- Following: move it to the next workday.
- Preceding: move it to the previous workday.
- ModifiedFollowing: move it to the next workday, unless that falls in another month; then to the
  previous workday.
- ModifiedPreceding: move it to the previous workday, unless that falls in another month; then to
  the next workday.

With Days 0, it just applies the convention to the date.

.PARAMETER Days
How many calendar days to add; negative to go back.

.PARAMETER Date
The date; the time of day is ignored. Today when omitted. Accepts pipeline input, also from a
property named Date.

.PARAMETER Adjust
What to do when the result is not a workday: None, Following, Preceding, ModifiedFollowing or
ModifiedPreceding. None when omitted.

.PARAMETER Calendar
The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them,
combined so that a day is a workday only if it is a workday in all of them. When omitted, the
default calendar is used (see Set-WorkdayCalendar).

.EXAMPLE
Add-CalendarDay 30 -Date 2026-10-01 -Adjust Following -Calendar ./holidays/br.txt

2026-11-03: thirty days later is Saturday, 2026-10-31; then come a Sunday and All Souls' Day.

.EXAMPLE
Add-CalendarDay 30 -Date 2026-10-01 -Adjust ModifiedFollowing -Calendar ./holidays/br.txt

2026-10-30: the following workday is in November, so the result goes back to the last workday of
October.

.EXAMPLE
Add-CalendarDay 0 -Date 2026-11-01 -Adjust Preceding -Calendar ./holidays/br.txt

2026-10-30: moves a Sunday back to the previous workday.

.NOTES
An error about a date (one the calendar does not cover, for instance) stops the command. With
-ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and
goes on with the next ones, and -ErrorVariable collects the errors.

.OUTPUTS
System.DateTime
#>

function Add-CalendarDay {
    [CmdletBinding()]
    [OutputType([datetime])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [int]$Days,

        [Parameter(Position = 1, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [datetime]$Date = [datetime]::Today,

        [ValidateSet('None', 'Following', 'Preceding', 'ModifiedFollowing', 'ModifiedPreceding')]
        [string]$Adjust = 'None',

        [object[]]$Calendar
    )

    begin {
        try { $resolved = Resolve-Calendar $Calendar }
        catch { $PSCmdlet.ThrowTerminatingError($_) }
    }

    process {
        # The result is checked first, when it is to be adjusted: one outside the calendar is then
        # reported without an exception. Without adjustment the calendar is not consulted.
        $problem = $null
        try {
            $day = (ConvertTo-CalendarDate $Date).AddDays($Days)
            if ($Adjust -ne 'None') { $problem = Get-CalendarCoverageError $resolved $day }
            if (-not $problem) { Get-AdjustedDate $resolved $day $Adjust }
        }
        catch { $problem = $_ }
        if ($problem) { Write-CalendarError $PSCmdlet $problem }
    }
}
#endregion

#region Public/Add-Workday.ps1
<#
.SYNOPSIS
Adds workdays to a date, or subtracts them.

.DESCRIPTION
Returns the Days-th workday after the date, or before it when Days is negative. The date itself
does not need to be a workday: one workday after a Saturday is the following Monday, and one workday
before it is the Friday (when they are workdays).

With Days 0 the date comes back unchanged, even if it is not a workday; Get-NextOrSameWorkday and
Get-PreviousOrSameWorkday move a date to a workday.

From a workday, Get-WorkdayDelta between the date and the result gives Days back.

.PARAMETER Days
How many workdays to add; negative to go back.

.PARAMETER Date
The date; the time of day is ignored. Today when omitted. Accepts pipeline input, also from a
property named Date.

.PARAMETER Calendar
The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them,
combined so that a day is a workday only if it is a workday in all of them. When omitted, the
default calendar is used (see Set-WorkdayCalendar).

.EXAMPLE
Add-Workday 5 -Date 2026-11-16 -Calendar ./holidays/br.txt

2026-11-24: the 17th, 18th, 19th, 23rd and 24th; the 20th is a holiday in Brazil.

.EXAMPLE
Add-Workday -2

Two workdays before today (D-2), in the default calendar.

.EXAMPLE
Import-Csv trades.csv | Add-Workday 2

The settlement date (D+2) of each trade, taken from the Date column.

.NOTES
An error about a date (one the calendar does not cover, for instance) stops the command. With
-ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and
goes on with the next ones, and -ErrorVariable collects the errors.

.OUTPUTS
System.DateTime
#>

function Add-Workday {
    [CmdletBinding()]
    [OutputType([datetime])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [int]$Days,

        [Parameter(Position = 1, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [datetime]$Date = [datetime]::Today,

        [object[]]$Calendar
    )

    begin {
        try { $resolved = Resolve-Calendar $Calendar }
        catch { $PSCmdlet.ThrowTerminatingError($_) }
    }

    process {
        # The date is checked first: a date outside the calendar is then reported without an exception.
        # Not with 0 days, which gives the date back without consulting the calendar.
        $problem = $null
        try {
            $day = ConvertTo-CalendarDate $Date
            if ($Days -ne 0) { $problem = Get-CalendarCoverageError $resolved $day }
            if (-not $problem) { Add-CalendarWorkday $resolved $day $Days }
        }
        catch { $problem = $_ }
        if ($problem) { Write-CalendarError $PSCmdlet $problem }
    }
}
#endregion

#region Public/Get-FirstWorkdayInMonth.ps1
<#
.SYNOPSIS
Returns the first workday of a month.

.DESCRIPTION
The first day of the month if it is a workday, otherwise the first workday after it. Get-NthWorkdayInMonth
gives the second, the third, and so on.

.PARAMETER Date
Any date in the month; the current month when omitted. Accepts pipeline input, also from a property
named Date.

.PARAMETER Calendar
The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them,
combined so that a day is a workday only if it is a workday in all of them. When omitted, the
default calendar is used (see Set-WorkdayCalendar).

.EXAMPLE
Get-FirstWorkdayInMonth -Date 2026-11-15 -Calendar ./holidays/br.txt

2026-11-03: November 1st is a Sunday and the 2nd is All Souls' Day.

.EXAMPLE
Get-FirstWorkdayInMonth -Date (Get-Date).AddMonths(1)

The first workday of next month.

.NOTES
An error about a date (one the calendar does not cover, for instance) stops the command. With
-ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and
goes on with the next ones, and -ErrorVariable collects the errors.

.OUTPUTS
System.DateTime
#>

function Get-FirstWorkdayInMonth {
    [CmdletBinding()]
    [OutputType([datetime])]
    param(
        [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [datetime]$Date = [datetime]::Today,

        [object[]]$Calendar
    )

    begin {
        try { $resolved = Resolve-Calendar $Calendar }
        catch { $PSCmdlet.ThrowTerminatingError($_) }
    }

    process {
        # The first day of the month is checked first: one outside the calendar is then reported
        # without an exception
        $problem = $null
        try {
            $day = ConvertTo-CalendarDate $Date
            $problem = Get-CalendarCoverageError $resolved ([datetime]::new($day.Year, $day.Month, 1))
            if (-not $problem) { Get-NthCalendarWorkdayInMonth $resolved $day 1 }
        }
        catch { $problem = $_ }
        if ($problem) { Write-CalendarError $PSCmdlet $problem }
    }
}
#endregion

#region Public/Get-LastWorkdayInMonth.ps1
<#
.SYNOPSIS
Returns the last workday of a month.

.DESCRIPTION
The last day of the month if it is a workday, otherwise the last workday before it.
Get-NthWorkdayInMonth with a negative position gives the second to last, the third to last, and so
on.

.PARAMETER Date
Any date in the month; the current month when omitted. Accepts pipeline input, also from a property
named Date.

.PARAMETER Calendar
The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them,
combined so that a day is a workday only if it is a workday in all of them. When omitted, the
default calendar is used (see Set-WorkdayCalendar).

.EXAMPLE
Get-LastWorkdayInMonth -Date 2026-10-14 -Calendar ./holidays/br.txt

2026-10-30: October 31st, 2026 is a Saturday.

.EXAMPLE
if ((Get-LastWorkdayInMonth) -eq [datetime]::Today) { ./Close-Month.ps1 }

Closes the month on its last workday.

.NOTES
An error about a date (one the calendar does not cover, for instance) stops the command. With
-ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and
goes on with the next ones, and -ErrorVariable collects the errors.

.OUTPUTS
System.DateTime
#>

function Get-LastWorkdayInMonth {
    [CmdletBinding()]
    [OutputType([datetime])]
    param(
        [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [datetime]$Date = [datetime]::Today,

        [object[]]$Calendar
    )

    begin {
        try { $resolved = Resolve-Calendar $Calendar }
        catch { $PSCmdlet.ThrowTerminatingError($_) }
    }

    process {
        # The last day of the month is checked first: one outside the calendar is then reported
        # without an exception
        $problem = $null
        try {
            $day = ConvertTo-CalendarDate $Date
            $problem = Get-CalendarCoverageError $resolved ([datetime]::new($day.Year, $day.Month, 1).AddMonths(1).AddDays(-1))
            if (-not $problem) { Get-NthCalendarWorkdayInMonth $resolved $day -1 }
        }
        catch { $problem = $_ }
        if ($problem) { Write-CalendarError $PSCmdlet $problem }
    }
}
#endregion

#region Public/Get-NextOrSameWorkday.ps1
<#
.SYNOPSIS
Returns the date itself if it is a workday, otherwise the first workday after it.

.DESCRIPTION
The "following" business day convention: a non-working date moves forward to the next workday.

.PARAMETER Date
The date; the time of day is ignored. Today when omitted. Accepts pipeline input, also from a
property named Date.

.PARAMETER Calendar
The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them,
combined so that a day is a workday only if it is a workday in all of them. When omitted, the
default calendar is used (see Set-WorkdayCalendar).

.EXAMPLE
Get-NextOrSameWorkday -Date 2026-11-20 -Calendar ./holidays/br.txt

2026-11-23: the 20th is a holiday and the 21st and 22nd are a weekend.

.EXAMPLE
Get-NextOrSameWorkday -Date 2026-11-19 -Calendar ./holidays/br.txt

2026-11-19 itself, a working Thursday.

.NOTES
An error about a date (one the calendar does not cover, for instance) stops the command. With
-ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and
goes on with the next ones, and -ErrorVariable collects the errors.

.OUTPUTS
System.DateTime
#>

function Get-NextOrSameWorkday {
    [CmdletBinding()]
    [OutputType([datetime])]
    param(
        [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [datetime]$Date = [datetime]::Today,

        [object[]]$Calendar
    )

    begin {
        try { $resolved = Resolve-Calendar $Calendar }
        catch { $PSCmdlet.ThrowTerminatingError($_) }
    }

    process {
        # The date is checked first: a date outside the calendar is then reported without an exception
        $problem = $null
        try {
            $day = ConvertTo-CalendarDate $Date
            $problem = Get-CalendarCoverageError $resolved $day
            if (-not $problem) { Find-Workday $resolved $day 1 -OrSame }
        }
        catch { $problem = $_ }
        if ($problem) { Write-CalendarError $PSCmdlet $problem }
    }
}
#endregion

#region Public/Get-NextWorkday.ps1
<#
.SYNOPSIS
Returns the first workday after a date.

.DESCRIPTION
The date itself never counts, workday or not. Use Get-NextOrSameWorkday to keep a date that is
already a workday.

.PARAMETER Date
The date; the time of day is ignored. Today when omitted. Accepts pipeline input, also from a
property named Date.

.PARAMETER Calendar
The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them,
combined so that a day is a workday only if it is a workday in all of them. When omitted, the
default calendar is used (see Set-WorkdayCalendar).

.EXAMPLE
Get-NextWorkday -Date 2026-11-19 -Calendar ./holidays/br.txt

2026-11-23: the 20th is a holiday in Brazil and the 21st and 22nd are a weekend.

.EXAMPLE
Get-NextWorkday

Tomorrow, or the first workday after it, in the default calendar.

.NOTES
An error about a date (one the calendar does not cover, for instance) stops the command. With
-ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and
goes on with the next ones, and -ErrorVariable collects the errors.

.OUTPUTS
System.DateTime
#>

function Get-NextWorkday {
    [CmdletBinding()]
    [OutputType([datetime])]
    param(
        [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [datetime]$Date = [datetime]::Today,

        [object[]]$Calendar
    )

    begin {
        try { $resolved = Resolve-Calendar $Calendar }
        catch { $PSCmdlet.ThrowTerminatingError($_) }
    }

    process {
        # The date is checked first: a date outside the calendar is then reported without an exception
        $problem = $null
        try {
            $day = ConvertTo-CalendarDate $Date
            $problem = Get-CalendarCoverageError $resolved $day
            if (-not $problem) { Find-Workday $resolved $day 1 }
        }
        catch { $problem = $_ }
        if ($problem) { Write-CalendarError $PSCmdlet $problem }
    }
}
#endregion

#region Public/Get-NthWorkdayInMonth.ps1
<#
.SYNOPSIS
Returns the Nth workday of a month, counting from its start or from its end.

.DESCRIPTION
1 is the first workday of the month, 2 the second, and so on; -1 is the last workday, -2 the second
to last. When the month has fewer workdays than asked for, the result is its last workday (its
first, counting from the end): a task set for the 22nd workday of the month still runs, on the last
one, in a month that has only 20.

.PARAMETER Nth
The position: 1 to 31 from the start of the month, -1 to -31 from its end; 0 is not accepted.

.PARAMETER Date
Any date in the month; the current month when omitted. Accepts pipeline input, also from a property
named Date.

.PARAMETER Calendar
The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them,
combined so that a day is a workday only if it is a workday in all of them. When omitted, the
default calendar is used (see Set-WorkdayCalendar).

.EXAMPLE
Get-NthWorkdayInMonth 5 -Date 2026-11-01 -Calendar ./holidays/br.txt

2026-11-09: the workdays of November 2026 start on the 3rd, after a Sunday and All Souls' Day.

.EXAMPLE
Get-NthWorkdayInMonth -2 -Date 2026-11-01 -Calendar ./holidays/br.txt

2026-11-27, the second to last workday of November 2026.

.EXAMPLE
Get-NthWorkdayInMonth 25 -Date 2026-11-01 -Calendar ./holidays/br.txt

2026-11-30: November 2026 has only 19 workdays, so its last one stands for the 25th.

.EXAMPLE
if ((Get-NthWorkdayInMonth 3) -eq [datetime]::Today) { ./Send-MonthlyReport.ps1 }

Sends the report on the third workday of each month.

.NOTES
An error about a date (one the calendar does not cover, for instance) stops the command. With
-ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and
goes on with the next ones, and -ErrorVariable collects the errors.

.OUTPUTS
System.DateTime
#>

function Get-NthWorkdayInMonth {
    [CmdletBinding()]
    [OutputType([datetime])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [ValidateRange(-31, 31)]
        [ValidateScript({ $_ -ne 0 }, ErrorMessage = 'Nth cannot be 0: 1 is the first workday of the month and -1 the last.')]
        [int]$Nth,

        [Parameter(Position = 1, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [datetime]$Date = [datetime]::Today,

        [object[]]$Calendar
    )

    begin {
        try { $resolved = Resolve-Calendar $Calendar }
        catch { $PSCmdlet.ThrowTerminatingError($_) }
    }

    process {
        # The day of the month the count starts from is checked first: one outside the calendar is then
        # reported without an exception
        $problem = $null
        try {
            $day = ConvertTo-CalendarDate $Date
            $first = [datetime]::new($day.Year, $day.Month, 1)
            $problem = Get-CalendarCoverageError $resolved $(if ($Nth -gt 0) { $first } else { $first.AddMonths(1).AddDays(-1) })
            if (-not $problem) { Get-NthCalendarWorkdayInMonth $resolved $day $Nth }
        }
        catch { $problem = $_ }
        if ($problem) { Write-CalendarError $PSCmdlet $problem }
    }
}
#endregion

#region Public/Get-PreviousOrSameWorkday.ps1
<#
.SYNOPSIS
Returns the date itself if it is a workday, otherwise the last workday before it.

.DESCRIPTION
The "preceding" business day convention: a non-working date moves back to the previous workday.

.PARAMETER Date
The date; the time of day is ignored. Today when omitted. Accepts pipeline input, also from a
property named Date.

.PARAMETER Calendar
The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them,
combined so that a day is a workday only if it is a workday in all of them. When omitted, the
default calendar is used (see Set-WorkdayCalendar).

.EXAMPLE
Get-PreviousOrSameWorkday -Date 2026-11-22 -Calendar ./holidays/br.txt

2026-11-19: the 22nd and 21st are a weekend and the 20th is a holiday in Brazil.

.EXAMPLE
Get-PreviousOrSameWorkday

Today if it is a workday, otherwise the last workday before it.

.NOTES
An error about a date (one the calendar does not cover, for instance) stops the command. With
-ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and
goes on with the next ones, and -ErrorVariable collects the errors.

.OUTPUTS
System.DateTime
#>

function Get-PreviousOrSameWorkday {
    [CmdletBinding()]
    [OutputType([datetime])]
    param(
        [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [datetime]$Date = [datetime]::Today,

        [object[]]$Calendar
    )

    begin {
        try { $resolved = Resolve-Calendar $Calendar }
        catch { $PSCmdlet.ThrowTerminatingError($_) }
    }

    process {
        # The date is checked first: a date outside the calendar is then reported without an exception
        $problem = $null
        try {
            $day = ConvertTo-CalendarDate $Date
            $problem = Get-CalendarCoverageError $resolved $day
            if (-not $problem) { Find-Workday $resolved $day -1 -OrSame }
        }
        catch { $problem = $_ }
        if ($problem) { Write-CalendarError $PSCmdlet $problem }
    }
}
#endregion

#region Public/Get-PreviousWorkday.ps1
<#
.SYNOPSIS
Returns the last workday before a date.

.DESCRIPTION
The date itself never counts, workday or not. Use Get-PreviousOrSameWorkday to keep a date that is
already a workday.

.PARAMETER Date
The date; the time of day is ignored. Today when omitted. Accepts pipeline input, also from a
property named Date.

.PARAMETER Calendar
The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them,
combined so that a day is a workday only if it is a workday in all of them. When omitted, the
default calendar is used (see Set-WorkdayCalendar).

.EXAMPLE
Get-PreviousWorkday -Date 2026-11-23 -Calendar ./holidays/br.txt

2026-11-19: the 22nd and 21st are a weekend and the 20th is a holiday in Brazil.

.EXAMPLE
$reference = Get-PreviousWorkday

The last workday before today, typically the date of the latest market data.

.NOTES
An error about a date (one the calendar does not cover, for instance) stops the command. With
-ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and
goes on with the next ones, and -ErrorVariable collects the errors.

.OUTPUTS
System.DateTime
#>

function Get-PreviousWorkday {
    [CmdletBinding()]
    [OutputType([datetime])]
    param(
        [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [datetime]$Date = [datetime]::Today,

        [object[]]$Calendar
    )

    begin {
        try { $resolved = Resolve-Calendar $Calendar }
        catch { $PSCmdlet.ThrowTerminatingError($_) }
    }

    process {
        # The date is checked first: a date outside the calendar is then reported without an exception
        $problem = $null
        try {
            $day = ConvertTo-CalendarDate $Date
            $problem = Get-CalendarCoverageError $resolved $day
            if (-not $problem) { Find-Workday $resolved $day -1 }
        }
        catch { $problem = $_ }
        if ($problem) { Write-CalendarError $PSCmdlet $problem }
    }
}
#endregion

#region Public/Get-Workday.ps1
<#
.SYNOPSIS
Lists the workdays between two dates.

.DESCRIPTION
Both dates are included when they are workdays. When End comes before Start, the list runs
backwards, from Start to End.

.PARAMETER Start
The first date; the time of day is ignored.

.PARAMETER End
The last date; the time of day is ignored.

.PARAMETER Calendar
The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them,
combined so that a day is a workday only if it is a workday in all of them. When omitted, the
default calendar is used (see Set-WorkdayCalendar).

.EXAMPLE
Get-Workday 2026-11-16 2026-11-24 -Calendar ./holidays/br.txt

The 16th, 17th, 18th, 19th, 23rd and 24th of November 2026; the 20th is a holiday in Brazil.

.EXAMPLE
foreach ($day in Get-Workday 2026-01-01 2026-01-31) { ./Import-Prices.ps1 -Date $day }

Runs an import for each workday of January.

.OUTPUTS
System.DateTime
#>

function Get-Workday {
    [CmdletBinding()]
    [OutputType([datetime])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [datetime]$Start,

        [Parameter(Mandatory, Position = 1)]
        [datetime]$End,

        [object[]]$Calendar
    )

    try {
        $resolved = Resolve-Calendar $Calendar
        $day = ConvertTo-CalendarDate $Start
        $last = ConvertTo-CalendarDate $End
        $step = if ($day -le $last) { 1 } else { -1 }

        # Both ends first, so that a range the calendar does not fully cover fails before any output
        Assert-CalendarCoverage $resolved $day
        Assert-CalendarCoverage $resolved $last

        while ($true) {
            if (Test-CalendarDay $resolved $day) { $day }
            if ($day -eq $last) { break }
            $day = $day.AddDays($step)
        }
    }
    catch {
        $PSCmdlet.ThrowTerminatingError((ConvertTo-CalendarErrorRecord $_))
    }
}
#endregion

#region Public/Get-WorkdayCalendar.ps1
<#
.SYNOPSIS
Returns a calendar: the default one, or the one given.

.DESCRIPTION
Without -Calendar, returns the calendar the module uses by default (see Set-WorkdayCalendar). With
it, returns the calendar it describes, which is handy to read a holiday file once, or to combine
several, and pass the result to many calls.

The calendar shows its name, its non-working weekdays and the period it covers; its Holidays
property lists the holidays.

.PARAMETER Calendar
The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them,
combined so that a day is a workday only if it is a workday in all of them.

.EXAMPLE
Get-WorkdayCalendar

Shows the calendar in effect.

.EXAMPLE
$both = Get-WorkdayCalendar ./holidays/br.txt, ./holidays/us.txt
Get-NextWorkday -Calendar $both

Combines two calendars once and reuses the result.

.EXAMPLE
(Get-WorkdayCalendar ./holidays/br.txt).Holidays | Where-Object Year -EQ 2026

Lists the holidays of 2026.

.OUTPUTS
ElektoCalendarPosh.Calendar
#>

function Get-WorkdayCalendar {
    [CmdletBinding()]
    [OutputType('ElektoCalendarPosh.Calendar')]
    param(
        [Parameter(Position = 0)]
        [object[]]$Calendar
    )

    try {
        Resolve-Calendar $Calendar
    }
    catch {
        $PSCmdlet.ThrowTerminatingError($_)
    }
}
#endregion

#region Public/Get-WorkdayDelta.ps1
<#
.SYNOPSIS
Counts the workdays between two dates.

.DESCRIPTION
By default (the Financial mode) the count includes the start date, when it is a workday, and
excludes the end date: the Brazilian financial convention, set by Banco Central's Circular 2.456 of
1994 ("the count of workdays between two dates shall include the first and exclude the last"). From
a workday, it counts the steps of Add-Workday: Get-WorkdayDelta $d (Add-Workday 5 $d) is 5.

The result is negative when End comes before Start, with the same magnitude as the other way round.

Other modes:

- Full: includes both dates when they are workdays, like Excel's NETWORKDAYS.
- None: the same as Financial.
- StartOnNextWork, StartOnPrevWork: a non-working start moves first to the next (previous) workday.
- EndOnNextWork, EndOnPrevWork: the same for the end.
- StartAndEndOnNext, StartAndEndOnPrev: both move to the next (previous) workday.
- StartAndEndExpanding: the start moves back and the end moves forward.
- StartAndEndCollapsing: the start moves forward and the end moves back.

.PARAMETER Start
The start date; the time of day is ignored. Today when omitted.

.PARAMETER End
The end date; the time of day is ignored. Today when omitted.

Either date can come from the pipeline, or from a property of the same name: piped values go to the
one not given by name, to Start when neither is.

.PARAMETER Mode
How to count the ends: Financial (default), Full, None, StartOnNextWork, StartOnPrevWork,
EndOnNextWork, EndOnPrevWork, StartAndEndOnNext, StartAndEndOnPrev, StartAndEndExpanding or
StartAndEndCollapsing.

.PARAMETER Calendar
The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them,
combined so that a day is a workday only if it is a workday in all of them. When omitted, the
default calendar is used (see Set-WorkdayCalendar).

.EXAMPLE
Get-WorkdayDelta -Start 2026-11-16 -End 2026-11-24 -Calendar ./holidays/br.txt

5: the 16th, 17th, 18th, 19th and 23rd. The 20th is a holiday and the 24th, the end, does not count.

.EXAMPLE
Get-WorkdayDelta -Start 2026-11-16 -End 2026-11-24 -Mode Full -Calendar ./holidays/br.txt

6: now the 24th counts too.

.EXAMPLE
'2026-12-31', '2027-06-30' | Get-WorkdayDelta -Start (Get-Date)

The workdays from today until each date.

.NOTES
An error about a date (one the calendar does not cover, for instance) stops the command. With
-ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and
goes on with the next ones, and -ErrorVariable collects the errors.

.OUTPUTS
System.Int32
#>

function Get-WorkdayDelta {
    [CmdletBinding()]
    [OutputType([int])]
    param(
        [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [datetime]$Start = [datetime]::Today,

        [Parameter(Position = 1, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [datetime]$End = [datetime]::Today,

        [ValidateSet('Financial', 'Full', 'None', 'StartOnNextWork', 'StartOnPrevWork', 'EndOnNextWork',
            'EndOnPrevWork', 'StartAndEndOnNext', 'StartAndEndOnPrev', 'StartAndEndExpanding',
            'StartAndEndCollapsing')]
        [string]$Mode = 'Financial',

        [object[]]$Calendar
    )

    begin {
        try { $resolved = Resolve-Calendar $Calendar }
        catch { $PSCmdlet.ThrowTerminatingError($_) }
    }

    process {
        # Both dates are checked first: one outside the calendar is then reported without an exception.
        # Not for the same date twice, which counts 0 without consulting the calendar, except in Full.
        $problem = $null
        try {
            $from = ConvertTo-CalendarDate $Start
            $to = ConvertTo-CalendarDate $End
            if ($from -ne $to -or $Mode -eq 'Full') {
                $problem = (Get-CalendarCoverageError $resolved $from) ?? (Get-CalendarCoverageError $resolved $to)
            }
            if (-not $problem) { Get-CalendarDelta $resolved $from $to $Mode }
        }
        catch { $problem = $_ }
        if ($problem) { Write-CalendarError $PSCmdlet $problem }
    }
}
#endregion

#region Public/New-WorkdayCalendar.ps1
<#
.SYNOPSIS
Creates a workday calendar from a holiday file, a list of dates, or both.

.DESCRIPTION
A calendar is a set of non-working weekdays (Saturday and Sunday, unless told otherwise) plus a set
of holidays. Every function in this module takes the calendar in its -Calendar parameter, which also
accepts the path of a holiday file directly; New-WorkdayCalendar is for when the file needs
adjustments (another name, another weekend, extra holidays) or when there is no file at all.

The holiday file is plain text, UTF-8:

    # Comments start with '#'. Blank lines are ignored.
    # Optional, before the first holiday: the non-working weekdays, by English name.
    # Saturday and Sunday when omitted.
    Saturday Sunday

    # One holiday per line, as yyyy-MM-dd; the rest of the line is a free description.
    2026-01-01 New Year's Day
    2026-11-20 Black Consciousness Day
    2026-12-25 Christmas Day
    2027-01-01 New Year's Day

Instead of weekday names, the word NoWeekend, as the first line that is not a comment, makes every
day of the week a workday: only the holidays are not.

A calendar covers from its first holiday to its last, and asking about a date outside them is an
error: a file that lacks next year's holidays must not treat them as workdays. Listing the next
January 1st, as above, covers the end of the year. A single holiday covers from a year before it to
a year after it, and a calendar without holidays covers every date.

.PARAMETER Path
The holiday file. Relative paths are relative to the current location.

.PARAMETER Holiday
Holidays to add, besides those in the file.

.PARAMETER Weekend
The non-working weekdays, replacing those declared in the file and the default Saturday and Sunday.
An empty list (@()) makes every weekday a workday.

.PARAMETER Name
The calendar name, shown in messages. The file name without extension when omitted.

.EXAMPLE
$br = New-WorkdayCalendar -Path ./holidays/br.txt
Get-NextWorkday -Date 2026-11-19 -Calendar $br

Reads the file once and uses the calendar.

.EXAMPLE
New-WorkdayCalendar -Path ./holidays/br.txt -Holiday 2026-12-24, 2026-12-31 -Name 'br-office'

The national calendar plus two days the office is closed.

.EXAMPLE
New-WorkdayCalendar -Weekend Friday, Saturday

A calendar for a Friday and Saturday weekend, without holidays.

.OUTPUTS
ElektoCalendarPosh.Calendar
#>

function New-WorkdayCalendar {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Only creates an object in memory.')]
    [CmdletBinding()]
    [OutputType('ElektoCalendarPosh.Calendar')]
    param(
        [Parameter(Position = 0)]
        [string]$Path,

        [datetime[]]$Holiday = @(),

        [DayOfWeek[]]$Weekend,

        [string]$Name
    )

    try {
        $holidays = [System.Collections.Generic.List[datetime]]::new()
        $weekendDays = $script:DefaultWeekend
        $source = $null

        if ($Path) {
            $parsed = Read-CalendarFile (Get-FullPath $Path)
            $holidays.AddRange($parsed.Holidays)
            if ($parsed.WeekendDeclared) { $weekendDays = $parsed.Weekend }
            if (-not $Name) { $Name = $parsed.Name }
            $source = $parsed.Path
        }

        foreach ($day in $Holiday) { $holidays.Add($day) }
        if ($PSBoundParameters.ContainsKey('Weekend')) { $weekendDays = $Weekend }
        if (-not $Name) { $Name = 'Custom' }

        New-CalendarObject -Name $Name -Path $source -Holidays $holidays -Weekend $weekendDays
    }
    catch {
        $PSCmdlet.ThrowTerminatingError($_)
    }
}
#endregion

#region Public/Set-WorkdayCalendar.ps1
<#
.SYNOPSIS
Sets the calendar the module uses when a function is called without -Calendar.

.DESCRIPTION
Without -Calendar, every function uses the default calendar, which is, in order of precedence:

1. The calendar set by Set-WorkdayCalendar in the current session.
2. The holiday file named by the WORKDAY_CALENDAR environment variable (several files may be given,
   separated by ';' on Windows and ':' elsewhere, and are combined).
3. Saturday and Sunday as the only non-working days.

Calling Set-WorkdayCalendar without a calendar goes back to the second and third options.

A holiday file set here is read again whenever it changes, so a long-running script always sees the
current version.

.PARAMETER Calendar
The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them,
combined so that a day is a workday only if it is a workday in all of them.

.PARAMETER PassThru
Returns the calendar now in effect.

.EXAMPLE
Set-WorkdayCalendar ./holidays/br.txt
Add-Workday 2

At the top of a script: from then on, every function uses the Brazilian calendar.

.EXAMPLE
Set-WorkdayCalendar ./holidays/br.txt, ./holidays/us.txt

A day is a workday only if it is a workday both in Brazil and in the United States.

.EXAMPLE
Set-WorkdayCalendar

Back to WORKDAY_CALENDAR or, without it, to Saturday and Sunday only.

.OUTPUTS
ElektoCalendarPosh.Calendar, with -PassThru.
#>

function Set-WorkdayCalendar {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType('ElektoCalendarPosh.Calendar')]
    param(
        [Parameter(Position = 0)]
        [object[]]$Calendar,

        [switch]$PassThru
    )

    try {
        # Paths become absolute now, so that a later change of location does not change the calendar
        $items = @(foreach ($item in $Calendar) { if ($item -is [string]) { Get-FullPath $item } else { $item } })

        # Resolving now reports a missing or malformed file here, not on first use
        $target = if ($items.Count -gt 0) { "calendar '$((Resolve-Calendar $items).Name)'" } else { 'the built-in default' }
        if ($PSCmdlet.ShouldProcess('default workday calendar', "Set to $target")) {
            $script:DefaultCalendar = if ($items.Count -gt 0) { $items } else { $null }
        }

        if ($PassThru) { Get-DefaultCalendar }
    }
    catch {
        $PSCmdlet.ThrowTerminatingError($_)
    }
}
#endregion

#region Public/Test-NextDayIsWorkday.ps1
<#
.SYNOPSIS
Tells whether the day after a date is a workday.

.DESCRIPTION
Looks at the next calendar day, whatever the date itself is: true on the eve of a workday. Handy for
jobs that prepare the next business day, which must run on a Sunday before a working Monday but not
on a Thursday before a holiday.

.PARAMETER Date
The date; the time of day is ignored. Today when omitted. Accepts pipeline input, also from a
property named Date.

.PARAMETER Calendar
The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them,
combined so that a day is a workday only if it is a workday in all of them. When omitted, the
default calendar is used (see Set-WorkdayCalendar).

.EXAMPLE
Test-NextDayIsWorkday -Date 2026-11-19 -Calendar ./holidays/br.txt

False: 2026-11-20 is a holiday in Brazil.

.EXAMPLE
if (Test-NextDayIsWorkday) { ./Prepare-NextDay.ps1 }

Prepares tomorrow only when tomorrow is a workday.

.NOTES
An error about a date (one the calendar does not cover, for instance) stops the command. With
-ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and
goes on with the next ones, and -ErrorVariable collects the errors.

.OUTPUTS
System.Boolean
#>

function Test-NextDayIsWorkday {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [datetime]$Date = [datetime]::Today,

        [object[]]$Calendar
    )

    begin {
        try { $resolved = Resolve-Calendar $Calendar }
        catch { $PSCmdlet.ThrowTerminatingError($_) }
    }

    process {
        # The day is checked first: a day outside the calendar is then reported without an exception
        $problem = $null
        try {
            $day = (ConvertTo-CalendarDate $Date).AddDays(1)
            $problem = Get-CalendarCoverageError $resolved $day
            if (-not $problem) { Test-CalendarDay $resolved $day }
        }
        catch { $problem = $_ }
        if ($problem) { Write-CalendarError $PSCmdlet $problem }
    }
}
#endregion

#region Public/Test-PreviousDayIsWorkday.ps1
<#
.SYNOPSIS
Tells whether the day before a date was a workday.

.DESCRIPTION
Looks at the previous calendar day, whatever the date itself is: true on the day after a workday.
Handy for jobs that process the previous business day, which must run on a Saturday after a working
Friday but not on a Tuesday after a holiday.

.PARAMETER Date
The date; the time of day is ignored. Today when omitted. Accepts pipeline input, also from a
property named Date.

.PARAMETER Calendar
The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them,
combined so that a day is a workday only if it is a workday in all of them. When omitted, the
default calendar is used (see Set-WorkdayCalendar).

.EXAMPLE
Test-PreviousDayIsWorkday -Date 2026-11-21 -Calendar ./holidays/br.txt

False: 2026-11-20 is a holiday in Brazil.

.EXAMPLE
if (Test-PreviousDayIsWorkday) { ./Import-YesterdayTrades.ps1 }

Imports yesterday's trades only when yesterday was a workday.

.NOTES
An error about a date (one the calendar does not cover, for instance) stops the command. With
-ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and
goes on with the next ones, and -ErrorVariable collects the errors.

.OUTPUTS
System.Boolean
#>

function Test-PreviousDayIsWorkday {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [datetime]$Date = [datetime]::Today,

        [object[]]$Calendar
    )

    begin {
        try { $resolved = Resolve-Calendar $Calendar }
        catch { $PSCmdlet.ThrowTerminatingError($_) }
    }

    process {
        # The day is checked first: a day outside the calendar is then reported without an exception
        $problem = $null
        try {
            $day = (ConvertTo-CalendarDate $Date).AddDays(-1)
            $problem = Get-CalendarCoverageError $resolved $day
            if (-not $problem) { Test-CalendarDay $resolved $day }
        }
        catch { $problem = $_ }
        if ($problem) { Write-CalendarError $PSCmdlet $problem }
    }
}
#endregion

#region Public/Test-Workday.ps1
<#
.SYNOPSIS
Tells whether a date is a workday.

.DESCRIPTION
A date is a workday when it is neither one of the calendar's non-working weekdays (Saturday and
Sunday, unless the calendar says otherwise) nor one of its holidays.

.PARAMETER Date
The date; the time of day is ignored. Today when omitted. Accepts pipeline input, also from a
property named Date.

.PARAMETER Calendar
The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them,
combined so that a day is a workday only if it is a workday in all of them. When omitted, the
default calendar is used (see Set-WorkdayCalendar).

.EXAMPLE
Test-Workday -Date 2026-11-20 -Calendar ./holidays/br.txt

False when br.txt lists 2026-11-20 (Black Consciousness Day) as a holiday.

.EXAMPLE
if (Test-Workday) { ./Send-DailyReport.ps1 }

Sends the report only on workdays of the default calendar.

.EXAMPLE
'2026-11-19', '2026-11-20', '2026-11-21' | Test-Workday -Calendar ./holidays/br.txt

True, False, False: a Thursday, a holiday and a Saturday.

.NOTES
An error about a date (one the calendar does not cover, for instance) stops the command. With
-ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and
goes on with the next ones, and -ErrorVariable collects the errors.

.OUTPUTS
System.Boolean
#>

function Test-Workday {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [datetime]$Date = [datetime]::Today,

        [object[]]$Calendar
    )

    begin {
        try { $resolved = Resolve-Calendar $Calendar }
        catch { $PSCmdlet.ThrowTerminatingError($_) }
    }

    process {
        # The date is checked first: a date outside the calendar is then reported without an exception
        $problem = $null
        try {
            $day = ConvertTo-CalendarDate $Date
            $problem = Get-CalendarCoverageError $resolved $day
            if (-not $problem) { Test-CalendarDay $resolved $day }
        }
        catch { $problem = $_ }
        if ($problem) { Write-CalendarError $PSCmdlet $problem }
    }
}
#endregion