packages feed

seihou-core-0.6.0.0: src/Seihou/Engine/UpdateTransaction.hs

module Seihou.Engine.UpdateTransaction
  ( UpdateTransaction (..),
    TransactionError (..),
    beginUpdateTransaction,
    applyReconciliation,
    applyReconciliationWithHook,
    rollbackUpdateTransaction,
    setUpdateTransactionExpectedManifest,
    completeUpdateTransaction,
    recoverIncompleteTransactions,
  )
where

import Control.Exception (SomeException, bracketOnError, displayException, onException, try)
import Control.Monad (foldM, forM, forM_, unless, when)
import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.:?), (.=))
import Data.Aeson qualified as Aeson
import Data.ByteString.Lazy qualified as LBS
import Data.Foldable (traverse_)
import Data.Generics.Labels ()
import Data.List (sortOn)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Data.Time (UTCTime, getCurrentTime)
import Seihou.Core.Path (validateProjectRelativePath)
import Seihou.Core.Types
import Seihou.Effect.BaselineStore (putBaseline)
import Seihou.Effect.BaselineStoreInterp (runBaselineStore)
import Seihou.Effect.FilesystemInterp (runFilesystem)
import Seihou.Engine.Reconcile
import Seihou.Manifest.Hash (hashContent)
import Seihou.Manifest.Types (manifestFromJSON)
import Seihou.Prelude
import System.Directory qualified as Directory
import System.FilePath (splitDirectories, takeDirectory, takeFileName)
import System.IO (hClose, hSetEncoding, utf8)
import System.IO.Temp (createTempDirectory, openTempFile)

data UpdateTransaction = UpdateTransaction
  { projectRoot :: !FilePath,
    transactionDirectory :: !FilePath,
    targets :: !(Set FilePath)
  }
  deriving stock (Eq, Generic, Show)

data TransactionError
  = InvalidTransactionPath FilePath Text
  | TransactionStartFailed Text
  | TransactionJournalMalformed FilePath Text
  | TransactionUnjournaledPaths (Set FilePath)
  | TransactionUnresolvedPaths (Set FilePath)
  | TransactionStalePlan FilePath ObservedFile ObservedFile
  | TransactionApplyFailed Text (Maybe Text)
  | TransactionRollbackFailed Text
  | TransactionCompletionFailed Text
  deriving stock (Eq, Show)

data JournalEntry = JournalEntry
  { targetPath :: !FilePath,
    backupFile :: !(Maybe FilePath)
  }
  deriving stock (Eq, Generic, Show)

data JournalMetadata = JournalMetadata
  { journalVersion :: !Int,
    createdAt :: !UTCTime,
    entries :: ![JournalEntry],
    newDirectories :: ![FilePath],
    expectedManifest :: !(Maybe Manifest)
  }
  deriving stock (Eq, Generic, Show)

