packages feed

seihou-core-0.10.0.0: test/Seihou/Engine/CheckoutSpec.hs

module Seihou.Engine.CheckoutSpec (tests) where

import Control.Monad (forM_, when)
import Data.ByteString.Lazy qualified as LBS
import Data.Either (isLeft)
import Data.Generics.Labels ()
import Data.IORef
import Data.Map.Strict qualified as Map
import Data.Maybe (isJust)
import Data.Set qualified as Set
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Text.IO qualified as TIO
import Data.Time (UTCTime (..), fromGregorian)
import Seihou.Core.Checkout
import Seihou.Core.Module (checkFileExistence, validateModule)
import Seihou.Core.Types
import Seihou.Dhall.Eval (evalModuleFromFile)
import Seihou.Effect.Filesystem
import Seihou.Effect.FilesystemInterp (runFilesystem)
import Seihou.Effect.Process
import Seihou.Effect.ProcessInterp (runProcessIO)
import Seihou.Engine.Checkout
import Seihou.Engine.Plan (compilePlan)
import Seihou.Engine.Remove (CheckoutRemovalSelection (..), selectCheckoutRemoval)
import Seihou.Manifest.Types
import Seihou.Prelude
import System.Directory qualified as D
import System.Exit (ExitCode (..))
import System.IO.Temp (withSystemTempDirectory)
import System.Process (readProcessWithExitCode)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

fixtureManifest :: Manifest
fixtureManifest = emptyManifest now & #applications .~ [app] & #checkoutOutputs .~ Map.fromList [(dest, record source) | (dest, source) <- [(".local/config", "config/editor"), (".tool/assets", "assets/shared")]]
  where
    now = UTCTime (fromGregorian 2026 10 15) 0
    app = AppliedComposition (ApplicationId "a") (AppliedModuleTarget "demo") (LocalOrigin "demo") (Just "1") [] Nothing Nothing [AppliedInstanceState "demo" emptyParentVars (LocalOrigin "demo") (Just "1") Map.empty] Map.empty now
    record source = CheckoutOutputRecord (DirectorySymlink source) (Map.singleton (ApplicationId "a") (Set.singleton "demo"))

withFixture :: (FilePath -> IO a) -> IO a
withFixture action = withSystemTempDirectory "checkout-contract" $ \temp -> do
  root <- D.canonicalizePath temp
  (code, _, _) <- readProcessWithExitCode "git" ["-C", root, "init", "-q"] ""
  code `shouldBe` ExitSuccess
  forM_ ["config/editor", "assets/shared"] (D.createDirectoryIfMissing True . (root </>))
  TIO.writeFile (root </> ".gitignore") ".local/\n.tool/\n"
  TIO.writeFile (root </> "config/editor/settings") "sentinel"
  action root

prepare root intent old new = runEff $ runFilesystem $ runProcessIO $ prepareCheckoutPlan intent SyncIgnoredOnly root old new []

apply root plan = runEff $ runFilesystem $ runProcessIO $ applyCheckoutPlan SyncIgnoredOnly root plan

