packages feed

seihou-core-0.4.0.0: test/Seihou/Core/SchemaUpgradeSpec.hs

module Seihou.Core.SchemaUpgradeSpec (tests) where

import Data.Text qualified as T
import Seihou.Core.SchemaUpgrade
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

-- Test schema URL and hash for upgrade tests
testUrl :: T.Text
testUrl = "https://raw.githubusercontent.com/shinzui/seihou-schema/abc123/package.dhall"

testHash :: T.Text
testHash = "sha256:0000000000000000000000000000000000000000000000000000000000000000"

tests :: IO TestTree
tests = testSpec "Seihou.Core.SchemaUpgrade" spec

-- | A module.dhall missing version, patch, and commands (pre-schema-evolution).
oldModuleText :: T.Text
oldModuleText =
  T.unlines
    [ "{ name = \"old-module\"",
      ", description = Some \"Pre-commands era module\"",
      ", vars = [] : List { name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text }",
      ", exports = [] : List { var : Text, alias : Optional Text }",
      ", prompts = [] : List { var : Text, text : Text, when : Optional Text, choices : Optional (List Text) }",
      ", steps =",
      "  [ { strategy = \"copy\"",
      "    , src = \"foo\"",
      "    , dest = \"foo\"",
      "    , when = None Text",
      "    }",
      "  ]",
      ", dependencies = [] : List Text",
      "}"
    ]

-- | A module with bare string dependencies.
bareStringDepsText :: T.Text
bareStringDepsText =
  T.unlines
    [ "{ name = \"with-deps\"",
      ", version = None Text",
      ", description = Some \"Has bare string deps\"",
      ", vars = [] : List { name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text }",
      ", exports = [] : List { var : Text, alias : Optional Text }",
      ", prompts = [] : List { var : Text, text : Text, when : Optional Text, choices : Optional (List Text) }",
      ", steps = [] : List { strategy : Text, src : Text, dest : Text, when : Optional Text, patch : Optional Text }",
      ", commands = [] : List { run : Text, workDir : Optional Text, when : Optional Text }",
      ", dependencies = [ \"haskell-base\", \"nix-flake\" ]",
      "}"
    ]

-- | A fully current module (with schema import).
currentModuleText :: T.Text
currentModuleText =
  T.unlines
    [ "let S =",
      "      https://raw.githubusercontent.com/shinzui/seihou-schema/abc123/package.dhall",
      "        sha256:0000000000000000000000000000000000000000000000000000000000000000",
      "",
      "in  S.Module::{",
      "    , name = \"current-module\"",
      "    , version = None Text",
      "    , description = Some \"Fully current module\"",
      "    , vars = [] : List { name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text }",
      "    , exports = [] : List { var : Text, alias : Optional Text }",
      "    , prompts = [] : List { var : Text, text : Text, when : Optional Text, choices : Optional (List Text) }",
      "    , steps =",
      "      [ { strategy = \"template\"",
      "        , src = \"foo.tpl\"",
      "        , dest = \"foo\"",
      "        , when = None Text",
      "        , patch = None Text",
      "        }",
      "      ]",
      "    , commands = [] : List { run : Text, workDir : Optional Text, when : Optional Text }",
      "    , dependencies = [] : List { module : Text, vars : List { name : Text, value : Text } }",
      "    , migrations = [] : List S.Migration.Type",
      "    }"
    ]

-- | A module missing only version (has patch and commands).
missingVersionOnlyText :: T.Text
missingVersionOnlyText =
  T.unlines
    [ "{ name = \"no-version\"",
      ", description = Some \"Missing version only\"",
      ", vars = [] : List { name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text }",
      ", exports = [] : List { var : Text, alias : Optional Text }",
      ", prompts = [] : List { var : Text, text : Text, when : Optional Text, choices : Optional (List Text) }",
      ", steps = [] : List { strategy : Text, src : Text, dest : Text, when : Optional Text, patch : Optional Text }",
      ", commands = [] : List { run : Text, workDir : Optional Text, when : Optional Text }",
      ", dependencies = [] : List { module : Text, vars : List { name : Text, value : Text } }",
      "}"
    ]

-- | A module with multiple steps, some missing patch.
multiStepText :: T.Text
multiStepText =
  T.unlines
    [ "{ name = \"multi-step\"",
      ", version = None Text",
      ", description = None Text",
      ", vars = [] : List { name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text }",
      ", exports = [] : List { var : Text, alias : Optional Text }",
      ", prompts = [] : List { var : Text, text : Text, when : Optional Text, choices : Optional (List Text) }",
      ", steps =",
      "  [ { strategy = \"copy\"",
      "    , src = \"a\"",
      "    , dest = \"a\"",
      "    , when = None Text",
      "    }",
      "  , { strategy = \"template\"",
      "    , src = \"b.tpl\"",
      "    , dest = \"b\"",
      "    , when = None Text",
      "    , patch = None Text",
      "    }",
      "  , { strategy = \"copy\"",
      "    , src = \"c\"",
      "    , dest = \"c\"",
      "    , when = None Text",
      "    }",
      "  ]",
      ", commands = [] : List { run : Text, workDir : Optional Text, when : Optional Text }",
      ", dependencies = [] : List { module : Text, vars : List { name : Text, value : Text } }",
      "}"
    ]

