packages feed

seihou-core-0.4.0.0: src/Seihou/Core/SchemaUpgrade.hs

module Seihou.Core.SchemaUpgrade
  ( UpgradeIssue (..),
    UpgradeResult (..),
    detectIssues,
    upgradeModuleText,
    issueMessage,
  )
where

import Data.Char (isSpace)
import Data.Text qualified as T
import Seihou.Prelude

-- | Describes a single schema issue found in a module.dhall file.
data UpgradeIssue
  = -- | The module record is missing the @version@ field.
    MissingVersion
  | -- | A step record (at the given 0-based index) is missing the @patch@ field.
    MissingStepPatch Int
  | -- | The module record is missing the @commands@ field.
    MissingCommands
  | -- | A dependency is a bare text string rather than a record.
    BareStringDep Text
  | -- | The empty dependency list uses @List Text@ rather than the record type.
    BareStringDepTypeAnnotation
  | -- | The module is missing the @let S = \<url\>@ schema import.
    MissingSchemaImport
  | -- | The module record is missing the @migrations@ field.
    MissingMigrations
  deriving stock (Eq, Show)

-- | Result of attempting to upgrade a module.dhall text.
data UpgradeResult
  = -- | The module already conforms to the current schema.
    AlreadyCurrent
  | -- | The module was upgraded; contains the new text and issues that were fixed.
    Upgraded Text [UpgradeIssue]
  deriving stock (Eq, Show)

-- | Human-readable description of an upgrade issue.
issueMessage :: UpgradeIssue -> Text
issueMessage MissingVersion = "missing field: version"
issueMessage (MissingStepPatch n) = "missing field: patch (in step " <> T.pack (show (n + 1)) <> ")"
issueMessage MissingCommands = "missing field: commands"
issueMessage (BareStringDep name) = "bare string dependency: " <> name
issueMessage BareStringDepTypeAnnotation = "dependency list uses List Text type annotation"
issueMessage MissingSchemaImport = "missing schema import (let S = ...)"
issueMessage MissingMigrations = "missing field: migrations"

-- | Detect all schema issues in a module.dhall text without modifying it.
-- The schema URL is used to check whether the schema import is present.
detectIssues :: Text -> Text -> [UpgradeIssue]
detectIssues schemaUrl content =
  let ls = T.lines content
   in detectMissingVersion ls
        <> detectMissingStepPatch ls
        <> detectMissingCommands ls
        <> detectBareStringDeps ls
        <> detectDepTypeAnnotation ls
        <> detectMissingSchemaImport schemaUrl ls
        <> detectMissingMigrations ls

-- | Upgrade a module.dhall text to the current canonical schema.
-- Returns 'AlreadyCurrent' if no changes are needed.
-- The schema URL and hash are used to inject the schema import if missing.
upgradeModuleText :: Text -> Text -> Text -> UpgradeResult
upgradeModuleText schemaUrl schemaHash content =
  let issues = detectIssues schemaUrl content
   in if null issues
        then AlreadyCurrent
        else
          let upgraded =
                content
                  & applyIf (MissingVersion `elem` issues) insertVersion
                  & applyIf (any isMissingPatch issues) (insertPatchFields issues)
                  & applyIf (MissingCommands `elem` issues) insertCommands
                  & applyIf (any isBareStringDepIssue issues) convertBareStringDeps
                  & applyIf (BareStringDepTypeAnnotation `elem` issues) convertDepTypeAnnotation
                  & applyIf (MissingSchemaImport `elem` issues) (injectSchemaImport schemaUrl schemaHash)
                  & applyIf (MissingMigrations `elem` issues) insertMigrations
           in Upgraded upgraded issues
  where
    applyIf True f x = f x
    applyIf False _ x = x

    isMissingPatch (MissingStepPatch _) = True
    isMissingPatch _ = False

    isBareStringDepIssue (BareStringDep _) = True
    isBareStringDepIssue _ = False

-- ---------------------------------------------------------------------------
-- Detection
-- ---------------------------------------------------------------------------

detectMissingVersion :: [Text] -> [UpgradeIssue]
detectMissingVersion ls
  | any (matchesField "version") ls = []
  | otherwise = [MissingVersion]

detectMissingCommands :: [Text] -> [UpgradeIssue]
detectMissingCommands ls
  | any (matchesField "commands") ls = []
  | otherwise = [MissingCommands]

detectMissingStepPatch :: [Text] -> [UpgradeIssue]
detectMissingStepPatch ls =
  let blocks = extractStepBlocks ls
   in [ MissingStepPatch i
      | (i, block) <- zip [0 ..] blocks,
        not (any (matchesField "patch") block)
      ]

