packages feed

keiro-dsl-0.11.0.0: src/Keiro/Dsl/SidecarMigration.hs

-- | Refuse-then-apply migration for renamed scaffold sidecars.
--
-- Planning inspects exact old and new paths before any ledger read. Applying a
-- rename preserves the bytes directly; applying a retirement moves the legacy
-- duplicate into the recoverable sidecar backup slot. Legacy conformance
-- records are converted to the forward-compatible ledger format before their
-- original bytes are retired.
module Keiro.Dsl.SidecarMigration
  ( SidecarScope (..),
    SidecarMoveDisposition (..),
    SidecarMove (..),
    PreparedSidecarMove,
    preparedSidecarMove,
    planSidecarMigrations,
    applyPreparedSidecarMoves,
    renderSidecarMove,
  )
where

import Control.Exception (IOException, bracketOnError, try)
import Control.Monad (filterM, when)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Keiro.Dsl.ConformancePackage
  ( ConformancePackagePlan (cppDirectory),
    parseLegacyConformancePackageRecord,
    renderConformancePackageRecord,
  )
import Keiro.Dsl.Scaffold (isGeneratedBannerLine)
import Keiro.Dsl.SidecarNames
import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, listDirectory, removeFile, renameFile)
import System.FilePath (takeDirectory, takeFileName, (</>))
import System.IO (Handle, hClose, openTempFile)

data SidecarScope
  = ContextSidecars !Text
  | WorkspaceSidecars !Text
  deriving stock (Eq, Show)

data SidecarMoveDisposition
  = RenameSidecar
  | RetireLegacySidecar
  | ConvertLegacyConformanceLedger
  deriving stock (Eq, Show)

data SidecarMove = SidecarMove
  { sidecarOldPath :: !FilePath,
    sidecarNewPath :: !FilePath,
    sidecarBackupPath :: !(Maybe FilePath),
    sidecarMoveDisposition :: !SidecarMoveDisposition
  }
  deriving stock (Eq, Show)

data PreparedSidecarMove = PreparedSidecarMove
  { preparedSidecarMove :: !SidecarMove,
    preparedConvertedContents :: !(Maybe Text)
  }
  deriving stock (Eq, Show)

-- | Inspect the two scope-specific sidecars and the optional generated
-- conformance package. An old-name file always produces a move: either a direct
-- rename when the new path is absent, or retirement when the new path is already
-- authoritative.
planSidecarMigrations :: FilePath -> SidecarScope -> Maybe ConformancePackagePlan -> IO (Either [Text] [PreparedSidecarMove])
planSidecarMigrations out scope conformancePlan = do
  ordinaryResults <- traverse (planOrdinary out) (scopePairs scope)
  -- Legacy conformance records are migrated wherever they are found in the out
  -- tree, not only when this run happens to plan a conformance package. Keying
  -- the migration on the plan left a record orphaned — and unreadable, since the
  -- legacy parser is no longer reachable from the current reader — as soon as a
  -- spec stopped generating a conformance package. See ExecPlan 199.
  legacyDirectories <- legacyConformanceDirectories out plannedDirectory
  conformanceResults <-
    traverse
      ( \directory ->
          planConformance
            out
            (directory </> legacyConformanceRecordFileName)
            (directory </> conformanceLedgerFileName)
      )
      (plannedDirectories <> legacyDirectories)
  let results = ordinaryResults <> conformanceResults
      errors = [message | Left message <- results]
      moves = [move | Right (Just move) <- results]
  pure $ if null errors then Right moves else Left errors
  where
    plannedDirectory = fmap cppDirectory conformancePlan
    plannedDirectories = maybe [] (: []) plannedDirectory

