packages feed

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

module Seihou.CLI.Update
  ( handleUpdate,
  )
where

import Control.Monad (unless, when)
import Data.Aeson (encode, object, (.=))
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.Generics.Labels ()
import Data.Maybe (isJust)
import Data.Set qualified as Set
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Seihou.CLI.CommandExecution (CommandPolicy (..))
import Seihou.CLI.Commands (UpdateOpts (..))
import Seihou.CLI.CommitMessage (generateCommitMessage)
import Seihou.CLI.Git (gitAdd, gitCheckIgnore, gitCommit, gitDiffCached, isGitRepo)
import Seihou.CLI.Style (useColor)
import Seihou.CLI.Update.Interaction
  ( InteractionError (..),
    InteractionMode (..),
    forceResolveUpdatePlan,
    resolveInteractively,
  )
import Seihou.CLI.Update.Render
  ( encodeUpdateOutput,
    errorOutput,
    planOutput,
    renderUpdateHuman,
    resultOutput,
  )
import Seihou.Core.Types (ModuleName (..))
import Seihou.Effect.ProcessInterp (runProcessIO)
import Seihou.Prelude
import System.Exit (ExitCode (..), exitFailure)
import System.FilePath (isAbsolute)
import System.IO (hFlush, hIsTerminalDevice, isEOF, stderr, stdin)
import "seihou-cli" Seihou.CLI.Update qualified as Service

handleUpdate :: UpdateOpts -> IO ()
handleUpdate opts = do
  validateOptions opts
  terminal <- hIsTerminalDevice stdin
  let request = requestFromOptions terminal opts
  Service.withProjectUpdate request (handlePlanned terminal opts)

validateOptions :: UpdateOpts -> IO ()
validateOptions opts
  | opts ^. #dryRun && (opts ^. #commit || isJust (opts ^. #commitMessage)) =
      failCli opts "invalid_options" "--commit and --commit-message cannot be used with --dry-run"
  | opts ^. #runAllCommands && (opts ^. #noCommands) =
      failCli opts "invalid_options" "--run-all-commands and --no-commands are mutually exclusive"
  | otherwise = pure ()

