packages feed

seihou-cli-0.6.0.0: src/Seihou/CLI/Update/Migrations.hs

module Seihou.CLI.Update.Migrations
  ( StagedMigrations (..),
    planAndStageMigrations,
    migrationTouchedPaths,
    migrationTouchesDirectories,
  )
where

import Control.Monad (guard)
import Data.Generics.Labels ()
import Data.List (find)
import Data.Map.Strict qualified as Map
import Data.Maybe (mapMaybe)
import Data.Set qualified as Set
import Data.Text.IO qualified as TIO
import Effectful (runPureEff)
import Seihou.CLI.Update.Types
import Seihou.Composition.Instance (ModuleInstance (..))
import Seihou.Core.Migration (Migration (..), MigrationOp (..), MigrationPlan (..), planMigrationChain)
import Seihou.Core.Types
import Seihou.Core.Version (parseVersion)
import Seihou.Effect.FilesystemPure (PureFS (..), runFilesystemPure)
import Seihou.Effect.ProcessPure (ProcessMock (..), runProcessPure)
import Seihou.Engine.Migrate
  ( ExecutedMigrationPlan (..),
    MigrationOpInstance (..),
    classifyMigration,
    executeMigration,
  )
import Seihou.Prelude
import System.Directory qualified as Directory
import System.Exit (ExitCode (..))
import System.FilePath (takeDirectory)

data StagedMigrations = StagedMigrations
  { plans :: ![PlannedUpdateMigration],
    manifest :: !Manifest,
    filesystem :: !PureFS,
    warnings :: ![UpdateWarning]
  }
  deriving stock (Eq, Generic, Show)

data Transition = Transition
  { moduleName :: !ModuleName,
    originUrl :: !(Maybe Text),
    fromVersion :: !Text,
    toVersion :: !Text,
    candidateModule :: !Module,
    sourceDirectory :: !FilePath
  }
  deriving stock (Generic)

-- | Deduplicate equal module transitions and simulate them against a complete
-- snapshot of tracked project text. Shell commands are mocked as successful
-- and retained as explicit warnings for the real apply/revalidation phase.
planAndStageMigrations ::
  FilePath ->
  Manifest ->
  CandidateCatalog ->
  [(Maybe AppliedComposition, [(ModuleInstance, Module, FilePath)])] ->
  IO (Either UpdateError StagedMigrations)