tests :: IO TestTree
tests = testSpec "checkout filesystem contract" $ do
  it "requires application selection for ambiguous owners and preserves unselected claims" $ do
    let [app] = fixtureManifest ^. #applications
        second = app & #applicationId .~ ApplicationId "b"
        shared = fixtureManifest & #applications .~ [app, second] & #checkoutOutputs %~ Map.map (#owners %~ Map.insert (ApplicationId "b") (Set.singleton "demo"))
    selectCheckoutRemoval shared "demo" Nothing `shouldSatisfy` isLeft
    Right (Just selected) <- pure (selectCheckoutRemoval shared "demo" (Just (ApplicationId "a")))
    let retained = selected ^. #candidateManifest
    map (^. #applicationId) (retained ^. #applications) `shouldBe` [ApplicationId "b"]
    map (Map.keys . (^. #owners)) (Map.elems (retained ^. #checkoutOutputs)) `shouldBe` [[ApplicationId "b"], [ApplicationId "b"]]
  it "rejects native spelling aliases when the temporary volume is case-insensitive" $ withFixture $ \root -> do
    insensitive <- D.doesDirectoryExist (root </> ".GIT")
    when insensitive $ do
      let aliased = fixtureManifest & #checkoutOutputs %~ Map.adjust (#spec .~ DirectorySymlink "config/Editor") ".local/config"
      Right plan <- prepare root Rebuild aliased aliased
      checkoutApplicable plan `shouldBe` False
      concatMap (map (^. #code) . (^. #errors)) (plan ^. #entries) `shouldContain` ["checkout_native_alias"]
  it "compiles project sources without an installed files source and validates rendered paths" $ withFixture $ \root -> do
    let modul = Module "demo" (Just "1.0.0") Nothing [VarDecl "source" VTText Nothing Nothing True Nothing] [] [] [Step Symlink "{{source}}" ".local/config" Nothing Nothing Checkout] [] [] Nothing []
    checkFileExistence root modul `shouldReturn` []
    compiled <- compilePlan root modul (Map.singleton "source" (VText "config/editor"))
    compiled `shouldBe` Right [CheckoutOutputOp ".local/config" (DirectorySymlink "config/editor") "demo"]
    escaped <- compilePlan root modul (Map.singleton "source" (VText "../outside"))
    escaped `shouldSatisfy` isLeft
    invalid <- validateModule root (modul & #steps %~ map (#lifecycle .~ Managed))
    invalid `shouldSatisfy` isLeft
    skipped <- compilePlan root (modul & #steps %~ map (#condition ?~ ExprLit False)) Map.empty
    skipped `shouldBe` Right []
  it "traces only declared link writes and parent creation, with fixed read-only Git calls" $ withFixture $ \root -> do
    Right plan <- prepare root Rebuild fixtureManifest fixtureManifest
    writes <- newIORef []
    processes <- newIORef []
    applied <- runEff $ tracedFilesystem writes $ tracedProcess processes $ applyCheckoutPlan SyncIgnoredOnly root plan
    applied ^. #failure `shouldBe` Nothing
    actualWrites <- readIORef writes
    Set.fromList actualWrites `shouldBe` Set.fromList [("mkdir", root </> ".local"), ("mkdir", root </> ".tool"), ("link", root </> ".local/config"), ("link", root </> ".tool/assets")]
    actualProcesses <- readIORef processes
    actualProcesses
      `shouldSatisfy` all
        ( \(cmd, args) ->
            cmd == "git" && case args of
              ["rev-parse", "--show-toplevel"] -> True
              ["ls-files", "--cached", "-z"] -> True
              ["check-ignore", "-q", "--", path] -> "./" `T.isPrefixOf` path
              _ -> False
        )
  it "creates portable links and converges without following their contents" $ withFixture $ \root -> do
    Right plan <- prepare root Rebuild fixtureManifest fixtureManifest
    map (^. #action) (plan ^. #entries) `shouldBe` [CreateLink, CreateLink]
    result <- apply root plan
    result ^. #failure `shouldBe` Nothing
    D.getSymbolicLinkTarget (root </> ".local/config") `shouldReturn` "../config/editor"
    TIO.readFile (root </> "config/editor/settings") `shouldReturn` "sentinel"
    Right repeated <- prepare root Rebuild fixtureManifest fixtureManifest
    map (^. #action) (repeated ^. #entries) `shouldBe` [UnchangedLink, UnchangedLink]
  it "repairs dangling wrong links and leaves sentinel target bytes alone" $ withFixture $ \root -> do
    D.createDirectoryIfMissing True (root </> ".local")
    D.createDirectoryLink "../missing" (root </> ".local/config")
    Right plan <- prepare root Rebuild fixtureManifest fixtureManifest
    map (^. #action) (plan ^. #entries) `shouldBe` [ReplaceLink, CreateLink]
    result <- apply root plan
    result ^. #failure `shouldBe` Nothing
  it "refuses every mutation when any destination is a real object" $ withFixture $ \root -> do
    D.createDirectoryIfMissing True (root </> ".local/config")
    TIO.writeFile (root </> ".local/config/sentinel") "keep"
    Right plan <- prepare root Rebuild fixtureManifest fixtureManifest
    checkoutApplicable plan `shouldBe` False
    result <- apply root plan
    result ^. #failure `shouldSatisfy` isJust
    D.doesPathExist (root </> ".tool") `shouldReturn` False
    TIO.readFile (root </> ".local/config/sentinel") `shouldReturn` "keep"
  it "refuses symlink ancestors and missing sources" $ withFixture $ \root -> do
    D.createDirectoryLink "assets" (root </> ".local")
    Right plan <- prepare root Rebuild fixtureManifest fixtureManifest
    checkoutApplicable plan `shouldBe` False
    D.removeDirectoryLink (root </> ".local")
    D.removeDirectory (root </> "assets/shared")
    Right missing <- prepare root Rebuild fixtureManifest fixtureManifest
    checkoutApplicable missing `shouldBe` False
  it "protects ignored tracked paths even when absent on disk" $ withFixture $ \root -> do
    D.createDirectoryIfMissing True (root </> ".local")
    TIO.writeFile (root </> ".local/config") "tracked"
    (code, _, _) <- readProcessWithExitCode "git" ["-C", root, "add", "-f", "--", ".local/config"] ""
    code `shouldBe` ExitSuccess
    D.removeFile (root </> ".local/config")
    Right plan <- prepare root Rebuild fixtureManifest fixtureManifest
    checkoutApplicable plan `shouldBe` False
    concatMap (map (^. #code) . (^. #errors)) (plan ^. #entries) `shouldContain` ["checkout_path_tracked"]
  it "reports unsupported symlink creation without copying source data" $ withFixture $ \root -> do
    Right plan <- prepare root Rebuild fixtureManifest fixtureManifest
    result <- runEff $ unsupportedLinks $ runProcessIO $ applyCheckoutPlan SyncIgnoredOnly root plan
    result ^. #failure `shouldSatisfy` isJust
    case result ^. #failure of
      Just err -> err ^. #message `shouldSatisfy` T.isInfixOf "symlink permissions"
      Nothing -> expectationFailure "expected a creation error"
    D.doesPathExist (root </> ".local") `shouldReturn` False
    TIO.readFile (root </> "config/editor/settings") `shouldReturn` "sentinel"
  it "rolls back an ordinary mid-apply failure and supports retry" $ withFixture $ \root -> do
    Right plan <- prepare root Rebuild fixtureManifest fixtureManifest
    result <- runEff $ runFilesystem $ runProcessIO $ applyCheckoutPlanWithHook (\n -> when (n == 1) (liftIO (ioError (userError "injected")))) SyncIgnoredOnly root plan
    result ^. #failure `shouldSatisfy` isJust
    D.doesPathExist (root </> ".local") `shouldReturn` False
    retried <- apply root plan
    retried ^. #failure `shouldBe` Nothing
  it "retires a dangling last-owner link without checking its source" $ withFixture $ \root -> do
    Right first <- prepare root Rebuild fixtureManifest fixtureManifest
    _ <- apply root first
    D.removeFile (root </> "config/editor/settings")
    D.removeDirectory (root </> "config/editor")
    let candidate = fixtureManifest & #checkoutOutputs %~ Map.delete ".local/config"
    Right retired <- prepare root Reconcile fixtureManifest candidate
    map (^. #action) (retired ^. #entries) `shouldBe` [RetireLink, UnchangedLink]
    result <- apply root retired
    result ^. #failure `shouldBe` Nothing
    D.pathIsSymbolicLink (root </> ".tool/assets") `shouldReturn` True
  it "rejects unknown specs and bad owners at the byte decoding boundary" $ do
    let bytes = manifestToJSON fixtureManifest
    manifestFromJSON bytes `shouldBe` Right fixtureManifest
    manifestFromJSON (replace "directory-symlink" "invented-kind" bytes) `shouldSatisfy` isLeft
    manifestFromJSON (manifestToJSON (fixtureManifest & #applications .~ [])) `shouldSatisfy` isLeft
  where
    replace a b = LBS.fromStrict . TE.encodeUtf8 . T.replace a b . TE.decodeUtf8 . LBS.toStrict

-- These first-order effects can be reissued in an isolated real interpreter.
-- The trace fails on any text read/write, copy, rename or recursive removal.
tracedFilesystem :: (IOE :> es) => IORef [(Text, FilePath)] -> Eff (Filesystem : es) a -> Eff es a
tracedFilesystem trace = interpret $ \_ operation -> do
  case operation of
    CreateDirectoryIfMissing _ path -> liftIO (modifyIORef' trace (("mkdir", path) :))
    CreateDirectoryLink _ path -> liftIO (modifyIORef' trace (("link", path) :))
    RemoveLink path -> liftIO (modifyIORef' trace (("unlink", path) :))
    InspectPath _ -> pure ()
    ListDirectory _ -> pure ()
    NormalizePath _ -> pure ()
    _ -> liftIO (ioError (userError "checkout attempted an unexpected filesystem effect"))
  liftIO $ runEff $ runFilesystem $ reissue operation

reissue :: Filesystem m a -> Eff '[Filesystem, IOE] a
reissue (CreateDirectoryIfMissing parents path) = createDirectoryIfMissing parents path
reissue (CreateDirectoryLink target path) = createLink target path
reissue (RemoveLink path) = removeLink path
reissue (InspectPath path) = inspectPath path
reissue (ListDirectory path) = listDirectory path
reissue (NormalizePath path) = normalizePath path
reissue (RemoveDirectoryIfEmpty path) = removeDirectoryIfEmpty path
reissue _ = liftIO (ioError (userError "unexpected filesystem effect"))

tracedProcess :: (IOE :> es) => IORef [(Text, [Text])] -> Eff (Process : es) a -> Eff es a
tracedProcess trace = interpret $ \_ (RunProcess cmd args root) -> do
  liftIO (modifyIORef' trace ((cmd, args) :))
  liftIO $ runEff $ runProcessIO $ runProcess cmd args root

unsupportedLinks :: (IOE :> es) => Eff (Filesystem : es) a -> Eff es a
unsupportedLinks = interpret $ \_ operation -> case operation of
  CreateDirectoryLink _ _ -> liftIO (ioError (userError "symlink creation unsupported"))
  _ -> liftIO $ runEff $ runFilesystem $ reissue operation