packages feed

seihou-core-0.4.0.0: test/Seihou/Integration/ExecutionSpec.hs

module Seihou.Integration.ExecutionSpec (tests) where

import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Data.Time (UTCTime, defaultTimeLocale, parseTimeOrError)
import Effectful
import Seihou.Core.Module (loadModule)
import Seihou.Core.Types
import Seihou.Core.Variable (resolveVariables)
import Seihou.Effect.FilesystemPure (PureFS (..), emptyFS, runFilesystemPure)
import Seihou.Engine.Diff (computeDiff)
import Seihou.Engine.Execute (dryRunPlan, executePlan)
import Seihou.Engine.Plan (compilePlan)
import Seihou.Manifest.Types (emptyManifest)
import System.Directory (getCurrentDirectory)
import System.FilePath ((</>))
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

tests :: IO TestTree
tests = testSpec "Seihou.Integration.Execution" spec

fixedTime :: UTCTime
fixedTime = parseTimeOrError True defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" "2026-03-01T10:30:00Z"

fixtureDir :: IO FilePath
fixtureDir = do
  cwd <- getCurrentDirectory
  pure (cwd </> "test" </> "fixtures")

-- | Helper to extract the resolved variable values map.
resolvedValues :: Map.Map VarName ResolvedVar -> Map.Map VarName VarValue
resolvedValues = Map.map (.value)

-- | Load haskell-base fixture, resolve vars, compile plan.
compileFixturePlan :: [(Text, Text)] -> IO (Module, [Operation])
compileFixturePlan vars = do
  fixtures <- fixtureDir
  result <- loadModule [fixtures] "haskell-base"
  case result of
    Left err -> error ("Failed to load module: " <> show err)
    Right modul -> do
      let cli = Map.fromList [(VarName k, v) | (k, v) <- vars]
          env = Map.empty
      case resolveVariables (modul.vars) cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
        Left errs -> error ("Failed to resolve: " <> show errs)
        Right resolved -> do
          planResult <- compilePlan (fixtures </> "haskell-base") modul (resolvedValues resolved)
          case planResult of
            Left errs -> error ("Plan failed: " <> show errs)
            Right ops -> pure (modul, ops)

-- | Helper to create a manifest with file records (avoids ambiguous record update).
manifestWithFiles :: UTCTime -> Map.Map FilePath FileRecord -> Manifest
manifestWithFiles t recs =
  let base = emptyManifest t
   in Manifest
        { version = base.version,
          genAt = base.genAt,
          modules = base.modules,
          vars = base.vars,
          files = recs
        }