detectBareStringDeps :: [Text] -> [UpgradeIssue]
detectBareStringDeps ls =
  let depsValue = extractDepsValue ls
   in map BareStringDep (extractBareStrings depsValue)

detectDepTypeAnnotation :: [Text] -> [UpgradeIssue]
detectDepTypeAnnotation ls
  | any (\l -> "List Text" `T.isInfixOf` l && matchesField "dependencies" l) ls =
      [BareStringDepTypeAnnotation]
  | otherwise = []

detectMissingSchemaImport :: Text -> [Text] -> [UpgradeIssue]
detectMissingSchemaImport schemaUrl ls
  | any (\l -> "let S =" `T.isInfixOf` l || "let Schema =" `T.isInfixOf` l) ls,
    any (T.isInfixOf "seihou-schema") ls =
      []
  | -- Also accept if the schema URL itself appears (e.g. different binding name)
    any (T.isInfixOf schemaUrl) ls =
      []
  | otherwise = [MissingSchemaImport]

detectMissingMigrations :: [Text] -> [UpgradeIssue]
detectMissingMigrations ls
  | any (matchesField "migrations") ls = []
  | otherwise = [MissingMigrations]

-- ---------------------------------------------------------------------------
-- Rewriting
-- ---------------------------------------------------------------------------