spec :: Spec
spec = do
  describe "detectIssues" $ do
    it "detects all issues in an old module" $ do
      let issues = detectIssues testUrl oldModuleText
      issues `shouldContain` [MissingVersion]
      issues `shouldContain` [MissingCommands]
      issues `shouldContain` [MissingStepPatch 0]
      issues `shouldContain` [BareStringDepTypeAnnotation]
      issues `shouldContain` [MissingSchemaImport]
      issues `shouldContain` [MissingMigrations]

    it "returns empty list for a current module" $ do
      detectIssues testUrl currentModuleText `shouldBe` []

    it "detects missing version, schema import, and migrations when other fields present" $ do
      detectIssues testUrl missingVersionOnlyText
        `shouldBe` [MissingVersion, MissingSchemaImport, MissingMigrations]

    it "detects bare string dependencies" $ do
      let issues = detectIssues testUrl bareStringDepsText
      issues `shouldContain` [BareStringDep "haskell-base"]
      issues `shouldContain` [BareStringDep "nix-flake"]

    it "detects missing patch only on steps that lack it" $ do
      let issues = detectIssues testUrl multiStepText
      issues `shouldContain` [MissingStepPatch 0]
      issues `shouldContain` [MissingStepPatch 2]
      issues `shouldNotContain` [MissingStepPatch 1]

  describe "upgradeModuleText" $ do
    it "returns AlreadyCurrent for a current module" $ do
      upgradeModuleText testUrl testHash currentModuleText `shouldBe` AlreadyCurrent

    it "inserts version field after name" $ do
      case upgradeModuleText testUrl testHash missingVersionOnlyText of
        Upgraded text _ -> do
          T.isInfixOf ", version = None Text" text `shouldBe` True
          -- version should appear after name and before description
          let ls = T.lines text
              nameIdx = findIndex (T.isInfixOf "{ name =") ls
              versionIdx = findIndex (T.isInfixOf ", version =") ls
              descIdx = findIndex (T.isInfixOf ", description =") ls
          case (nameIdx, versionIdx, descIdx) of
            (Just n, Just v, Just d) -> do
              v `shouldBe` n + 1
              d `shouldSatisfy` (> v)
            _ -> expectationFailure "could not find expected fields"
        AlreadyCurrent -> expectationFailure "expected Upgraded"

    it "inserts commands field" $ do
      case upgradeModuleText testUrl testHash oldModuleText of
        Upgraded text _ ->
          T.isInfixOf ", commands =" text `shouldBe` True
        AlreadyCurrent -> expectationFailure "expected Upgraded"

    it "inserts patch in steps that lack it" $ do
      case upgradeModuleText testUrl testHash multiStepText of
        Upgraded text _ -> do
          -- Should have 3 occurrences of patch now (step 1 already had it)
          let patchCount = length (filter (T.isInfixOf ", patch =") (T.lines text))
          patchCount `shouldBe` 3
        AlreadyCurrent -> expectationFailure "expected Upgraded"

    it "converts bare string deps to record form" $ do
      case upgradeModuleText testUrl testHash bareStringDepsText of
        Upgraded text _ -> do
          T.isInfixOf "{ module = \"haskell-base\"" text `shouldBe` True
          T.isInfixOf "{ module = \"nix-flake\"" text `shouldBe` True
          -- The old bare format "haskell-base", "nix-flake" should be gone
          T.isInfixOf "\"haskell-base\", \"nix-flake\"" text `shouldBe` False
        AlreadyCurrent -> expectationFailure "expected Upgraded"

    it "converts List Text annotation to record type" $ do
      case upgradeModuleText testUrl testHash oldModuleText of
        Upgraded text _ -> do
          -- The dependencies line should no longer use "[] : List Text"
          T.isInfixOf "[] : List Text" text `shouldBe` False
          -- It should use the record type annotation instead
          T.isInfixOf "List { module : Text" text `shouldBe` True
        AlreadyCurrent -> expectationFailure "expected Upgraded"

    it "is idempotent" $ do
      case upgradeModuleText testUrl testHash oldModuleText of
        Upgraded text _ ->
          upgradeModuleText testUrl testHash text `shouldBe` AlreadyCurrent
        AlreadyCurrent -> expectationFailure "expected first upgrade to produce changes"

    it "injects schema import into legacy module" $ do
      case upgradeModuleText testUrl testHash oldModuleText of
        Upgraded text _ -> do
          T.isInfixOf "let S =" text `shouldBe` True
          T.isInfixOf "seihou-schema" text `shouldBe` True
          T.isInfixOf "S.Module::" text `shouldBe` True
        AlreadyCurrent -> expectationFailure "expected Upgraded"

    it "inserts migrations field" $ do
      case upgradeModuleText testUrl testHash missingVersionOnlyText of
        Upgraded text _ ->
          T.isInfixOf ", migrations =" text `shouldBe` True
        AlreadyCurrent -> expectationFailure "expected Upgraded"

  describe "issueMessage" $ do
    it "produces human-readable messages" $ do
      issueMessage MissingVersion `shouldBe` "missing field: version"
      issueMessage (MissingStepPatch 0) `shouldBe` "missing field: patch (in step 1)"
      issueMessage MissingCommands `shouldBe` "missing field: commands"
      issueMessage (BareStringDep "foo") `shouldBe` "bare string dependency: foo"
      issueMessage MissingMigrations `shouldBe` "missing field: migrations"
  where
    findIndex p xs = go 0 xs
      where
        go _ [] = Nothing
        go i (x : rest')
          | p x = Just i
          | otherwise = go (i + 1) rest'