-- | Extract planned files from operations for computeDiff.
extractPlanned :: ModuleName -> [Operation] -> [(FilePath, Text, ModuleName, Maybe PatchOp)]
extractPlanned modName' ops =
  [(dest, content, modName', Nothing) | WriteFileOp dest content _ <- ops]
    ++ [(dest, content, mName, Just pOp) | PatchFileOp dest content pOp _ mName <- ops]

spec :: Spec
spec = do
  describe "full execution pipeline" $ do
    it "first run creates files and builds file records" $ do
      (modul, ops) <- compileFixturePlan [("project.name", "my-app")]
      let modName = modul.name
          (records, fs) =
            runPureEff $
              runFilesystemPure emptyFS $
                executePlan "" ops Map.empty modName fixedTime
      -- Verify files were created in the filesystem
      Map.member "README.md" (fs.files) `shouldBe` True
      Map.member "my-app.cabal" (fs.files) `shouldBe` True
      Map.member "src/Lib.hs" (fs.files) `shouldBe` True
      Map.member "LICENSE" (fs.files) `shouldBe` True
      Map.member "cabal.project" (fs.files) `shouldBe` True
      -- Verify FileRecords for manifest
      Map.member "README.md" records `shouldBe` True
      Map.member "my-app.cabal" records `shouldBe` True
      Map.member "src/Lib.hs" records `shouldBe` True
      -- Verify content
      Map.lookup "README.md" (fs.files) `shouldBe` Just "# my-app\n\nVersion: 0.1.0.0\n"

    it "re-run with same plan shows all unchanged" $ do
      (modul, ops) <- compileFixturePlan [("project.name", "my-app")]
      let modName = modul.name
          planned = extractPlanned modName ops
          -- First run: execute to get filesystem state and records
          (records, fs) =
            runPureEff $
              runFilesystemPure emptyFS $
                executePlan "" ops Map.empty modName fixedTime
          -- Build manifest from first run
          manifest = manifestWithFiles fixedTime records
          -- Re-run: compute diff against same plan and same FS
          (diff, _) =
            runPureEff $
              runFilesystemPure fs $
                computeDiff manifest (Set.singleton modName) planned
      -- All files should be unchanged
      length (diff.new) `shouldBe` 0
      length (diff.modified) `shouldBe` 0
      length (diff.conflicts) `shouldBe` 0
      length (diff.orphaned) `shouldBe` 0
      length (diff.unchanged) `shouldBe` 5

    it "re-run with changed variable shows modified, new, and orphaned" $ do
      (modul, ops1) <- compileFixturePlan [("project.name", "my-app")]
      let modName = modul.name
          -- First run
          (records, fs) =
            runPureEff $
              runFilesystemPure emptyFS $
                executePlan "" ops1 Map.empty modName fixedTime
          manifest = manifestWithFiles fixedTime records
      -- Compile with changed variable
      (_, ops2) <- compileFixturePlan [("project.name", "other-app")]
      let planned2 = extractPlanned modName ops2
          (diff, _) =
            runPureEff $
              runFilesystemPure fs $
                computeDiff manifest (Set.singleton modName) planned2
      -- README.md and cabal.project have different content → Modified
      length (diff.modified) `shouldBe` 2
      -- src/Lib.hs and LICENSE have same content → Unchanged
      length (diff.unchanged) `shouldBe` 2
      -- my-app.cabal not in new plan → Orphaned
      length (diff.orphaned) `shouldBe` 1
      (head diff.orphaned).path `shouldBe` "my-app.cabal"
      -- other-app.cabal is new → New
      length (diff.new) `shouldBe` 1
      (head diff.new).path `shouldBe` "other-app.cabal"
      -- No conflicts
      length (diff.conflicts) `shouldBe` 0

    it "dryRunPlan lists all operations without execution" $ do
      (_, ops) <- compileFixturePlan [("project.name", "my-app")]
      let text = dryRunPlan ops
      T.isInfixOf "README.md" text `shouldBe` True
      T.isInfixOf "my-app.cabal" text `shouldBe` True
      T.isInfixOf "cabal.project" text `shouldBe` True

    it "force mode: re-execute after user edit overwrites the file" $ do
      (modul, ops) <- compileFixturePlan [("project.name", "my-app")]
      let modName = modul.name
          planned = extractPlanned modName ops
          -- First run
          (records, fs1) =
            runPureEff $
              runFilesystemPure emptyFS $
                executePlan "" ops Map.empty modName fixedTime
          manifest = manifestWithFiles fixedTime records
          -- Simulate user editing README.md
          fs2 = PureFS (Map.insert "README.md" "user edit" fs1.files) fs1.dirs
          -- Compute diff → should detect conflict
          (diff, _) =
            runPureEff $
              runFilesystemPure fs2 $
                computeDiff manifest (Set.singleton modName) planned
      -- README.md is a conflict (user edited, plan unchanged)
      length (diff.conflicts) `shouldBe` 1
      (head diff.conflicts).path `shouldBe` "README.md"
      -- Force: re-execute (overwrites user changes)
      let (_, fs3) =
            runPureEff $
              runFilesystemPure fs2 $
                executePlan "" ops Map.empty modName fixedTime
      -- Verify README.md was overwritten with plan content
      Map.lookup "README.md" fs3.files `shouldBe` Just "# my-app\n\nVersion: 0.1.0.0\n"

    it "records correct strategy per file in FileRecords" $ do
      (modul, ops) <- compileFixturePlan [("project.name", "my-app")]
      let modName = modul.name
          (records, _) =
            runPureEff $
              runFilesystemPure emptyFS $
                executePlan "" ops Map.empty modName fixedTime
      -- README.md → Template
      (records Map.! "README.md").strategy `shouldBe` Template
      -- src/Lib.hs → Template
      (records Map.! "src/Lib.hs").strategy `shouldBe` Template
      -- LICENSE → Copy
      (records Map.! "LICENSE").strategy `shouldBe` Copy
      -- my-app.cabal → Template
      (records Map.! "my-app.cabal").strategy `shouldBe` Template
      -- cabal.project → DhallText
      (records Map.! "cabal.project").strategy `shouldBe` DhallText