instance ToJSON JournalEntry where
  toJSON entry =
    Aeson.object
      [ "path" .= (entry ^. #targetPath),
        "backup" .= (entry ^. #backupFile)
      ]

instance FromJSON JournalEntry where
  parseJSON = Aeson.withObject "JournalEntry" $ \object ->
    JournalEntry <$> object .: "path" <*> object .:? "backup"

instance ToJSON JournalMetadata where
  toJSON metadata =
    Aeson.object
      [ "version" .= (metadata ^. #journalVersion),
        "createdAt" .= (metadata ^. #createdAt),
        "entries" .= (metadata ^. #entries),
        "newDirectories" .= (metadata ^. #newDirectories),
        "expectedManifest" .= (metadata ^. #expectedManifest)
      ]

instance FromJSON JournalMetadata where
  parseJSON = Aeson.withObject "JournalMetadata" $ \object -> do
    version <- object .: "version"
    unless (version == 1) (fail "unsupported transaction journal version")
    JournalMetadata
      <$> pure version
      <*> object .: "createdAt"
      <*> object .: "entries"
      <*> object .: "newDirectories"
      <*> object .:? "expectedManifest"

beginUpdateTransaction :: FilePath -> Set FilePath -> IO (Either TransactionError UpdateTransaction)
beginUpdateTransaction projectRoot targets = case traverse validateTransactionPath (Set.toAscList targets) of
  Left err -> pure (Left err)
  Right safeTargets -> do
    result <- try @SomeException $ do
      let transactionsRoot = projectRoot </> ".seihou" </> "transactions"
      Directory.createDirectoryIfMissing True transactionsRoot
      transactionDirectory <- createTempDirectory transactionsRoot "update-"
      let transaction = UpdateTransaction projectRoot transactionDirectory (Set.fromList safeTargets)
      initializeJournal transaction safeTargets `onException` cleanupDirectory transactionDirectory
      pure transaction
    pure $ first (TransactionStartFailed . exceptionText) result

initializeJournal :: UpdateTransaction -> [FilePath] -> IO ()
initializeJournal transaction safeTargets = do
  let backupDirectory = transaction ^. #transactionDirectory </> "backups"
  Directory.createDirectoryIfMissing True backupDirectory
  entries <- forM (zip [0 :: Int ..] safeTargets) $ \(index, relativePath) -> do
    let fullPath = transaction ^. #projectRoot </> relativePath
        backupName = show index <> ".txt"
        backupPath = backupDirectory </> backupName
    exists <- Directory.doesFileExist fullPath
    if exists
      then do
        TIO.readFile fullPath >>= TIO.writeFile backupPath
        pure (JournalEntry relativePath (Just backupName))
      else pure (JournalEntry relativePath Nothing)
  missingDirectories <- missingParentDirectories (transaction ^. #projectRoot) safeTargets
  now <- getCurrentTime
  writeJournal
    (transaction ^. #transactionDirectory)
    JournalMetadata
      { journalVersion = 1,
        createdAt = now,
        entries = entries,
        newDirectories = missingDirectories,
        expectedManifest = Nothing
      }

missingParentDirectories :: FilePath -> [FilePath] -> IO [FilePath]
missingParentDirectories projectRoot paths = do
  let candidates =
        Set.toAscList . Set.fromList . concatMap relativeParents $ paths
  missing <- filterMIO (fmap not . Directory.doesDirectoryExist . (projectRoot </>)) candidates
  pure (sortOn pathDepth missing)

relativeParents :: FilePath -> [FilePath]
relativeParents path =
  filter (/= ".") (takeWhile (/= ".") (iterate takeDirectory (takeDirectory path)))

applyReconciliation ::
  UpdateTransaction ->
  ReconciliationPlan ->
  Manifest ->
  IO (Either TransactionError Manifest)
applyReconciliation = applyReconciliationWithHook (const (pure ()))

-- | Hooked variant for deterministic failure-injection tests. The hook runs
-- after each successful disk write or deletion and receives a one-based count.
applyReconciliationWithHook ::
  (Int -> IO ()) ->
  UpdateTransaction ->
  ReconciliationPlan ->
  Manifest ->
  IO (Either TransactionError Manifest)
applyReconciliationWithHook afterMutation transaction plan manifest = do
  preflight <- transactionPreflight transaction plan
  case preflight of
    Left err -> failAndRollback transaction err
    Right () -> do
      prepared <- try @SomeException (prepareCandidateManifest transaction plan manifest)
      case prepared of
        Left err -> failAndRollback transaction (TransactionApplyFailed (exceptionText err) Nothing)
        Right candidate -> do
          journalResult <- updateJournalForPlan transaction plan candidate
          case journalResult of
            Left err -> failAndRollback transaction err
            Right () -> do
              mutationResult <- try @SomeException (applyMutations afterMutation transaction plan)
              case mutationResult of
                Left err ->
                  failAndRollback transaction (TransactionApplyFailed (exceptionText err) Nothing)
                Right () -> pure (Right candidate)

transactionPreflight :: UpdateTransaction -> ReconciliationPlan -> IO (Either TransactionError ())
transactionPreflight transaction plan = case traverse validateTransactionPath (Set.toAscList (plan ^. #requiredDirectories)) of
  Left err -> pure (Left err)
  Right _
    | not (Set.null unjournaled) -> pure (Left (TransactionUnjournaledPaths unjournaled))
    | not (Set.null unresolved) -> pure (Left (TransactionUnresolvedPaths unresolved))
    | otherwise -> verifyObservedFiles transaction plan
  where
    unjournaled = Map.keysSet (plan ^. #files) Set.\\ (transaction ^. #targets)
    unresolved = unresolvedPaths plan

verifyObservedFiles :: UpdateTransaction -> ReconciliationPlan -> IO (Either TransactionError ())
verifyObservedFiles transaction plan = go (Map.toAscList (plan ^. #files))
  where
    go [] = pure (Right ())
    go ((path, reconciliation) : rest) = do
      current <- observeDiskFile (transaction ^. #projectRoot </> path)
      let planned = reconciliationObservation reconciliation
      if current == planned
        then go rest
        else pure (Left (TransactionStalePlan path planned current))

reconciliationObservation :: FileReconciliation -> ObservedFile
reconciliationObservation reconciliation = case reconciliation of
  FileCreate _ _ observed -> observed
  FileUpdate _ _ observed _ -> observed
  FileAutoMerge _ _ observed _ -> observed
  FileUnchanged _ _ observed _ -> observed
  FileConflict _ _ _ _ observed _ _ -> observed
  FileDeleteSafe _ _ observed -> observed
  FileOrphanEdited _ _ _ observed _ -> observed
  FileReleaseSharedOwnership _ _ observed -> observed
  FileAlreadyAbsent _ _ observed -> observed

observeDiskFile :: FilePath -> IO ObservedFile
observeDiskFile path = do
  exists <- Directory.doesFileExist path
  if exists
    then do
      content <- TIO.readFile path
      pure (ObservedFile True (Just (hashContent content)))
    else pure (ObservedFile False Nothing)

prepareCandidateManifest :: UpdateTransaction -> ReconciliationPlan -> Manifest -> IO Manifest
prepareCandidateManifest transaction plan manifest = do
  nextFiles <- foldM applyManifestAction (manifest ^. #files) (Map.toAscList (plan ^. #files))
  pure (replaceManifestFiles manifest nextFiles)
  where
    applyManifestAction files (path, reconciliation) = case desiredState reconciliation of
      Just (desired, state) -> do
        baseline <- writeBaselineBlob (transaction ^. #projectRoot) (state ^. #generatedBaseline)
        let record =
              FileRecord
                { hash = state ^. #recordedHash,
                  moduleName = desired ^. #moduleName,
                  strategy = desired ^. #strategy,
                  generatedAt = manifest ^. #genAt,
                  baseline = Just baseline,
                  applicationIds = desired ^. #applicationIds
                }
        pure (Map.insert path record files)
      Nothing -> pure (applyOrphanManifestAction (plan ^. #applicationIds) reconciliation files)

desiredState :: FileReconciliation -> Maybe (DesiredFile, PlannedFileState)
desiredState reconciliation = case reconciliation of
  FileCreate desired state _ -> Just (desired, state)
  FileUpdate desired state _ _ -> Just (desired, state)
  FileAutoMerge desired state _ _ -> Just (desired, state)
  FileUnchanged desired state _ _ -> Just (desired, state)
  FileConflict desired _ _ _ _ _ (Just resolution) -> Just (desired, resolution ^. #state)
  _ -> Nothing

applyOrphanManifestAction ::
  Set ApplicationId ->
  FileReconciliation ->
  Map FilePath FileRecord ->
  Map FilePath FileRecord
applyOrphanManifestAction selected reconciliation files = case reconciliation of
  FileDeleteSafe path _ _ -> Map.delete path files
  FileAlreadyAbsent path _ _ -> Map.delete path files
  FileReleaseSharedOwnership path record _ ->
    let remaining = (record ^. #applicationIds) Set.\\ selected
     in if Set.null remaining
          then Map.delete path files
          else Map.insert path (replaceRecordApplications record remaining) files
  FileOrphanEdited path _ _ _ (Just DeleteEditedOrphan) -> Map.delete path files
  FileOrphanEdited _ _ _ _ (Just RetainTrackedOrphan) -> files
  FileOrphanEdited path record _ _ (Just DetachAndKeepOrphan) ->
    let remaining = (record ^. #applicationIds) Set.\\ selected
     in if Set.null remaining
          then Map.delete path files
          else Map.insert path (replaceRecordApplications record remaining) files
  _ -> files

replaceRecordApplications :: FileRecord -> Set ApplicationId -> FileRecord
replaceRecordApplications record owners =
  FileRecord
    { hash = record ^. #hash,
      moduleName = record ^. #moduleName,
      strategy = record ^. #strategy,
      generatedAt = record ^. #generatedAt,
      baseline = record ^. #baseline,
      applicationIds = owners
    }

replaceManifestFiles :: Manifest -> Map FilePath FileRecord -> Manifest
replaceManifestFiles manifest nextFiles =
  Manifest
    { version = manifest ^. #version,
      genAt = manifest ^. #genAt,
      modules = manifest ^. #modules,
      vars = manifest ^. #vars,
      files = nextFiles,
      applications = manifest ^. #applications,
      recipe = manifest ^. #recipe,
      blueprint = manifest ^. #blueprint,
      blueprintMigrations = manifest ^. #blueprintMigrations
    }

writeBaselineBlob :: FilePath -> Text -> IO BaselineRef
writeBaselineBlob projectRoot content = do
  let baselineDirectory = projectRoot </> ".seihou" </> "baselines"
  runEff $ runFilesystem $ runBaselineStore baselineDirectory (putBaseline content)

updateJournalForPlan :: UpdateTransaction -> ReconciliationPlan -> Manifest -> IO (Either TransactionError ())
updateJournalForPlan transaction plan candidate = do
  metadataResult <- readJournal (transaction ^. #transactionDirectory)
  case metadataResult of
    Left err -> pure (Left err)
    Right metadata -> do
      missingDirectories <-
        filterMIO
          (fmap not . Directory.doesDirectoryExist . (transaction ^. #projectRoot </>))
          ( Set.toAscList . Set.fromList $
              concatMap
                (\path -> path : relativeParents path)
                (Set.toAscList (plan ^. #requiredDirectories))
          )
      let updated = setExpectedManifestAndDirectories metadata missingDirectories candidate
      result <- try @SomeException $ writeJournal (transaction ^. #transactionDirectory) updated
      pure $ first (\err -> TransactionApplyFailed (exceptionText err) Nothing) result

setExpectedManifestAndDirectories :: JournalMetadata -> [FilePath] -> Manifest -> JournalMetadata
setExpectedManifestAndDirectories metadata additionalDirectories candidate =
  JournalMetadata
    { journalVersion = metadata ^. #journalVersion,
      createdAt = metadata ^. #createdAt,
      entries = metadata ^. #entries,
      newDirectories =
        sortOn pathDepth . Set.toList $
          Set.fromList (metadata ^. #newDirectories <> additionalDirectories),
      expectedManifest = Just candidate
    }

applyMutations :: (Int -> IO ()) -> UpdateTransaction -> ReconciliationPlan -> IO ()
applyMutations afterMutation transaction plan = do
  forM_ (Set.toAscList (plan ^. #requiredDirectories)) $ \relativePath ->
    Directory.createDirectoryIfMissing True (transaction ^. #projectRoot </> relativePath)
  _ <- foldM applyOne (0 :: Int) (Map.toAscList (plan ^. #files))
  pure ()
  where
    applyOne count (path, reconciliation) = case mutationFor reconciliation of
      NoMutation -> pure count
      WriteMutation content -> do
        atomicWriteText (transaction ^. #projectRoot </> path) content
        let next = count + 1
        afterMutation next
        pure next
      DeleteMutation -> do
        let fullPath = transaction ^. #projectRoot </> path
        exists <- Directory.doesFileExist fullPath
        when exists (Directory.removeFile fullPath)
        let next = count + 1
        afterMutation next
        pure next

data FileMutation = NoMutation | WriteMutation Text | DeleteMutation

mutationFor :: FileReconciliation -> FileMutation
mutationFor reconciliation = case desiredState reconciliation of
  Just (_, state)
    | state ^. #writeToDisk -> WriteMutation (state ^. #appliedContent)
    | otherwise -> NoMutation
  Nothing -> case reconciliation of
    FileDeleteSafe _ _ _ -> DeleteMutation
    FileOrphanEdited _ _ _ _ (Just DeleteEditedOrphan) -> DeleteMutation
    _ -> NoMutation

rollbackUpdateTransaction :: UpdateTransaction -> IO (Either TransactionError ())
rollbackUpdateTransaction transaction = do
  metadataResult <- readJournal (transaction ^. #transactionDirectory)
  case metadataResult of
    Left err -> pure (Left err)
    Right metadata -> do
      result <- try @SomeException $ do
        forM_ (metadata ^. #entries) (restoreEntry transaction)
        removeNewDirectories (transaction ^. #projectRoot) (metadata ^. #newDirectories)
        cleanupDirectory (transaction ^. #transactionDirectory)
      pure $ first (TransactionRollbackFailed . exceptionText) result

restoreEntry :: UpdateTransaction -> JournalEntry -> IO ()
restoreEntry transaction entry = do
  let target = transaction ^. #projectRoot </> (entry ^. #targetPath)
  case entry ^. #backupFile of
    Nothing -> do
      exists <- Directory.doesFileExist target
      when exists (Directory.removeFile target)
    Just backupName -> do
      let backupPath = transaction ^. #transactionDirectory </> "backups" </> backupName
      content <- TIO.readFile backupPath
      atomicWriteText target content

removeNewDirectories :: FilePath -> [FilePath] -> IO ()
removeNewDirectories projectRoot = mapM_ removeIfEmpty . reverse . sortOn pathDepth
  where
    removeIfEmpty relativePath = do
      let fullPath = projectRoot </> relativePath
      exists <- Directory.doesDirectoryExist fullPath
      when exists $ do
        contents <- Directory.listDirectory fullPath
        when (null contents) (Directory.removeDirectory fullPath)

completeUpdateTransaction :: UpdateTransaction -> IO (Either TransactionError ())
completeUpdateTransaction transaction = do
  result <- try @SomeException (cleanupDirectory (transaction ^. #transactionDirectory))
  pure $ first (TransactionCompletionFailed . exceptionText) result

-- | Replace the recovery commit marker with the exact manifest the caller is
-- about to publish. Reconciliation initially records its files-only candidate;
-- orchestrators that add application/cache/receipt state must set the complete
-- candidate before the atomic manifest write.
setUpdateTransactionExpectedManifest :: UpdateTransaction -> Manifest -> IO (Either TransactionError ())
setUpdateTransactionExpectedManifest transaction expected = do
  metadataResult <- readJournal (transaction ^. #transactionDirectory)
  case metadataResult of
    Left err -> pure (Left err)
    Right metadata -> do
      let updated =
            JournalMetadata
              { journalVersion = metadata ^. #journalVersion,
                createdAt = metadata ^. #createdAt,
                entries = metadata ^. #entries,
                newDirectories = metadata ^. #newDirectories,
                expectedManifest = Just expected
              }
      result <- try @SomeException (writeJournal (transaction ^. #transactionDirectory) updated)
      pure $ first (\err -> TransactionApplyFailed (exceptionText err) Nothing) result

recoverIncompleteTransactions :: FilePath -> IO [Either TransactionError ()]
recoverIncompleteTransactions projectRoot = do
  let transactionsRoot = projectRoot </> ".seihou" </> "transactions"
  exists <- Directory.doesDirectoryExist transactionsRoot
  if not exists
    then pure []
    else do
      names <- Directory.listDirectory transactionsRoot
      directories <- filterMIO (Directory.doesDirectoryExist . (transactionsRoot </>)) names
      dated <- forM directories $ \name -> do
        modified <- Directory.getModificationTime (transactionsRoot </> name)
        pure (modified, name)
      forM (map snd (sortOn fst dated)) $ \name ->
        recoverOne projectRoot (transactionsRoot </> name)

recoverOne :: FilePath -> FilePath -> IO (Either TransactionError ())
recoverOne projectRoot transactionDirectory = do
  metadataResult <- readJournal transactionDirectory
  case metadataResult of
    Left err -> do
      quarantined <- quarantineTransaction projectRoot transactionDirectory
      pure $ case quarantined of
        Left quarantineError -> Left quarantineError
        Right () -> Left err
    Right metadata -> do
      committed <- manifestMatches projectRoot (metadata ^. #expectedManifest)
      if committed
        then do
          result <- try @SomeException (cleanupDirectory transactionDirectory)
          pure $ first (TransactionCompletionFailed . exceptionText) result
        else
          rollbackUpdateTransaction
            UpdateTransaction
              { projectRoot = projectRoot,
                transactionDirectory = transactionDirectory,
                targets = Set.fromList (map (^. #targetPath) (metadata ^. #entries))
              }

manifestMatches :: FilePath -> Maybe Manifest -> IO Bool
manifestMatches _ Nothing = pure False
manifestMatches projectRoot (Just expected) = do
  let manifestPath = projectRoot </> ".seihou" </> "manifest.json"
  exists <- Directory.doesFileExist manifestPath
  if not exists
    then pure False
    else do
      bytes <- LBS.readFile manifestPath
      pure (manifestFromJSON bytes == Right expected)

quarantineTransaction :: FilePath -> FilePath -> IO (Either TransactionError ())
quarantineTransaction projectRoot transactionDirectory = do
  result <- try @SomeException $ do
    let quarantineRoot = projectRoot </> ".seihou" </> "transactions-quarantine"
        target = quarantineRoot </> takeFileName transactionDirectory
    Directory.createDirectoryIfMissing True quarantineRoot
    targetExists <- Directory.doesPathExist target
    when targetExists (ioError (userError ("quarantine target already exists: " <> target)))
    Directory.renamePath transactionDirectory target
  pure $ first (TransactionRollbackFailed . exceptionText) result

readJournal :: FilePath -> IO (Either TransactionError JournalMetadata)
readJournal transactionDirectory = do
  let path = journalPath transactionDirectory
  result <- try @SomeException (LBS.readFile path)
  pure $ case result of
    Left err -> Left (TransactionJournalMalformed path (exceptionText err))
    Right bytes -> case Aeson.eitherDecode bytes of
      Left err -> Left (TransactionJournalMalformed path (T.pack err))
      Right metadata -> first (TransactionJournalMalformed path) (validateJournal metadata)

validateJournal :: JournalMetadata -> Either Text JournalMetadata
validateJournal metadata = do
  traverse_ (validateJournalEntry . (^. #targetPath)) (metadata ^. #entries)
  traverse_ validateBackupName [name | JournalEntry _ (Just name) <- metadata ^. #entries]
  traverse_ (first renderTransactionPathError . validateTransactionPath) (metadata ^. #newDirectories)
  pure metadata
  where
    validateJournalEntry path = first renderTransactionPathError (validateTransactionPath path)
    validateBackupName name
      | takeFileName name == name && name /= "." && name /= ".." = Right ()
      | otherwise = Left ("invalid backup filename: " <> T.pack name)

renderTransactionPathError :: TransactionError -> Text
renderTransactionPathError (InvalidTransactionPath path reason) = T.pack path <> ": " <> reason
renderTransactionPathError other = T.pack (show other)

writeJournal :: FilePath -> JournalMetadata -> IO ()
writeJournal transactionDirectory metadata = do
  let path = journalPath transactionDirectory
      tempPath = path <> ".tmp"
  LBS.writeFile tempPath (Aeson.encode metadata)
  Directory.renamePath tempPath path

journalPath :: FilePath -> FilePath
journalPath transactionDirectory = transactionDirectory </> "journal.json"

validateTransactionPath :: FilePath -> Either TransactionError FilePath
validateTransactionPath rawPath = case validateProjectRelativePath (T.pack rawPath) of
  Left err -> Left (InvalidTransactionPath rawPath err)
  Right safePath
    | safePath /= rawPath -> Left (InvalidTransactionPath rawPath "path must not contain surrounding whitespace")
    | safePath == "." -> Left (InvalidTransactionPath rawPath "path must name a project file")
    | targetsControlPath safePath ->
        Left (InvalidTransactionPath rawPath "path targets Seihou or Git control data")
    | otherwise -> Right safePath

targetsControlPath :: FilePath -> Bool
targetsControlPath path = case pathSegments path of
  firstSegment : _ -> firstSegment == ".seihou" || firstSegment == ".git"
  [] -> False

pathSegments :: FilePath -> [Text]
pathSegments = filter (not . T.null) . T.split (\character -> character == '/' || character == '\\') . T.pack

atomicWriteText :: FilePath -> Text -> IO ()
atomicWriteText path content = do
  let parent = takeDirectory path
  Directory.createDirectoryIfMissing True parent
  bracketOnError
    (openTempFile parent (takeFileName path <> ".seihou-update-"))
    (\(tempPath, handle) -> ignoreException (hClose handle) >> removeIfExists tempPath)
    ( \(tempPath, handle) -> do
        hSetEncoding handle utf8
        TIO.hPutStr handle content
        hClose handle
        Directory.renamePath tempPath path
    )

removeIfExists :: FilePath -> IO ()
removeIfExists path = do
  exists <- Directory.doesFileExist path
  when exists (Directory.removeFile path)

ignoreException :: IO () -> IO ()
ignoreException action = do
  _ <- try @SomeException action
  pure ()

cleanupDirectory :: FilePath -> IO ()
cleanupDirectory path = do
  exists <- Directory.doesDirectoryExist path
  when exists (Directory.removeDirectoryRecursive path)

failAndRollback :: UpdateTransaction -> TransactionError -> IO (Either TransactionError a)
failAndRollback transaction originalError = do
  rollback <- rollbackUpdateTransaction transaction
  pure $ case rollback of
    Right () -> Left originalError
    Left rollbackError ->
      Left
        ( TransactionApplyFailed
            (T.pack (show originalError))
            (Just (T.pack (show rollbackError)))
        )

filterMIO :: (a -> IO Bool) -> [a] -> IO [a]
filterMIO predicate values = fmap concat $ forM values $ \value -> do
  keep <- predicate value
  pure [value | keep]

pathDepth :: FilePath -> Int
pathDepth = length . splitDirectories

exceptionText :: SomeException -> Text
exceptionText = T.pack . displayException