packages feed

aws-eventbridge-cron-0.1.0.0: src/AWS/EventBridge/DayOfWeek.hs

{-# LANGUAGE OverloadedStrings #-}

module AWS.EventBridge.DayOfWeek where

import Control.Applicative ((<|>), some)
import Control.Monad (when)
import qualified Data.Map.Strict as M
import qualified Data.Set as S
import Data.Char (toUpper)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Calendar (fromGregorian, gregorianMonthLength)
import Data.Time.Calendar.WeekDate (toWeekDate)
import Data.Void (Void)
import Text.Megaparsec (Parsec, choice, eof, errorBundlePretty, parse, sepBy1, try)
import Text.Megaparsec.Char (char, letterChar)
import Text.Megaparsec.Char.Lexer (decimal)

type Parser = Parsec Void Text

data DayOfWeekExprT
  = DowAny
  | DowAll
  | DowAt Int
  | DowRange Int Int
  | DowNth Int Int
  | DowUnion DayOfWeekExprT DayOfWeekExprT
  deriving (Eq, Show)

isValidDayOfWeek :: Int -> Bool
isValidDayOfWeek d = 1 <= d && d <= 7

evaluateDayOfWeekT :: Integer -> Int -> DayOfWeekExprT -> Either String [Int]
evaluateDayOfWeekT year month expr = fmap S.toAscList (S.fromList <$> go expr)
  where
    dim = gregorianMonthLength year month
    go DowAny = Right []
    go DowAll = Right [1 .. dim]
    go (DowAt day)
      | isValidDayOfWeek day = Right (matchingDays day)
      | otherwise = Left ("invalid day-of-week: " ++ show day ++ " (expected 1..7 or SUN-SAT)")
    go (DowRange start end)
      | not (isValidDayOfWeek start) = Left ("invalid range start: " ++ show start ++ " (expected 1..7)")
      | not (isValidDayOfWeek end) = Left ("invalid range end: " ++ show end ++ " (expected 1..7)")
      | start <= end = Right (concatMap matchingDays [start .. end])
      | otherwise = Left ("invalid range: start " ++ show start ++ " > end " ++ show end)
    go (DowNth day nth)
      | not (isValidDayOfWeek day) = Left ("invalid day-of-week: " ++ show day ++ " (expected 1..7 or SUN-SAT)")
      | nth < 1 || nth > 5 = Left ("invalid # occurrence: " ++ show nth ++ " (expected 1..5)")
      | otherwise =
          let occurrences = matchingDays day
          in case drop (nth - 1) occurrences of
               (d : _) -> Right [d]
               [] -> Right []
    go (DowUnion a b) = (++) <$> go a <*> go b

    -- | Enumerate the day-of-month values that fall on the requested weekday.
    -- Example: in April 2025 (month=4) Mondays (target=2) occur on
    -- days [7,14,21,28].
    matchingDays target =
      [ dayOfMonth
      | dayOfMonth <- [1 .. dim]
      , awsDayOfWeek year month dayOfMonth == target
      ]

parseDayOfWeek :: Parser DayOfWeekExprT
parseDayOfWeek = do
  terms <- dowTerm `sepBy1` char ','
  eof
  when (any isAny terms && length terms > 1) $
    fail "? cannot be combined with other day-of-week terms"
  when (any isNth terms && length terms > 1) $
    fail "# expressions cannot be combined with other day-of-week terms"
  pure (foldUnion terms)

parseDayOfWeekText :: Text -> Either String DayOfWeekExprT
parseDayOfWeekText input =
  case parse parseDayOfWeek "day-of-week" (T.strip input) of
    Left err -> Left (errorBundlePretty err)
    Right expr -> Right expr

dowTerm :: Parser DayOfWeekExprT
dowTerm = choice
  [ DowAny <$ char '?'
  , DowAll <$ char '*'
  , try parseNthTerm
  , try parseRangeTerm
  , DowAt <$> dowValue
  ]

parseRangeTerm :: Parser DayOfWeekExprT
parseRangeTerm = do
  start <- dowValue
  _ <- char '-'
  DowRange start <$> dowValue

parseNthTerm :: Parser DayOfWeekExprT
parseNthTerm = do
  day <- dowValue
  _ <- char '#'
  DowNth day <$> decimal

dowValue :: Parser Int
dowValue = try named <|> decimal
  where
    named = do
      txt <- someLetters
      case M.lookup (T.pack txt) dayOfWeekNames of
        Just value -> pure value
        Nothing -> fail ("unknown day-of-week name: " ++ txt)

-- | Parse one or more alphabetic characters and normalise to uppercase.
-- Example: parsing "tHu" yields "THU" which matches tokens like THU/THUR.
someLetters :: Parser String
someLetters = fmap (map toUpper) (some letterChar)

dayOfWeekNames :: M.Map Text Int
dayOfWeekNames = M.fromList
  [ ("SUN", 1), ("MON", 2), ("TUE", 3), ("TUES", 3)
  , ("WED", 4), ("THU", 5), ("THUR", 5), ("THURS", 5)
  , ("FRI", 6), ("SAT", 7)
  ]

foldUnion :: [DayOfWeekExprT] -> DayOfWeekExprT
-- | Combine a non-empty list of terms into a left-associated union.
-- Example: [DowAt 1, DowRange 2 3, DowNth 5 1] becomes
-- DowUnion (DowUnion (DowAt 1) (DowRange 2 3)) (DowNth 5 1).
foldUnion [] = DowAny
foldUnion (x : xs) = foldl DowUnion x xs

isAny :: DayOfWeekExprT -> Bool
isAny DowAny = True
isAny _ = False

isNth :: DayOfWeekExprT -> Bool
isNth (DowNth _ _) = True
isNth _ = False

-- | Convert a calendar date into the AWS EventBridge weekday index.
-- Example: 2025-04-07 (a Monday) yields 2; 2025-04-13 (a Sunday) yields 1.
awsDayOfWeek :: Integer -> Int -> Int -> Int
awsDayOfWeek year month dayOfMonth =
  let (_, _, iso) = toWeekDate (fromGregorian year month dayOfMonth)
  in (iso `mod` 7) + 1