-- | Directories under @out@ holding a legacy conformance record, excluding the
-- one this run already plans. Bounded to the depth generated conformance
-- packages actually use, so it never walks a consumer's whole source tree.
legacyConformanceDirectories :: FilePath -> Maybe FilePath -> IO [FilePath]
legacyConformanceDirectories out planned = do
  candidates <- descend legacyConformanceSearchDepth ""
  filterM
    (\directory -> doesFileExist (out </> directory </> legacyConformanceRecordFileName))
    [directory | directory <- candidates, Just directory /= planned]
  where
    descend :: Int -> FilePath -> IO [FilePath]
    descend depth relative
      | depth < 0 = pure []
      | otherwise = do
          entries <- listDirectorySafe (out </> relative)
          children <-
            filterM
              (\name -> doesDirectoryExist (out </> relative </> name))
              -- Never descend into the migration backup root. A retired legacy
              -- record lives there permanently by design, so scanning it would
              -- make every later run want to "migrate" the backup, forever.
              [name | name <- entries, name /= sidecarBackupRootName]
          nested <-
            concat
              <$> traverse
                (\name -> descend (depth - 1) (if null relative then name else relative </> name))
                children
          pure ((if null relative then [] else [relative]) <> nested)

-- | Generated conformance packages sit at most this many directories below the
-- scaffold output root (@<out>/<package-dir>/@ plus room for a nested layout).
legacyConformanceSearchDepth :: Int
legacyConformanceSearchDepth = 3

listDirectorySafe :: FilePath -> IO [FilePath]
listDirectorySafe path = do
  exists <- doesDirectoryExist path
  if exists then listDirectory path else pure []

scopePairs :: SidecarScope -> [(FilePath, FilePath)]
scopePairs = \case
  ContextSidecars context ->
    [ (legacyContextRecordFileName context, contextLedgerFileName context),
      (legacyContextManifestFileName context, contextCabalFragmentFileName context)
    ]
  WorkspaceSidecars service ->
    [ (legacyWorkspaceRecordFileName service, workspaceLedgerFileName service),
      (legacyWorkspaceManifestFileName service, workspaceCabalFragmentFileName service)
    ]

planOrdinary :: FilePath -> (FilePath, FilePath) -> IO (Either Text (Maybe PreparedSidecarMove))
planOrdinary out (oldRelative, newRelative) = do
  oldExists <- doesFileExist (out </> oldRelative)
  newExists <- doesFileExist (out </> newRelative)
  if not oldExists
    then pure (Right Nothing)
    else
      if newExists
        then prepareRetirement out oldRelative newRelative
        else
          pure . Right . Just $
            PreparedSidecarMove
              { preparedSidecarMove =
                  SidecarMove
                    { sidecarOldPath = oldRelative,
                      sidecarNewPath = newRelative,
                      sidecarBackupPath = Nothing,
                      sidecarMoveDisposition = RenameSidecar
                    },
                preparedConvertedContents = Nothing
              }

planConformance :: FilePath -> FilePath -> FilePath -> IO (Either Text (Maybe PreparedSidecarMove))
planConformance out oldRelative newRelative = do
  oldExists <- doesFileExist (out </> oldRelative)
  newExists <- doesFileExist (out </> newRelative)
  if not oldExists
    then pure (Right Nothing)
    else
      if newExists
        then prepareRetirement out oldRelative newRelative
        else do
          legacyContents <- TIO.readFile (out </> oldRelative)
          case parseLegacyConformancePackageRecord legacyContents of
            Nothing -> pure (Left (T.pack oldRelative <> ": legacy conformance record is invalid and cannot be converted"))
            Just record -> do
              let backupRelative = sidecarBackupRelative oldRelative
              backupExists <- doesFileExist (out </> backupRelative)
              if backupExists
                then pure (Left (T.pack oldRelative <> ": sidecar migration backup already exists at " <> T.pack backupRelative))
                else
                  pure . Right . Just $
                    PreparedSidecarMove
                      { preparedSidecarMove =
                          SidecarMove
                            { sidecarOldPath = oldRelative,
                              sidecarNewPath = newRelative,
                              sidecarBackupPath = Just backupRelative,
                              sidecarMoveDisposition = ConvertLegacyConformanceLedger
                            },
                        preparedConvertedContents = Just (preserveBanner legacyContents <> renderConformancePackageRecord record)
                      }

