packages feed

seihou-core-0.4.0.0: test/Seihou/Engine/PlanSpec.hs

module Seihou.Engine.PlanSpec (tests) where

import Data.Aeson qualified as Aeson
import Data.Map.Strict qualified as Map
import Data.Text qualified as T
import Data.Text.Encoding qualified as T
import Data.Yaml qualified as Yaml
import Seihou.Core.Module (loadModule)
import Seihou.Core.Types
import Seihou.Engine.Plan
import System.Directory (createDirectoryIfMissing, getCurrentDirectory)
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

tests :: IO TestTree
tests = testSpec "Seihou.Engine.Plan" spec

-- | Helper to create a temp fixture with files in a @files/@ subdirectory.
withFixture :: [(FilePath, String)] -> (FilePath -> IO a) -> IO a
withFixture files action = do
  withSystemTempDirectory "seihou-plan-test" $ \tmpDir -> do
    mapM_ (createFile tmpDir) files
    action tmpDir
  where
    createFile base (path, content) = do
      let full = base </> "files" </> path
          dir = takeDir full
      createDirectoryIfMissing True dir
      writeFile full content

    takeDir = reverse . dropWhile (/= '/') . reverse

spec :: Spec
spec = do
  describe "parentDirs" $ do
    it "returns empty for a file in the root" $ do
      parentDirs "README.md" `shouldBe` []

    it "returns one directory for a file one level deep" $ do
      parentDirs "src/Lib.hs" `shouldBe` ["src"]

    it "returns two directories for a file two levels deep" $ do
      parentDirs "a/b/c.txt" `shouldBe` ["a", "a/b"]

    it "returns three directories for a file three levels deep" $ do
      parentDirs "a/b/c/d.txt" `shouldBe` ["a", "a/b", "a/b/c"]

  describe "compilePlan" $ do
    it "compiles a Copy step" $ do
      withFixture [("data.txt", "raw content")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.empty
        result <- compilePlan baseDir modul vars
        case result of
          Right ops -> ops `shouldBe` [WriteFileOp "data.txt" "raw content" Copy]
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "compiles a Template step with rendering" $ do
      withFixture [("hello.tpl", "Hello, {{name}}!")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Template "hello.tpl" "hello.txt" Nothing Nothing],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.fromList [("name", VText "world")]
        result <- compilePlan baseDir modul vars
        case result of
          Right ops -> ops `shouldBe` [WriteFileOp "hello.txt" "Hello, world!" Template]
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "skips step when condition is false" $ do
      withFixture [("data.txt", "content")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Copy "data.txt" "data.txt" (Just (ExprLit False)) Nothing],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.empty
        result <- compilePlan baseDir modul vars
        case result of
          Right ops -> ops `shouldBe` []
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "includes step when condition is true" $ do
      withFixture [("data.txt", "content")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Copy "data.txt" "data.txt" (Just (ExprLit True)) Nothing],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.empty
        result <- compilePlan baseDir modul vars
        case result of
          Right ops -> ops `shouldBe` [WriteFileOp "data.txt" "content" Copy]
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "evaluates IsSet condition correctly" $ do
      withFixture [("LICENSE", "MIT License")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Copy "LICENSE" "LICENSE" (Just (ExprIsSet "license")) Nothing],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
        -- Variable IS set
        let vars1 = Map.fromList [("license", VText "MIT")]
        result1 <- compilePlan baseDir modul vars1
        case result1 of
          Right ops -> ops `shouldBe` [WriteFileOp "LICENSE" "MIT License" Copy]
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
        -- Variable is NOT set
        let vars2 = Map.empty
        result2 <- compilePlan baseDir modul vars2
        case result2 of
          Right ops -> ops `shouldBe` []
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "expands destination path with placeholders" $ do
      withFixture [("pkg.cabal.tpl", "name: {{name}}\n")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Template "pkg.cabal.tpl" "{{name}}.cabal" Nothing Nothing],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.fromList [("name", VText "my-app")]
        result <- compilePlan baseDir modul vars
        case result of
          Right ops -> ops `shouldBe` [WriteFileOp "my-app.cabal" "name: my-app\n" Template]
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "rejects a rendered destination with a parent directory segment" $ do
      withFixture [("README.md.tpl", "content")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Template "README.md.tpl" "{{project.name}}/README.md" Nothing Nothing],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.fromList [("project.name", VText "..")]
        result <- compilePlan baseDir modul vars
        case result of
          Left errs -> do
            length errs `shouldBe` 1
            T.isInfixOf "rendered destination" (errs !! 0) `shouldBe` True
            T.isInfixOf "must not contain '..' segment" (errs !! 0) `shouldBe` True
          Right _ -> expectationFailure "Expected Left"

    it "rejects a rendered absolute destination" $ do
      withFixture [("README.md.tpl", "content")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Template "README.md.tpl" "{{project.name}}/README.md" Nothing Nothing],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.fromList [("project.name", VText "/tmp/outside")]
        result <- compilePlan baseDir modul vars
        case result of
          Left errs -> do
            length errs `shouldBe` 1
            T.isInfixOf "rendered destination" (errs !! 0) `shouldBe` True
            T.isInfixOf "must be relative" (errs !! 0) `shouldBe` True
          Right _ -> expectationFailure "Expected Left"

    it "allows dots inside a rendered destination filename" $ do
      withFixture [("README.md.tpl", "content")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Template "README.md.tpl" "docs/README.v2.md" Nothing Nothing],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.empty
        result <- compilePlan baseDir modul vars
        case result of
          Right ops -> ops `shouldBe` [CreateDirOp "docs", WriteFileOp "docs/README.v2.md" "content" Template]
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "creates parent directories" $ do
      withFixture [("Lib.hs.tpl", "module Lib where\n")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Template "Lib.hs.tpl" "src/Lib.hs" Nothing Nothing],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.empty
        result <- compilePlan baseDir modul vars
        case result of
          Right ops ->
            ops `shouldBe` [CreateDirOp "src", WriteFileOp "src/Lib.hs" "module Lib where\n" Template]
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "deduplicates parent directory operations" $ do
      withFixture [("A.hs.tpl", "module A\n"), ("B.hs.tpl", "module B\n")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps =
                    [ Step Template "A.hs.tpl" "src/A.hs" Nothing Nothing,
                      Step Template "B.hs.tpl" "src/B.hs" Nothing Nothing
                    ],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.empty
        result <- compilePlan baseDir modul vars
        case result of
          Right ops ->
            ops
              `shouldBe` [ CreateDirOp "src",
                           WriteFileOp "src/A.hs" "module A\n" Template,
                           WriteFileOp "src/B.hs" "module B\n" Template
                         ]
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "propagates template error for unresolved placeholder" $ do
      withFixture [("hello.tpl", "Hello, {{missing}}!")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Template "hello.tpl" "hello.txt" Nothing Nothing],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.empty
        result <- compilePlan baseDir modul vars
        case result of
          Left errs -> do
            length errs `shouldBe` 1
            T.isInfixOf "missing" (errs !! 0) `shouldBe` True
          Right _ -> expectationFailure "Expected Left"

    it "compiles a DhallText step" $ do
      withFixture [("greeting.dhall", "\"Hello, {{name}}!\"")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step DhallText "greeting.dhall" "greeting.txt" Nothing Nothing],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.fromList [("name", VText "world")]
        result <- compilePlan baseDir modul vars
        case result of
          Right ops -> ops `shouldBe` [WriteFileOp "greeting.txt" "Hello, world!" DhallText]
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "compiles a DhallText step with Dhall string interpolation" $ do
      let dhallContent =
            "let name = \"{{project.name}}\"\n\
            \let version = \"{{project.version}}\"\n\
            \in \"name: ${name}\\nversion: ${version}\\n\""
      withFixture [("pkg.dhall", T.unpack dhallContent)] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step DhallText "pkg.dhall" "pkg.txt" Nothing Nothing],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.fromList [("project.name", VText "my-app"), ("project.version", VText "0.1.0.0")]
        result <- compilePlan baseDir modul vars
        case result of
          Right ops -> ops `shouldBe` [WriteFileOp "pkg.txt" "name: my-app\nversion: 0.1.0.0\n" DhallText]
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "reports error for invalid Dhall in DhallText step" $ do
      withFixture [("bad.dhall", "this is not valid dhall {")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step DhallText "bad.dhall" "out.txt" Nothing Nothing],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.empty
        result <- compilePlan baseDir modul vars
        case result of
          Left errs -> do
            length errs `shouldSatisfy` (>= 1)
            T.isInfixOf "Dhall" (errs !! 0) `shouldBe` True
          Right _ -> expectationFailure "Expected Left"

    it "compiles a Structured step to JSON" $ do
      withFixture [("data.json.gen", "{ name = \"{{name}}\", version = \"1.0.0\" }\n")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Structured "data.json.gen" "data.json" Nothing Nothing],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.fromList [("name", VText "my-app")]
        result <- compilePlan baseDir modul vars
        case result of
          Right ops -> do
            length ops `shouldBe` 1
            let (WriteFileOp dest content strat) = ops !! 0
            dest `shouldBe` "data.json"
            strat `shouldBe` Structured
            -- Parse the JSON to verify it's valid
            case Aeson.eitherDecodeStrict (T.encodeUtf8 content) of
              Left err -> expectationFailure ("Invalid JSON: " <> err)
              Right (val :: Aeson.Value) -> do
                val `shouldBe` Aeson.object [("name", Aeson.String "my-app"), ("version", Aeson.String "1.0.0")]
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "compiles a Structured step to YAML" $ do
      withFixture [("config.yaml.gen", "{ name = \"{{name}}\", debug = False }\n")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Structured "config.yaml.gen" "config.yaml" Nothing Nothing],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.fromList [("name", VText "my-app")]
        result <- compilePlan baseDir modul vars
        case result of
          Right ops -> do
            length ops `shouldBe` 1
            let (WriteFileOp dest content strat) = ops !! 0
            dest `shouldBe` "config.yaml"
            strat `shouldBe` Structured
            -- Parse the YAML to verify it's valid
            case Yaml.decodeEither' (T.encodeUtf8 content) of
              Left err -> expectationFailure ("Invalid YAML: " <> show err)
              Right (val :: Aeson.Value) -> do
                val `shouldBe` Aeson.object [("debug", Aeson.Bool False), ("name", Aeson.String "my-app")]
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "reports error for Structured step with unconvertible Dhall expression" $ do
      -- A lambda cannot be converted to JSON
      withFixture [("bad.gen", "\\(x : Text) -> x\n")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Structured "bad.gen" "out.json" Nothing Nothing],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.empty
        result <- compilePlan baseDir modul vars
        case result of
          Left errs -> do
            length errs `shouldSatisfy` (>= 1)
            T.isInfixOf "Cannot convert" (errs !! 0) `shouldBe` True
          Right _ -> expectationFailure "Expected Left"

    it "reports error for unknown output format in Structured step" $ do
      withFixture [("data.gen", "{ name = \"test\" }\n")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Structured "data.gen" "output.txt" Nothing Nothing],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.empty
        result <- compilePlan baseDir modul vars
        case result of
          Left errs -> do
            length errs `shouldSatisfy` (>= 1)
            T.isInfixOf "unsupported output format" (errs !! 0) `shouldBe` True
          Right _ -> expectationFailure "Expected Left"

    it "compiles haskell-base fixture end-to-end" $ do
      cwd <- getCurrentDirectory
      let fixtures = cwd </> "test" </> "fixtures"
      result <- loadModule [fixtures] "haskell-base"
      case result of
        Left err -> expectationFailure ("Failed to load module: " <> show err)
        Right modul -> do
          let vars =
                Map.fromList
                  [ ("project.name", VText "my-app"),
                    ("project.version", VText "0.1.0.0"),
                    ("license", VText "MIT")
                  ]
          planResult <- compilePlan (fixtures </> "haskell-base") modul vars
          case planResult of
            Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
            Right ops -> do
              -- Should have operations for README, src/Lib.hs, LICENSE, my-app.cabal, and cabal.project
              let writeOps = [op | op@(WriteFileOp _ _ _) <- ops]
                  dirOps = [op | op@(CreateDirOp _) <- ops]
              length writeOps `shouldBe` 5
              -- README.md with rendered content
              (writeOps !! 0).dest `shouldBe` "README.md"
              T.isInfixOf "my-app" ((writeOps !! 0).content) `shouldBe` True
              -- src/Lib.hs
              (writeOps !! 1).dest `shouldBe` "src/Lib.hs"
              -- LICENSE (copy)
              (writeOps !! 2).dest `shouldBe` "LICENSE"
              -- my-app.cabal (dest expanded from {{project.name}}.cabal)
              (writeOps !! 3).dest `shouldBe` "my-app.cabal"
              T.isInfixOf "my-app" ((writeOps !! 3).content) `shouldBe` True
              -- cabal.project (DhallText)
              (writeOps !! 4).dest `shouldBe` "cabal.project"
              T.isInfixOf "my-app" ((writeOps !! 4).content) `shouldBe` True
              -- Should have CreateDirOp for src/
              dirOps `shouldSatisfy` any (\op -> op.path == "src")
              -- Should have RunCommandOp for the command
              let cmdOps = [op | op@(RunCommandOp _ _) <- ops]
              length cmdOps `shouldBe` 1
              (cmdOps !! 0).command `shouldBe` "echo 'Project generated'"

    it "compiles a Template step with patch = AppendFile to PatchFileOp" $ do
      withFixture [("section.tpl", "appended content")] $ \baseDir -> do
        let modul =
              Module
                { name = "patch-mod",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Template "section.tpl" "README.md" Nothing (Just AppendFile)],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.empty
        result <- compilePlan baseDir modul vars
        case result of
          Right ops ->
            ops `shouldBe` [PatchFileOp "README.md" "appended content" AppendFile Template "patch-mod"]
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "compiles a Template step with patch = AppendSection to PatchFileOp" $ do
      withFixture [("section.tpl", "section {{name}}")] $ \baseDir -> do
        let modul =
              Module
                { name = "section-mod",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Template "section.tpl" "README.md" Nothing (Just AppendSection)],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.fromList [("name", VText "test")]
        result <- compilePlan baseDir modul vars
        case result of
          Right ops ->
            ops `shouldBe` [PatchFileOp "README.md" "section test" AppendSection Template "section-mod"]
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "compiles a Template step containing {{#if}} and selects the then-branch" $ do
      let tpl = "prefix\n{{#if Eq flag true}}hot\n{{/if}}suffix\n"
      withFixture [("conditional.tpl", tpl)] $ \baseDir -> do
        let modul =
              Module
                { name = "cond-mod",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Template "conditional.tpl" "out.txt" Nothing Nothing],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.fromList [("flag", VBool True)]
        result <- compilePlan baseDir modul vars
        case result of
          Right ops ->
            ops `shouldBe` [WriteFileOp "out.txt" "prefix\nhot\nsuffix\n" Template]
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "compiles a Template step containing {{#if}} and drops the untaken branch" $ do
      let tpl = "prefix\n{{#if Eq flag true}}hot\n{{/if}}suffix\n"
      withFixture [("conditional.tpl", tpl)] $ \baseDir -> do
        let modul =
              Module
                { name = "cond-mod",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Template "conditional.tpl" "out.txt" Nothing Nothing],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.fromList [("flag", VBool False)]
        result <- compilePlan baseDir modul vars
        case result of
          Right ops ->
            ops `shouldBe` [WriteFileOp "out.txt" "prefix\nsuffix\n" Template]
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "compiles a Template patch step whose body uses {{#if}}" $ do
      let tpl = "base{{#if Eq flag true}} extra{{/if}}\n"
      withFixture [("patch.tpl", tpl)] $ \baseDir -> do
        let modul =
              Module
                { name = "patch-cond-mod",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Template "patch.tpl" "README.md" Nothing (Just AppendFile)],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.fromList [("flag", VBool True)]
        result <- compilePlan baseDir modul vars
        case result of
          Right ops ->
            ops
              `shouldBe` [PatchFileOp "README.md" "base extra\n" AppendFile Template "patch-cond-mod"]
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "compiles a Copy step with patch = PrependFile to PatchFileOp" $ do
      withFixture [("header.txt", "header line\n")] $ \baseDir -> do
        let modul =
              Module
                { name = "header-mod",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Copy "header.txt" "out.txt" Nothing (Just PrependFile)],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.empty
        result <- compilePlan baseDir modul vars
        case result of
          Right ops ->
            ops `shouldBe` [PatchFileOp "out.txt" "header line\n" PrependFile Copy "header-mod"]
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "rejects Structured strategy with patch operation" $ do
      withFixture [("data.gen", "{ name = \"test\" }\n")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Structured "data.gen" "data.json" Nothing (Just AppendFile)],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.empty
        result <- compilePlan baseDir modul vars
        case result of
          Left errs -> do
            length errs `shouldSatisfy` (>= 1)
            T.isInfixOf "Structured" (errs !! 0) `shouldBe` True
          Right _ -> expectationFailure "Expected Left"

    it "compiles unconditional command to RunCommandOp" $ do
      withFixture [("data.txt", "content")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing],
                  commands = [Command "echo hello" Nothing Nothing],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.empty
        result <- compilePlan baseDir modul vars
        case result of
          Right ops -> do
            let cmdOps = [op | op@(RunCommandOp _ _) <- ops]
            length cmdOps `shouldBe` 1
            (cmdOps !! 0).command `shouldBe` "echo hello"
            (cmdOps !! 0).workDir `shouldBe` Nothing
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "skips command when condition is false" $ do
      withFixture [("data.txt", "content")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing],
                  commands = [Command "echo skip" Nothing (Just (ExprLit False))],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.empty
        result <- compilePlan baseDir modul vars
        case result of
          Right ops -> do
            let cmdOps = [op | op@(RunCommandOp _ _) <- ops]
            length cmdOps `shouldBe` 0
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "includes command when condition is true" $ do
      withFixture [("data.txt", "content")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing],
                  commands = [Command "echo yes" Nothing (Just (ExprIsSet "name"))],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.fromList [("name", VText "test")]
        result <- compilePlan baseDir modul vars
        case result of
          Right ops -> do
            let cmdOps = [op | op@(RunCommandOp _ _) <- ops]
            length cmdOps `shouldBe` 1
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "places commands after file operations in compiled plan" $ do
      withFixture [("data.txt", "content")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing],
                  commands = [Command "echo post" Nothing Nothing],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.empty
        result <- compilePlan baseDir modul vars
        case result of
          Right ops -> do
            let isFileOp (WriteFileOp {}) = True
                isFileOp (CreateDirOp {}) = True
                isFileOp (CopyFileOp {}) = True
                isFileOp (PatchFileOp {}) = True
                isFileOp _ = False
                isCmdOp (RunCommandOp {}) = True
                isCmdOp _ = False
                fileOps = filter isFileOp ops
                cmdOps = filter isCmdOp ops
            -- Commands should come after all file ops
            ops `shouldBe` (fileOps ++ cmdOps)
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "compiles command with workDir" $ do
      withFixture [("data.txt", "content")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing],
                  commands = [Command "npm install" (Just "subdir") Nothing],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.empty
        result <- compilePlan baseDir modul vars
        case result of
          Right ops -> do
            let cmdOps = [op | op@(RunCommandOp _ _) <- ops]
            length cmdOps `shouldBe` 1
            (cmdOps !! 0).workDir `shouldBe` Just "subdir"
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "interpolates {{var}} in command run field" $ do
      withFixture [("data.txt", "content")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing],
                  commands = [Command "echo {{name}}" Nothing Nothing],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.fromList [("name", VText "my-app")]
        result <- compilePlan baseDir modul vars
        case result of
          Right ops -> do
            let cmdOps = [op | op@(RunCommandOp _ _) <- ops]
            length cmdOps `shouldBe` 1
            (cmdOps !! 0).command `shouldBe` "echo my-app"
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "interpolates {{var}} in command workDir field" $ do
      withFixture [("data.txt", "content")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing],
                  commands = [Command "cabal build" (Just "{{name}}") Nothing],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.fromList [("name", VText "my-app")]
        result <- compilePlan baseDir modul vars
        case result of
          Right ops -> do
            let cmdOps = [op | op@(RunCommandOp _ _) <- ops]
            length cmdOps `shouldBe` 1
            (cmdOps !! 0).workDir `shouldBe` Just "my-app"
          Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "rejects a rendered command workDir with a parent directory segment" $ do
      withFixture [] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [],
                  commands = [Command "cabal build" (Just "{{project.name}}") Nothing],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.fromList [("project.name", VText "..")]
        result <- compilePlan baseDir modul vars
        case result of
          Left errs -> do
            length errs `shouldBe` 1
            T.isInfixOf "rendered command workDir" (errs !! 0) `shouldBe` True
            T.isInfixOf "must not contain '..' segment" (errs !! 0) `shouldBe` True
          Right _ -> expectationFailure "Expected Left"

    it "fails compilation when command has unresolved placeholder" $ do
      withFixture [("data.txt", "content")] $ \baseDir -> do
        let modul =
              Module
                { name = "test",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [],
                  prompts = [],
                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing],
                  commands = [Command "echo {{missing}}" Nothing Nothing],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
            vars = Map.empty
        result <- compilePlan baseDir modul vars
        case result of
          Left errs -> do
            length errs `shouldSatisfy` (>= 1)
            T.isInfixOf "missing" (errs !! 0) `shouldBe` True
          Right _ -> expectationFailure "Expected Left"