requestFromOptions :: Bool -> UpdateOpts -> Service.UpdateRequest
requestFromOptions terminal opts =
  Service.UpdateRequest
    { selection =
        if null (opts ^. #targets)
          then Service.AllRecordedApplications
          else Service.NamedUpdateTargets (opts ^. #targets),
      varOverrides = opts ^. #vars,
      reconfigure = opts ^. #reconfigure,
      promptPolicy =
        if terminal && not (opts ^. #json)
          then Service.AllowPrompts
          else Service.ForbidPrompts,
      commandPolicy =
        if opts ^. #runAllCommands
          then RunAllCommands
          else if opts ^. #noCommands then DisableCommands else RunChangedCommands,
      dryRun = opts ^. #dryRun,
      allowDowngrade = opts ^. #allowDowngrade
    }

handlePlanned :: Bool -> UpdateOpts -> Either Service.UpdateError Service.UpdatePlan -> IO ()
handlePlanned _ opts (Left err) = do
  if opts ^. #json
    then LBS.putStrLn (encodeUpdateOutput (errorOutput err))
    else TIO.hPutStr stderr (renderUpdateHuman False (errorOutput err))
  exitFailure
handlePlanned terminal opts (Right originalPlan) = do
  forced <-
    if opts ^. #force
      then either (failInteraction opts) pure (forceResolveUpdatePlan originalPlan)
      else pure originalPlan
  resolved <-
    resolveInteractively
      (if terminal && not (opts ^. #json) then Interactive else NonInteractive)
      forced
      >>= either (failInteraction opts) pure
  color <- useColor
  if Service.isUpdateNoOp resolved
    then
      if opts ^. #json
        then LBS.putStrLn (encodeUpdateOutput (planOutput resolved))
        else TIO.putStrLn "Already up to date."
    else
      if opts ^. #dryRun
        then emitPlan color opts resolved
        else do
          unless (opts ^. #json) $ TIO.putStr (renderUpdateHuman color (planOutput resolved))
          accepted <- if opts ^. #json then pure True else confirmApply terminal
          if not accepted
            then TIO.hPutStrLn stderr "Update cancelled; no managed state was changed."
            else do
              applied <- Service.applyProjectUpdate resolved
              case applied of
                Left err -> handlePlanned terminal opts (Left err)
                Right result -> do
                  if opts ^. #json
                    then LBS.putStrLn (encodeUpdateOutput (resultOutput result))
                    else TIO.putStr (renderUpdateHuman color (resultOutput result))
                  when (opts ^. #commit || isJust (opts ^. #commitMessage)) $ do
                    committed <- commitUpdate opts result
                    case committed of
                      Left err -> do
                        TIO.hPutStrLn stderr ("Update succeeded, but git commit failed: " <> err)
                        exitFailure
                      Right () -> pure ()

emitPlan :: Bool -> UpdateOpts -> Service.UpdatePlan -> IO ()
emitPlan color opts plan =
  if opts ^. #json
    then LBS.putStrLn (encodeUpdateOutput (planOutput plan))
    else TIO.putStr (renderUpdateHuman color (planOutput plan))

confirmApply :: Bool -> IO Bool
confirmApply False = pure False
confirmApply True = loop
  where
    loop = do
      TIO.hPutStr stderr "Apply? [Y/n] "
      hFlush stderr
      eof <- isEOF
      if eof
        then pure False
        else do
          answer <- T.toLower . T.strip <$> TIO.getLine
          case answer of
            "" -> pure True
            "y" -> pure True
            "yes" -> pure True
            "n" -> pure False
            "no" -> pure False
            _ -> do
              TIO.hPutStrLn stderr "Please answer yes or no."
              loop

commitUpdate :: UpdateOpts -> Service.UpdateResult -> IO (Either Text ())
commitUpdate opts result = do
  let candidates = filter (not . isAbsolute) (Set.toAscList (result ^. #touchedPaths))
  inRepo <- runEff $ runProcessIO isGitRepo
  if not inRepo
    then pure (Right ())
    else do
      ignored <- runEff $ runProcessIO $ gitCheckIgnore candidates
      let staged = filter (`notElem` ignored) candidates
      if null staged
        then pure (Right ())
        else do
          (addExit, _, addErr) <- runEff $ runProcessIO $ gitAdd staged
          case addExit of
            ExitFailure _ -> pure (Left (T.strip addErr))
            ExitSuccess -> do
              message <- case opts ^. #commitMessage of
                Just custom -> pure custom
                Nothing -> do
                  diff <- runEff $ runProcessIO gitDiffCached
                  let modules = map (ModuleName . (^. #name)) (result ^. #versions)
                  generateCommitMessage modules diff
              (commitExit, _, commitErr) <- runEff $ runProcessIO $ gitCommit message
              pure $ case commitExit of
                ExitSuccess -> Right ()
                ExitFailure _ -> Left (T.strip commitErr)

failInteraction :: UpdateOpts -> InteractionError -> IO a
failInteraction opts err = case err of
  InteractionRequired paths ->
    failCli
      opts
      "unresolved_conflicts"
      ( "Unresolved paths require an interactive terminal or an applicable --force choice: "
          <> T.intercalate ", " (map T.pack (Set.toAscList paths))
      )
  InteractionAborted path ->
    failCli opts "update_aborted" ("Resolution aborted for " <> T.pack path)
  InteractionResolutionFailed inner ->
    failCli opts "resolution_failed" (T.pack (show inner))
  InteractionInputFailed message ->
    failCli opts "input_failed" message

failCli :: UpdateOpts -> Text -> Text -> IO a
failCli opts code message = do
  if opts ^. #json
    then
      LBS.putStrLn $
        encode $
          object
            [ "schemaVersion" .= (1 :: Int),
              "outcome" .= ("error" :: Text),
              "error" .= object ["code" .= code, "message" .= message]
            ]
    else TIO.hPutStrLn stderr ("Update failed [" <> code <> "]: " <> message)
  exitFailure