packages feed

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

module Seihou.CLI.Update.Render
  ( UpdateOutput (..),
    UpdatePlanView,
    UpdateResultView,
    UpdateErrorView,
    planOutput,
    resultOutput,
    errorOutput,
    renderUpdateHuman,
    encodeUpdateOutput,
  )
where

import Data.Aeson (Value, encode, object, (.=))
import Data.ByteString.Lazy (ByteString)
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.Set qualified as Set
import Data.Text qualified as T
import Seihou.CLI.CommandExecution
  ( CommandDisposition (..),
    CommandPlan (..),
    CommandPlanSummary (..),
    PlannedCommand (..),
    summarizeCommandPlan,
  )
import Seihou.CLI.Update.Types
import Seihou.Core.Migration (MigrationPlan (..))
import Seihou.Core.Types
  ( ApplicationId (..),
    AppliedComposition (..),
    AppliedTarget (..),
    BaselineRef (..),
    CommandFingerprint (..),
    ModuleName (..),
    Operation (..),
    RecipeName (..),
    SHA256 (..),
    VarName (..),
  )
import Seihou.Engine.Reconcile
  ( DesiredFile (..),
    FileConflictChoice (..),
    FileReconciliation (..),
    OrphanChoice (..),
    ReconciliationPlan (..),
    ReconciliationSummary (..),
    ResolvedFileConflict (..),
    reconciliationSummary,
  )
import Seihou.Prelude

newtype UpdatePlanView = UpdatePlanView UpdatePlan

newtype UpdateResultView = UpdateResultView UpdateResult

newtype UpdateErrorView = UpdateErrorView UpdateError

data UpdateOutput
  = UpdatePlanOutput UpdatePlanView
  | UpdateAppliedOutput UpdateResultView
  | UpdateFailedOutput UpdateErrorView

planOutput :: UpdatePlan -> UpdateOutput
planOutput = UpdatePlanOutput . UpdatePlanView

resultOutput :: UpdateResult -> UpdateOutput
resultOutput = UpdateAppliedOutput . UpdateResultView

errorOutput :: UpdateError -> UpdateOutput
errorOutput = UpdateFailedOutput . UpdateErrorView