prepareRetirement :: FilePath -> FilePath -> FilePath -> IO (Either Text (Maybe PreparedSidecarMove))
prepareRetirement out oldRelative newRelative = do
  let backupRelative = sidecarBackupRelative oldRelative
  backupExists <- doesFileExist (out </> backupRelative)
  if backupExists
    then pure (Left (T.pack oldRelative <> ": sidecar migration backup already exists at " <> T.pack backupRelative))
    else
      pure . Right . Just $
        PreparedSidecarMove
          { preparedSidecarMove =
              SidecarMove
                { sidecarOldPath = oldRelative,
                  sidecarNewPath = newRelative,
                  sidecarBackupPath = Just backupRelative,
                  sidecarMoveDisposition = RetireLegacySidecar
                },
            preparedConvertedContents = Nothing
          }

sidecarBackupRelative :: FilePath -> FilePath
sidecarBackupRelative oldRelative = sidecarBackupRootName </> "sidecar-v1" </> oldRelative

-- | The directory holding recoverable originals of retired legacy sidecars.
sidecarBackupRootName :: FilePath
sidecarBackupRootName = ".keiro-dsl-name-migrations"

preserveBanner :: Text -> Text
preserveBanner contents = T.unlines [line | line <- T.lines contents, isGeneratedBannerLine line]

applyPreparedSidecarMoves :: FilePath -> [PreparedSidecarMove] -> IO ()
applyPreparedSidecarMoves out = mapM_ applyOne
  where
    applyOne prepared = case sidecarMoveDisposition move of
      RenameSidecar -> do
        createDirectoryIfMissing True (takeDirectory newPath)
        renameFile oldPath newPath
      RetireLegacySidecar -> retire move oldPath
      ConvertLegacyConformanceLedger -> case preparedConvertedContents prepared of
        Nothing -> error "prepared conformance sidecar conversion lacks converted contents"
        Just converted -> do
          writeTextAtomic newPath converted
          retire move oldPath
      where
        move = preparedSidecarMove prepared
        oldPath = out </> sidecarOldPath move
        newPath = out </> sidecarNewPath move

    retire move oldPath = case sidecarBackupPath move of
      Nothing -> error "prepared sidecar retirement lacks a backup path"
      Just backupRelative -> do
        let backupPath = out </> backupRelative
        createDirectoryIfMissing True (takeDirectory backupPath)
        renameFile oldPath backupPath

writeTextAtomic :: FilePath -> Text -> IO ()
writeTextAtomic path contents = do
  let directory = takeDirectory path
  createDirectoryIfMissing True directory
  bracketOnError
    (openTempFile directory (takeFileName path <> ".tmp"))
    cleanupTemporary
    (\(temporary, handle) -> TIO.hPutStr handle contents >> hClose handle >> renameFile temporary path)

cleanupTemporary :: (FilePath, Handle) -> IO ()
cleanupTemporary (temporary, handle) = do
  _ <- try (hClose handle) :: IO (Either IOException ())
  exists <- doesFileExist temporary
  when exists (removeFile temporary)

renderSidecarMove :: SidecarMove -> Text
renderSidecarMove move = case sidecarMoveDisposition move of
  RenameSidecar -> path (sidecarOldPath move) <> " -> " <> path (sidecarNewPath move)
  RetireLegacySidecar -> path (sidecarOldPath move) <> " -> retired to " <> backup
  ConvertLegacyConformanceLedger -> path (sidecarOldPath move) <> " -> " <> path (sidecarNewPath move) <> "; original retired to " <> backup
  where
    path = T.pack
    backup = maybe "<missing-backup>" path (sidecarBackupPath move)