planAndStageMigrations projectRoot manifest catalog applications = do
  initialFilesystem <- snapshotTrackedFiles projectRoot manifest
  pure $ do
    transitions <- collectTransitions catalog applications
    planned <- traverse planTransition (deduplicateTransitions transitions)
    let processMocks = concatMap commandMocks planned
        ((stageResult, finalFilesystem)) =
          runPureEff $
            runFilesystemPure initialFilesystem $
              runProcessPure processMocks $
                stageAll manifest [] planned
    (finalManifest, stagedPlans) <- stageResult
    let warnings =
          [ MigrationCommandNotSimulated (plannedMigration ^. #moduleName) command
          | plannedMigration <- stagedPlans,
            RunCommandInst command _ <- plannedMigration ^. #stagedPlan . #ops
          ]
    Right
      StagedMigrations
        { plans = stagedPlans,
          manifest = finalManifest,
          filesystem = finalFilesystem,
          warnings
        }

stageAll manifest completed [] = pure (Right (manifest, reverse completed))
stageAll manifest completed ((transition, sourcePlan) : rest) = do
  classified <- classifyMigration manifest sourcePlan
  case classified of
    Left err -> pure (Left (UpdateMigrationStageFailed (transition ^. #moduleName) err))
    Right stagedPlan -> do
      executed <- executeMigration False stagedPlan manifest (manifest ^. #genAt)
      case executed of
        Left err -> pure (Left (UpdateMigrationStageFailed (transition ^. #moduleName) err))
        Right nextManifest ->
          let planned =
                PlannedUpdateMigration
                  { moduleName = transition ^. #moduleName,
                    sourceDirectory = transition ^. #sourceDirectory,
                    sourcePlan,
                    stagedPlan,
                    containsCommands = any isCommand (stagedPlan ^. #ops)
                  }
           in stageAll nextManifest (planned : completed) rest
  where
    isCommand RunCommandInst {} = True
    isCommand _ = False

collectTransitions ::
  CandidateCatalog ->
  [(Maybe AppliedComposition, [(ModuleInstance, Module, FilePath)])] ->
  Either UpdateError [Transition]
collectTransitions catalog applications = do
  let raw = concatMap applicationTransitions applications
      priorByModule =
        Map.fromListWith
          Set.union
          [ ((transition ^. #moduleName, transition ^. #originUrl), Set.singleton (transition ^. #fromVersion))
          | transition <- raw
          ]
  case [ (name, Set.toAscList versions)
       | ((name, _), versions) <- Map.toAscList priorByModule,
         Set.size versions > 1
       ] of
    (name, versions) : _ -> Left (UpdateConflictingPriorVersions name versions)
    [] -> Right raw
  where
    applicationTransitions (Nothing, _) = []
    applicationTransitions (Just previous, candidates) =
      mapMaybe (transitionFor previous) candidates

    transitionFor previous (instanceId, candidateModule, sourceDirectory) = do
      prior <- find (matches instanceId) (previous ^. #instances)
      fromVersion <- (prior ^. #moduleVersion)
      toVersion <- (candidateModule ^. #version)
      guard (fromVersion /= toVersion)
      let artifact = Map.lookup (CandidateModule, candidateModule ^. #name . #unModuleName) (catalog ^. #artifacts)
      pure
        Transition
          { moduleName = candidateModule ^. #name,
            originUrl = artifact >>= (^. #sourceUrl),
            fromVersion,
            toVersion,
            candidateModule,
            sourceDirectory
          }

    matches instanceId state =
      state ^. #name == instanceId ^. #module_
        && state ^. #parentVars == (instanceId ^. #parentVars)

deduplicateTransitions :: [Transition] -> [Transition]
deduplicateTransitions = go Set.empty
  where
    go _ [] = []
    go seen (transition : rest)
      | Set.member (transitionKey transition) seen = go seen rest
      | otherwise = transition : go (Set.insert (transitionKey transition) seen) rest

    transitionKey transition =
      ( transition ^. #moduleName,
        transition ^. #originUrl,
        transition ^. #fromVersion,
        transition ^. #toVersion
      )

planTransition :: Transition -> Either UpdateError (Transition, MigrationPlan)
planTransition transition = do
  fromVersion <-
    maybe
      (Left (CandidateVersionInvalid (transition ^. #moduleName . #unModuleName) (transition ^. #fromVersion)))
      Right
      (parseVersion (transition ^. #fromVersion))
  toVersion <-
    maybe
      (Left (CandidateVersionInvalid (transition ^. #moduleName . #unModuleName) (transition ^. #toVersion)))
      Right
      (parseVersion (transition ^. #toVersion))
  planned <-
    first
      (UpdateMigrationPlanFailed (transition ^. #moduleName))
      (planMigrationChain (transition ^. #moduleName . #unModuleName) (transition ^. #candidateModule . #migrations) fromVersion toVersion)
  case planned of
    Nothing -> error "planTransition received unequal versions but no migration plan"
    Just sourcePlan -> Right (transition, sourcePlan)

commandMocks :: (Transition, MigrationPlan) -> [ProcessMock]
commandMocks (_, sourcePlan) =
  [ ProcessMock
      { command = "/bin/sh",
        args = ["-c", command],
        result = (ExitSuccess, "", "")
      }
  | migration <- sourcePlan ^. #steps,
    RunCommand command _ <- migration ^. #ops
  ]

snapshotTrackedFiles :: FilePath -> Manifest -> IO PureFS
snapshotTrackedFiles projectRoot manifest = do
  files <- fmap Map.fromList . fmap concat $ traverse readTracked (Map.keys (manifest ^. #files))
  let directories =
        Set.fromList
          [ directory
          | path <- Map.keys files,
            directory <- parents path
          ]
  pure PureFS {files, dirs = directories}
  where
    readTracked path = do
      let fullPath = projectRoot </> path
      exists <- Directory.doesFileExist fullPath
      if exists
        then do
          content <- TIO.readFile fullPath
          pure [(path, content)]
        else pure []

    parents path = takeWhile (\directory -> directory /= "." && directory /= "") (iterate takeDirectory (takeDirectory path))

migrationTouchedPaths :: [PlannedUpdateMigration] -> Set FilePath
migrationTouchedPaths = Set.fromList . concatMap (concatMap touched . (^. #stagedPlan . #ops))
  where
    touched (MoveFileInst source destination _) = [source, destination]
    touched (MoveDirInst source destination) = [source, destination]
    touched (DeleteFileInst path _) = [path]
    touched (DeleteDirInst path) = [path]
    touched RunCommandInst {} = []

migrationTouchesDirectories :: PlannedUpdateMigration -> Bool
migrationTouchesDirectories migration = any touchesDirectory (migration ^. #stagedPlan . #ops)
  where
    touchesDirectory MoveDirInst {} = True
    touchesDirectory DeleteDirInst {} = True
    touchesDirectory _ = False