packages feed

sydtest-sqitch-postgres-0.1.0.0: src/Test/Syd/Sqitch/Postgresql/Plan.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Parsing a @sqitch.plan@ file into a sequence of deploy steps.
module Test.Syd.Sqitch.Postgresql.Plan
  ( PlanStep (..),
    readSqitchPlan,
  )
where

import qualified Data.ByteString as SB
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text
import Path

-- | One step in a sqitch plan: enough information to drive @sqitch deploy@,
-- find the deploy script on disk, identify the step in test output, and
-- decide whether the idempotence check applies.
data PlanStep = PlanStep
  { -- | Human label used as test 'context'.
    stepLabel :: Text,
    -- | Argument to @sqitch deploy --to@ to bring the database to the
    -- post-step state.
    stepDeployTarget :: Text,
    -- | Filename (without directory) in @deploy/@, e.g. @init.sql@ or
    -- @add-foo\@v2026-01-01.sql@.
    stepScriptName :: Text,
    -- | If 'True', skip the per-change round-trip /and/ idempotence
    -- checks for this step. Set when the step is at or before the
    -- grandfather tag (see 'readSqitchPlan').
    --
    -- These checks are end-state invariants that grandfathered scripts
    -- aren't expected to satisfy in isolation -- they shipped before the
    -- checks existed, and the failure modes they guard against (revert
    -- drift, registry retry) can't manifest on databases that already
    -- ran them. The whole-plan cycle and schema-equality checks still
    -- apply to grandfathered steps end-to-end.
    stepIsGrandfathered :: Bool,
    -- | If 'True', this step is the post-tag head of a reworked change
    -- (its deploy target is @name\@HEAD@). The round-trip check is
    -- skipped on such steps because sqitch's revert of just the rework
    -- runs the rework's revert script, which by sqitch convention
    -- undoes the whole change rather than only the rework. A partial
    -- revert/redeploy therefore does not land at post(rework) and would
    -- fail the round-trip check spuriously. Whole-plan deploy/revert
    -- cycles still exercise this code path.
    stepIsReworkHead :: Bool
  }
  deriving (Show, Eq)

-- | Parse a @sqitch.plan@ file into one 'PlanStep' per change.
--
-- The optional grandfather tag marks a cut-over point: every change in
-- plan order at or before the tag is marked 'stepIsGrandfathered = True';
-- every change after it is 'False'. Pass 'Nothing' to mean \"no
-- grandfathering -- every change must be idempotent\".
--
-- If the tag is 'Just' but does not appear in the plan, 'fail' (a typo
-- in the tag should not silently pass as \"nothing is grandfathered\").
--
-- Reworked changes (a line of the form @name [name\@tag]@) become two
-- steps: the predecessor step deploys through @\@<tag>@ and reads
-- @deploy/name\@<tag>.sql@; the head step deploys via @name\@HEAD@ and
-- reads @deploy/name.sql@.
readSqitchPlan ::
  -- | Grandfather tag (without the leading @\@@), or 'Nothing'.
  Maybe Text ->
  Path Abs File ->
  IO [PlanStep]
readSqitchPlan mGrandfatherTag path = do
  contents <- Text.decodeUtf8Lenient <$> SB.readFile (fromAbsFile path)
  let allLines = Text.lines contents
      -- When there is no grandfather tag, no change is grandfathered;
      -- this is modelled by starting "past the tag" immediately.
      pastTagInitial = case mGrandfatherTag of
        Nothing -> True
        Just _ -> False
      steps = go pastTagInitial Map.empty allLines
      tagSeen = case mGrandfatherTag of
        Nothing -> True
        Just t -> any (tagLineMatches t) allLines
  if not tagSeen
    then
      fail $
        "sydtest-sqitch: grandfather tag '"
          <> Text.unpack (fromMaybe "" mGrandfatherTag)
          <> "' was not found in "
          <> fromAbsFile path
    else pure steps
  where
    -- A line is a tag declaration line if (after stripping) it starts with
    -- '@'. Its name is the run of non-space characters after the '@'.
    tagLineMatches :: Text -> Text -> Bool
    tagLineMatches t line =
      let stripped = Text.strip line
       in case Text.uncons stripped of
            Just ('@', rest) ->
              Text.takeWhile (\c -> c /= ' ' && c /= '\t') rest == t
            _ -> False

    -- pastTag is False until we cross the grandfather tag (or always True
    -- if there is no tag). Steps deployed while pastTag is False are
    -- grandfathered.
    go _ _ [] = []
    go pastTag seen (line : rest) =
      let stripped = Text.strip line
       in case Text.uncons stripped of
            Nothing -> go pastTag seen rest
            Just ('%', _) -> go pastTag seen rest
            Just ('@', tagRest) ->
              let tagName = Text.takeWhile (\c -> c /= ' ' && c /= '\t') tagRest
                  pastTag' = pastTag || Just tagName == mGrandfatherTag
               in go pastTag' seen rest
            _ -> case Text.words stripped of
              (name : _) ->
                let grandfathered = not pastTag
                    isHead = Map.member name seen
                    step = case findNextTagBeforeName name rest of
                      Just tagName ->
                        PlanStep
                          { stepLabel = name <> "@" <> tagName,
                            stepDeployTarget = "@" <> tagName,
                            stepScriptName = name <> "@" <> tagName,
                            stepIsGrandfathered = grandfathered,
                            stepIsReworkHead = False
                          }
                      Nothing ->
                        PlanStep
                          { stepLabel = name,
                            stepDeployTarget =
                              if isHead then name <> "@HEAD" else name,
                            stepScriptName = name,
                            stepIsGrandfathered = grandfathered,
                            stepIsReworkHead = isHead
                          }
                 in step : go pastTag (Map.insertWith (+) name (1 :: Int) seen) rest
              [] -> go pastTag seen rest

    -- For a change line @name@ at position p, look forward for the next
    -- tag line that comes before any subsequent line that also begins
    -- with @name@. If found, this is a reworked change and we want the
    -- predecessor (deploy through the tag) target.
    findNextTagBeforeName :: Text -> [Text] -> Maybe Text
    findNextTagBeforeName = scan Nothing
      where
        scan _ _ [] = Nothing
        scan tag nm (l : ls) =
          let stripped = Text.strip l
           in case Text.uncons stripped of
                Nothing -> scan tag nm ls
                Just ('%', _) -> scan tag nm ls
                Just ('@', tagRest) ->
                  let tagName = Text.takeWhile (\c -> c /= ' ' && c /= '\t') tagRest
                   in scan (Just tagName) nm ls
                _ -> case Text.words stripped of
                  (n : _) | n == nm -> tag
                  _ -> scan tag nm ls