renderUpdateHuman :: Bool -> UpdateOutput -> Text
renderUpdateHuman _ (UpdatePlanOutput (UpdatePlanView plan)) =
  T.unlines $
    versionLines plan
      <> [ renderInputs (plan ^. #inputChanges),
           "Migrations:  " <> count (length (plan ^. #migrations)) <> migrationCaveat plan,
           renderFiles (reconciliationSummary (plan ^. #reconciliation)),
           renderCommands (summarizeCommandPlan (plan ^. #commandPlan))
         ]
      <> conflictLines (plan ^. #reconciliation)
      <> warningLines (plan ^. #warnings)
renderUpdateHuman _ (UpdateAppliedOutput (UpdateResultView result)) =
  T.unlines $
    [ "Updated " <> count (length (result ^. #updatedApplications)) <> " application(s).",
      renderFiles (result ^. #fileSummary),
      "Commands:    "
        <> count (result ^. #commandSummary . #executed)
        <> " executed; "
        <> count (result ^. #commandSummary . #skippedUnchanged)
        <> " unchanged skipped; "
        <> count (result ^. #commandSummary . #skippedDisabled)
        <> " disabled"
    ]
      <> warningLines (result ^. #warnings)
renderUpdateHuman _ (UpdateFailedOutput (UpdateErrorView err)) =
  "Update failed [" <> errorCode err <> "]: " <> errorMessage err <> "\n"

encodeUpdateOutput :: UpdateOutput -> ByteString
encodeUpdateOutput = encode . outputValue

outputValue :: UpdateOutput -> Value
outputValue (UpdatePlanOutput (UpdatePlanView plan)) =
  object
    [ "schemaVersion" .= (1 :: Int),
      "outcome" .= ("plan" :: Text),
      "alreadyUpToDate" .= planLooksUnchanged plan,
      "applications" .= map applicationIdText (plan ^. #applications),
      "versions" .= map versionValue (plan ^. #versionChanges),
      "inputs" .= inputValue (plan ^. #inputChanges),
      "migrations" .= map migrationValue (plan ^. #migrations),
      "files" .= map fileValue (Map.toAscList (plan ^. #reconciliation . #files)),
      "commands" .= map commandValue (plan ^. #commandPlan . #commands),
      "warnings" .= map warningText (plan ^. #warnings)
    ]
outputValue (UpdateAppliedOutput (UpdateResultView result)) =
  object
    [ "schemaVersion" .= (1 :: Int),
      "outcome" .= ("applied" :: Text),
      "applications" .= map (^. #unApplicationId) (result ^. #updatedApplications),
      "versions" .= map versionValue (result ^. #versions),
      "files" .= summaryValue (result ^. #fileSummary),
      "commands"
        .= object
          [ "executed" .= (result ^. #commandSummary . #executed),
            "skippedUnchanged" .= (result ^. #commandSummary . #skippedUnchanged),
            "skippedDisabled" .= (result ^. #commandSummary . #skippedDisabled)
          ],
      "touchedPaths" .= Set.toAscList (result ^. #touchedPaths),
      "warnings" .= map warningText (result ^. #warnings)
    ]
outputValue (UpdateFailedOutput (UpdateErrorView err)) =
  object
    [ "schemaVersion" .= (1 :: Int),
      "outcome" .= ("error" :: Text),
      "error" .= object ["code" .= errorCode err, "message" .= errorMessage err]
    ]

versionLines :: UpdatePlan -> [Text]
versionLines plan
  | null (plan ^. #versionChanges) = ["Versions:    unchanged"]
  | otherwise = map renderVersionChange (plan ^. #versionChanges)

renderVersionChange :: VersionChange -> Text
renderVersionChange change =
  change ^. #name
    <> "  "
    <> fromMaybe "unversioned" (change ^. #fromVersion)
    <> " -> "
    <> fromMaybe "unversioned" (change ^. #toVersion)
    <> if change ^. #sameVersionContentChanged then " (content changed at same version)" else ""

renderInputs :: InputChangeSummary -> Text
renderInputs summary =
  "Inputs:      "
    <> count (summary ^. #reused)
    <> " reused; "
    <> count (summary ^. #overridden)
    <> " overridden; "
    <> count (summary ^. #newlyResolved)
    <> " newly resolved; "
    <> count (summary ^. #removed)
    <> " removed"

renderFiles :: ReconciliationSummary -> Text
renderFiles summary =
  "Files:       "
    <> count (summary ^. #creates)
    <> " created; "
    <> count (summary ^. #updates)
    <> " updated; "
    <> count (summary ^. #merged)
    <> " merged; "
    <> count (summary ^. #unchanged)
    <> " unchanged; "
    <> count (summary ^. #conflicts)
    <> " conflicts; "
    <> count (summary ^. #safeDeletes)
    <> " deleted; "
    <> count (summary ^. #editedOrphans)
    <> " edited orphans"

renderCommands summary =
  "Commands:    "
    <> count (summary ^. #willRun)
    <> " will run; "
    <> count (summary ^. #skippedUnchanged)
    <> " unchanged skipped; "
    <> count (summary ^. #skippedDisabled)
    <> " disabled"

migrationCaveat plan
  | any (^. #containsCommands) (plan ^. #migrations) = " (includes non-simulatable commands)"
  | otherwise = ""

conflictLines :: ReconciliationPlan -> [Text]
conflictLines reconciliation = concatMap renderOne (Map.toAscList (reconciliation ^. #files))
  where
    renderOne (path, FileConflict _ _ _ reason _ _ resolution) =
      [ "Conflict:    "
          <> T.pack path
          <> " ("
          <> T.pack (show reason)
          <> maybe "; unresolved" (("; " <>) . resolutionText . (^. #choice)) resolution
          <> ")"
      ]
    renderOne (path, FileOrphanEdited _ _ _ _ choice) =
      [ "Orphan:      "
          <> T.pack path
          <> maybe " (unresolved)" ((" (" <>) . (<> ")") . orphanChoiceText) choice
      ]
    renderOne _ = []

warningLines :: [UpdateWarning] -> [Text]
warningLines = map (("Warning:     " <>) . warningText)

versionValue :: VersionChange -> Value
versionValue change =
  object
    [ "name" .= (change ^. #name),
      "from" .= (change ^. #fromVersion),
      "to" .= (change ^. #toVersion),
      "sameVersionContentChanged" .= (change ^. #sameVersionContentChanged)
    ]

inputValue :: InputChangeSummary -> Value
inputValue summary =
  object
    [ "reused" .= (summary ^. #reused),
      "overridden" .= (summary ^. #overridden),
      "newlyResolved" .= (summary ^. #newlyResolved),
      "removed" .= (summary ^. #removed),
      "ambiguousLegacy" .= map (^. #unVarName) (summary ^. #ambiguousLegacy)
    ]

migrationValue :: PlannedUpdateMigration -> Value
migrationValue migration =
  object
    [ "module" .= (migration ^. #moduleName . #unModuleName),
      "from" .= showText (migration ^. #sourcePlan . #from),
      "to" .= showText (migration ^. #sourcePlan . #to),
      "steps" .= length (migration ^. #sourcePlan . #steps),
      "containsCommands" .= (migration ^. #containsCommands)
    ]

fileValue :: (FilePath, FileReconciliation) -> Value
fileValue (path, reconciliation) =
  object
    [ "path" .= path,
      "classification" .= classification reconciliation,
      "resolution" .= resolutionFor reconciliation
    ]

classification :: FileReconciliation -> Text
classification FileCreate {} = "create"
classification FileUpdate {} = "update"
classification FileAutoMerge {} = "autoMerge"
classification FileUnchanged {} = "unchanged"
classification FileConflict {} = "conflict"
classification FileDeleteSafe {} = "safeDelete"
classification FileOrphanEdited {} = "editedOrphan"
classification FileReleaseSharedOwnership {} = "releaseSharedOwnership"
classification FileAlreadyAbsent {} = "alreadyAbsent"

resolutionFor :: FileReconciliation -> Maybe Text
resolutionFor (FileConflict _ _ _ _ _ _ resolution) = resolutionText . (^. #choice) <$> resolution
resolutionFor (FileOrphanEdited _ _ _ _ choice) = orphanChoiceText <$> choice
resolutionFor _ = Nothing

commandValue :: PlannedCommand -> Value
commandValue planned =
  object
    [ "fingerprint" .= fingerprintText (planned ^. #fingerprint),
      "status" .= dispositionText (planned ^. #disposition),
      "module" .= commandModule (planned ^. #operation),
      "command" .= commandText (planned ^. #operation)
    ]

commandModule :: Operation -> Maybe Text
commandModule RunCommandOp {moduleName} = Just (moduleName ^. #unModuleName)
commandModule _ = Nothing

commandText :: Operation -> Maybe Text
commandText RunCommandOp {command} = Just command
commandText _ = Nothing

dispositionText :: CommandDisposition -> Text
dispositionText CommandWillRun = "willRun"
dispositionText CommandSkippedUnchanged = "skippedUnchanged"
dispositionText CommandSkippedDisabled = "skippedDisabled"

resolutionText :: FileConflictChoice -> Text
resolutionText AcceptGenerated = "useGenerated"
resolutionText KeepCurrent = "keepCurrent"
resolutionText WriteConflictMarkers = "writeConflictMarkers"
resolutionText AbortUpdate = "abort"

orphanChoiceText :: OrphanChoice -> Text
orphanChoiceText DeleteEditedOrphan = "delete"
orphanChoiceText RetainTrackedOrphan = "retainTracked"
orphanChoiceText DetachAndKeepOrphan = "detachAndKeep"
orphanChoiceText AbortOrphanUpdate = "abort"

summaryValue :: ReconciliationSummary -> Value
summaryValue summary =
  object
    [ "created" .= (summary ^. #creates),
      "updated" .= (summary ^. #updates),
      "merged" .= (summary ^. #merged),
      "unchanged" .= (summary ^. #unchanged),
      "conflicts" .= (summary ^. #conflicts),
      "safeDeletes" .= (summary ^. #safeDeletes),
      "editedOrphans" .= (summary ^. #editedOrphans),
      "sharedOwnership" .= (summary ^. #sharedOwnership)
    ]

applicationIdText :: AppliedComposition -> Text
applicationIdText application = (application ^. #applicationId . #unApplicationId)

fingerprintText :: CommandFingerprint -> Text
fingerprintText (CommandFingerprint (SHA256 value)) = value

warningText :: UpdateWarning -> Text
warningText = T.pack . show

errorCode :: UpdateError -> Text
errorCode UpdateManifestMissing {} = "manifest_missing"
errorCode UpdateManifestUnreadable {} = "manifest_unreadable"
errorCode NoRecordedApplications = "no_recorded_applications"
errorCode LegacyUpdateRequiresOneTarget = "legacy_update_requires_one_target"
errorCode UpdateTargetNotFound {} = "target_not_found"
errorCode SharedPathRequiresApplications {} = "shared_path_requires_applications"
errorCode CandidateCloneFailed {} = "candidate_clone_failed"
errorCode CandidateRepositoryInvalid {} = "candidate_repository_invalid"
errorCode CandidateArtifactMissing {} = "candidate_artifact_missing"
errorCode CandidateArtifactUnresolved {} = "candidate_artifact_unresolved"
errorCode CandidateArtifactAmbiguous {} = "candidate_artifact_ambiguous"
errorCode CandidateLoadFailed {} = "candidate_load_failed"
errorCode CandidateDowngrade {} = "candidate_downgrade"
errorCode CandidateVersionInvalid {} = "candidate_version_invalid"
errorCode UpdateConflictingPriorVersions {} = "conflicting_prior_versions"
errorCode UpdateVariableErrors {} = "variable_errors"
errorCode UpdateConfigurationFailed {} = "configuration_failed"
errorCode UpdateMigrationPlanFailed {} = "migration_plan_failed"
errorCode UpdateMigrationStageFailed {} = "migration_stage_failed"
errorCode UpdateCompositionFailed {} = "composition_failed"
errorCode UpdateReconciliationFailed {} = "reconciliation_failed"
errorCode UpdateHasUnresolvedPaths {} = "unresolved_paths"
errorCode UpdateRecoveryFailed {} = "recovery_failed"
errorCode UpdatePlanStale {} = "plan_stale"
errorCode UpdateTransactionFailed {} = "transaction_failed"
errorCode UpdateMigrationFailed {} = "migration_failed"
errorCode UpdateChangedAfterMigrationCommand {} = "changed_after_migration_command"
errorCode UpdateCommandFailed {} = "command_failed"
errorCode UpdateCachePublicationFailed {} = "cache_publication_failed"
errorCode UpdateManifestWriteFailed {} = "manifest_write_failed"

errorMessage :: UpdateError -> Text
errorMessage (UpdateManifestMissing path) =
  "No Seihou manifest was found at " <> T.pack path <> ". Run seihou run first."
errorMessage (UpdateTargetNotFound target available) =
  "No recorded application matches '"
    <> target
    <> "'. Available targets: "
    <> T.intercalate ", " available
errorMessage (SharedPathRequiresApplications path selected required) =
  "Path "
    <> T.pack path
    <> " is also owned by application(s) "
    <> T.intercalate ", " (map (^. #unApplicationId) (Set.toAscList required))
    <> ". Select every owner or run seihou update with no targets. Selected: "
    <> T.intercalate ", " (map (^. #unApplicationId) (Set.toAscList selected))
errorMessage (UpdateHasUnresolvedPaths paths) =
  "Resolve these paths before apply: " <> T.intercalate ", " (map T.pack (Set.toAscList paths))
errorMessage (UpdatePlanStale paths) =
  "The project changed after planning: " <> T.intercalate ", " (map T.pack (Set.toAscList paths))
errorMessage err = T.pack (show err)

planLooksUnchanged :: UpdatePlan -> Bool
planLooksUnchanged plan =
  null (plan ^. #versionChanges)
    && null (plan ^. #migrations)
    && plan ^. #inputChanges . #overridden == 0
    && plan ^. #inputChanges . #newlyResolved == 0
    && plan ^. #inputChanges . #removed == 0
    && (summarizeCommandPlan (plan ^. #commandPlan)) ^. #willRun == 0
    && all isUnchanged (Map.elems (plan ^. #reconciliation . #files))
  where
    isUnchanged FileUnchanged {} = True
    isUnchanged _ = False

count :: Int -> Text
count = T.pack . show

showText :: (Show a) => a -> Text
showText = T.pack . show