-- | Insert @, version = None Text@ after the @{ name = ...@ line.
insertVersion :: Text -> Text
insertVersion = mapLines $ \line ->
  if matchesFieldStart "name" line
    then [line, ", version = None Text"]
    else [line]

-- | Insert @, patch = None Text@ into step records that are missing it.
insertPatchFields :: [UpgradeIssue] -> Text -> Text
insertPatchFields issues content =
  let missingIndices = [i | MissingStepPatch i <- issues]
      ls = T.lines content
   in T.unlines (go ls 0 missingIndices False)
  where
    go [] _ _ _ = []
    go (l : rest) idx missing inStep
      | not inStep && containsStepStart l =
          l : go rest idx missing True
      | inStep && isBlockEnd l =
          if idx `elem` missing
            then "    , patch = None Text" : l : go rest (idx + 1) missing False
            else l : go rest (idx + 1) missing False
      | otherwise = l : go rest idx missing inStep

    isBlockEnd line =
      let s = T.stripEnd line
          stripped = T.stripStart line
       in "}" `T.isSuffixOf` s
            && not (containsStepStart stripped)

-- | Insert @, commands = ...@ before @, dependencies =@.
insertCommands :: Text -> Text
insertCommands = mapLines $ \line ->
  if matchesField "dependencies" line
    then [", commands = [] : List { run : Text, workDir : Optional Text, when : Optional Text }", line]
    else [line]

-- | Convert bare string dependencies to record form.
convertBareStringDeps :: Text -> Text
convertBareStringDeps content =
  let ls = T.lines content
      (before, rest) = break (matchesField "dependencies") ls
   in case rest of
        [] -> content
        _ ->
          let (depsLines, after) = spanDepsRegion rest
              converted = convertDepsLines depsLines
           in T.unlines (before <> converted <> after)

-- | Inject a schema import line and wrap the existing record in @S.Module::@.
-- Prepends @let S = \<url\> \<hash\>@ and replaces the opening @{@ with @in  S.Module::{@.
injectSchemaImport :: Text -> Text -> Text -> Text
injectSchemaImport url hash content =
  let header =
        T.unlines
          [ "let S =",
            "      " <> url,
            "        " <> hash,
            ""
          ]
      ls = T.lines content
      -- Find the first line that starts with '{' (the module record)
      (before, recordLines) = break (T.isPrefixOf "{" . T.stripStart) ls
   in case recordLines of
        [] -> header <> content
        (firstLine : rest) ->
          let -- Replace the leading '{' with 'in  S.Module::{'
              stripped = T.stripStart firstLine
              replaced = "in  S.Module::" <> stripped
           in header <> T.unlines (before <> (replaced : rest))

-- | Convert @[] : List Text@ to the record type annotation.
convertDepTypeAnnotation :: Text -> Text
convertDepTypeAnnotation =
  T.replace
    "[] : List Text"
    "[] : List { module : Text, vars : List { name : Text, value : Text } }"

-- | Insert @, migrations = [] : List S.Migration.Type@ before the closing
-- brace of the module record. Inserts after the @removal = ...@ line if
-- present (it always is on a current schema), otherwise before the closing
-- brace as a fallback.
insertMigrations :: Text -> Text
insertMigrations content =
  let ls = T.lines content
      -- For modules using the schema package, S.Migration.Type is the right
      -- type. For legacy modules without the schema import we still emit the
      -- same line — it will be a forward reference fixed up by adding the
      -- schema import (MissingSchemaImport handler runs separately).
      migrationsLine = "    , migrations = [] : List S.Migration.Type"
      legacyMigrationsLine = ", migrations = [] : List { from : Text, to : Text, ops : List < MoveFile : { src : Text, dest : Text } | MoveDir : { src : Text, dest : Text } | DeleteFile : { path : Text } | DeleteDir : { path : Text } | RunCommand : { run : Text, workDir : Optional Text } > }"
      hasSchemaImport =
        any (\l -> "let S =" `T.isInfixOf` l || "let Schema =" `T.isInfixOf` l) ls
      lineToInsert = if hasSchemaImport then migrationsLine else legacyMigrationsLine
   in T.unlines (insertBeforeClose lineToInsert ls)
  where
    -- Insert the new line just before the line that closes the module
    -- record. The closing line is the last line that is exactly @}@ or
    -- @  }@ (the schema-completion-syntax close). We look for the last
    -- such line so we insert into the outermost record, not nested ones.
    insertBeforeClose newLine xs =
      case findLastClosing xs of
        Just idx ->
          take idx xs ++ [newLine] ++ drop idx xs
        Nothing -> xs ++ [newLine]

    findLastClosing :: [Text] -> Maybe Int
    findLastClosing xs =
      let indexed = zip [0 ..] xs
          closes = [i | (i, l) <- indexed, isClosingBrace l]
       in case closes of
            [] -> Nothing
            _ -> Just (last closes)

    isClosingBrace :: Text -> Bool
    isClosingBrace l =
      let s = T.strip l
       in s == "}" || s == "} : T"

-- ---------------------------------------------------------------------------
-- Text utilities
-- ---------------------------------------------------------------------------

-- | Check if a line contains a field assignment like @, fieldName =@ or
-- @{ fieldName =@.
matchesField :: Text -> Text -> Bool
matchesField fieldName line =
  let s = T.stripStart line
   in (", " <> fieldName <> " =") `T.isPrefixOf` s
        || (", " <> fieldName <> "=") `T.isPrefixOf` s
        || ("{ " <> fieldName <> " =") `T.isPrefixOf` s

-- | Like 'matchesField' but only for the first field in a record (using @{@).
matchesFieldStart :: Text -> Text -> Bool
matchesFieldStart fieldName line =
  let s = T.stripStart line
   in ("{ " <> fieldName <> " =") `T.isPrefixOf` s

-- | Apply a per-line transformation and reassemble.
mapLines :: (Text -> [Text]) -> Text -> Text
mapLines f = T.unlines . concatMap f . T.lines

-- | Check if a line contains @{ strategy =@ (possibly preceded by @[@ or @,@).
containsStepStart :: Text -> Bool
containsStepStart line = "{ strategy =" `T.isInfixOf` line || "{ strategy=" `T.isInfixOf` line

-- | Extract step blocks from lines. A step block starts with a line containing
-- @{ strategy =@ and ends at the next line ending with @}@.
extractStepBlocks :: [Text] -> [[Text]]
extractStepBlocks [] = []
extractStepBlocks (l : rest)
  | containsStepStart l =
      let (block, remaining) = spanBlock (l : rest)
       in block : extractStepBlocks remaining
  | otherwise = extractStepBlocks rest
  where
    spanBlock [] = ([], [])
    spanBlock (x : xs)
      | "}" `T.isSuffixOf` T.stripEnd x = ([x], xs)
      | otherwise =
          let (block, remaining) = spanBlock xs
           in (x : block, remaining)

-- | Extract the raw value portion of the dependencies field.
-- For @, dependencies = [ "foo", "bar" ]@ this returns @[ "foo", "bar" ]@.
-- For multi-line deps, concatenates all lines.
extractDepsValue :: [Text] -> Text
extractDepsValue ls =
  let region = extractDepsRegion ls
   in case region of
        [] -> ""
        (firstLine : rest) ->
          -- Strip the field name prefix from the first line
          let val = case T.breakOn "=" firstLine of
                (_, after)
                  | T.null after -> firstLine
                  | otherwise -> T.drop 1 after -- drop the '='
           in T.unlines (val : rest)

-- | Extract the lines in the dependencies region.
extractDepsRegion :: [Text] -> [Text]
extractDepsRegion ls =
  let (_, rest) = break (matchesField "dependencies") ls
   in case rest of
        [] -> []
        _ -> fst (spanDepsRegion rest)

-- | Split the deps region from the rest of the lines.
spanDepsRegion :: [Text] -> ([Text], [Text])
spanDepsRegion [] = ([], [])
spanDepsRegion (l : rest)
  -- Single-line: , dependencies = [ "foo" ] or , dependencies = [] : List Text
  | matchesField "dependencies" l
      && ("]" `T.isSuffixOf` T.stripEnd l || "List Text" `T.isSuffixOf` T.stripEnd l) =
      ([l], rest)
  | matchesField "dependencies" l = collectUntilClose [l] rest
  | otherwise = ([], l : rest)
  where
    collectUntilClose acc [] = (reverse acc, [])
    collectUntilClose acc (x : xs)
      | "]" `T.isSuffixOf` T.stripEnd x = (reverse (x : acc), xs)
      | otherwise = collectUntilClose (x : acc) xs

-- | Extract bare string names from a deps value text.
-- Handles both single-line @[ "foo", "bar" ]@ and multi-line formats.
-- A bare string dep is a quoted string that is NOT preceded by @module =@
-- (which would make it a record field value rather than a bare dep).
extractBareStrings :: Text -> [Text]
extractBareStrings t =
  let pieces = T.splitOn "\"" t
   in extractOddElements pieces
  where
    extractOddElements (context : name : rest)
      -- Skip if the name contains '=' which would mean it's part of a record field value
      | "=" `T.isInfixOf` name = extractOddElements rest
      -- Skip if it looks like a type annotation (contains ':')
      | ":" `T.isInfixOf` name = extractOddElements rest
      | T.null name = extractOddElements rest
      -- Skip if preceded by 'module =' — this is a record-form dependency value
      | "module =" `T.isInfixOf` context = extractOddElements rest
      | "module=" `T.isInfixOf` context = extractOddElements rest
      | otherwise = name : extractOddElements rest
    extractOddElements _ = []

-- | Convert dependency lines, handling both single-line and multi-line formats.
convertDepsLines :: [Text] -> [Text]
convertDepsLines [] = []
convertDepsLines [singleLine]
  -- Single-line deps: , dependencies = [ "foo", "bar" ]
  | matchesField "dependencies" singleLine =
      let indent = T.takeWhile isSpace singleLine
          bareNames = extractBareStrings singleLine
       in case bareNames of
            [] -> [singleLine] -- no bare strings to convert, leave as-is
            _ ->
              let entries =
                    [ "  { module = \"" <> name <> "\", vars = [] : List { name : Text, value : Text } }"
                    | name <- bareNames
                    ]
                  formatted = case entries of
                    [] -> [indent <> ", dependencies = [] : List { module : Text, vars : List { name : Text, value : Text } }"]
                    [e] -> [indent <> ", dependencies = [ " <> T.stripStart e <> " ]"]
                    (first : mid) ->
                      let lastEntry = last mid
                          midEntries = init mid
                       in [indent <> ", dependencies ="]
                            <> [indent <> "  [ " <> T.stripStart first]
                            <> [indent <> "  , " <> T.stripStart e | e <- midEntries]
                            <> [indent <> "  , " <> T.stripStart lastEntry]
                            <> [indent <> "  ]"]
               in formatted
convertDepsLines ls =
  -- Multi-line: convert each line that contains a bare string
  map convertMultiLineDepEntry ls
  where
    convertMultiLineDepEntry line
      | hasBareString line =
          let indent = T.takeWhile isSpace line
              s = T.stripStart line
              prefix
                | "[ " `T.isPrefixOf` s = indent <> "[ "
                | "[" `T.isPrefixOf` s = indent <> "[ "
                | ", " `T.isPrefixOf` s = indent <> ", "
                | otherwise = indent <> "  "
              name = extractSingleBareString line
              trailingClose = if "]" `T.isSuffixOf` T.stripEnd line then " ]" else ""
           in prefix <> "{ module = \"" <> name <> "\", vars = [] : List { name : Text, value : Text } }" <> trailingClose
      | otherwise = line

    hasBareString line =
      let s = T.strip line
          cleaned = T.dropWhile (\c -> c == '[' || c == ',' || c == ' ') s
       in case T.uncons (T.stripStart cleaned) of
            Just ('"', _) -> not ("{" `T.isInfixOf` s)
            _ -> False

    extractSingleBareString line =
      let s = T.strip line
          cleaned = T.filter (\c -> c /= '[' && c /= ']' && c /= ',') s
          trimmed = T.strip cleaned
       in case T.stripPrefix "\"" trimmed >>= T.stripSuffix "\"" of
            Just name -> name
            Nothing -> trimmed