packages feed

cabal-buck2-0.2.0.0: test/Main.hs

-- | Runs @cabal-buck2@ on the fixture projects in @test/fixtures@ and checks
-- the files it generates. None of these needs a buck2 binary, or a buck2
-- prelude: the fixtures' @buck2@ directory only has to exist.
module Main (main) where

import Control.Exception (SomeException, bracket, displayException, try)
import Control.Monad (forM_, unless, when)
import Data.List (isInfixOf)
import qualified Data.ByteString.Char8 as BS8
import qualified Data.Map as Map
import System.Directory
  ( copyFile
  , createDirectoryIfMissing
  , doesDirectoryExist
  , doesFileExist
  , removeFile
  , findExecutable
  , getTemporaryDirectory
  , listDirectory
  , removeDirectoryRecursive
  )
import System.Environment (getEnvironment, lookupEnv)
import System.Exit (ExitCode (..), exitFailure)
import System.FilePath ((</>))
import System.IO (hPutStrLn, stderr)
import System.Process (CreateProcess (..), callProcess, getCurrentPid, proc, readCreateProcessWithExitCode)

import Distribution.Client.Buck2.Cache (cacheBlock, spliceBlock)
import Distribution.Client.Buck2.Fingerprint (ccFingerprintOf, fingerprintOf)
import Distribution.Client.Buck2.Version (cabalBuck2Version)

main :: IO ()
main = do
  exe <- maybe (fail "cabal-buck2 not found on PATH") return =<< findExecutable "cabal-buck2"
  results <- mapM (runTest exe) tests
  unitResults <- mapM runUnitTest unitTests
  let failures =
        [name | (name, False) <- zip (map fst tests) results]
          ++ [name | (name, False) <- zip (map fst unitTests) unitResults]
  unless (null failures) $ do
    hPutStrLn stderr $ "FAILED: " ++ unwords failures
    exitFailure

unitTests :: [(String, IO ())]
unitTests = [("fingerprint", fingerprint), ("cc-fingerprint", ccFingerprint), ("splice-block", spliceBlockTest)]

runUnitTest :: (String, IO ()) -> IO Bool
runUnitTest (name, test) = do
  r <- try test
  case r of
    Right () -> putStrLn ("PASS " ++ name) >> return True
    Left e -> do
      hPutStrLn stderr ("FAIL " ++ name ++ ": " ++ displayException (e :: SomeException))
      return False

tests :: [(String, Project -> IO ())]
tests =
  [ ("basic", basic)
  , ("disabled-stanzas", disabledStanzas)
  , ("missing-module", missingModule)
  , ("cache-config", cacheConfig)
  , ("source-deps", sourceDeps)
  , ("custom-setup", customSetup)
  , ("reexports", reexports)
  , ("buck2-version", buck2Version)
  ]

-- | A copy of a fixture project, and how to run @cabal-buck2@ in it.
data Project = Project
  { projectDir :: FilePath
  , runBuck2 :: [String] -> IO (ExitCode, String)
  -- ^ The exit code and the output (stdout and stderr).
  }

runTest :: FilePath -> (String, Project -> IO ()) -> IO Bool
runTest exe (name, test) = do
  tmp <- getTemporaryDirectory
  pid <- getCurrentPid
  let dir = tmp </> ("cabal-buck2-test-" ++ show pid ++ "-" ++ name)
  bracket (setup dir) (const (cleanup dir)) $ \project -> do
    r <- try (test project)
    case r of
      Right () -> putStrLn ("PASS " ++ name) >> return True
      Left e -> do
        hPutStrLn stderr ("FAIL " ++ name ++ ": " ++ displayException (e :: SomeException))
        return False
  where
    -- The fixture is named after the test, except for tests that reuse one.
    fixture = case name of
      "disabled-stanzas" -> "basic"
      "cache-config" -> "basic"
      "buck2-version" -> "basic"
      _ -> name
    setup dir = do
      cleanup dir
      copyTree ("test" </> "fixtures" </> fixture) (dir </> "project")
      -- What a checkout of the right branch of haskell-buck2 has.
      writeBuck2Version (dir </> "project") cabalBuck2Version
      env <- getEnvironment
      -- The compiler to use: $HC if it's set (as haskell-ci does), otherwise
      -- whatever `ghc` is on the PATH.
      compilerArgs <- maybe [] (\hc -> ["-w", hc]) <$> lookupEnv "HC"
      -- A private cabal directory and config, so that the user's store and
      -- config (which $CABAL_DIR and $CABAL_CONFIG may point to, as in
      -- haskell-ci) are neither used nor changed.
      let cabalDir = dir </> "cabal-dir"
          cabalEnv =
            [("CABAL_DIR", cabalDir), ("CABAL_CONFIG", cabalDir </> "config")]
              ++ [kv | kv@(k, _) <- env, k `notElem` ["CABAL_DIR", "CABAL_CONFIG"]]
          run args = do
            (code, out, err) <-
              readCreateProcessWithExitCode
                (proc exe (compilerArgs ++ args)){cwd = Just (dir </> "project"), env = Just cabalEnv}
                ""
            return (code, out ++ err)
      createDirectoryIfMissing True cabalDir
      writeFile (cabalDir </> "config") ""
      return Project{projectDir = dir </> "project", runBuck2 = run}
    cleanup dir = do
      exists <- doesDirectoryExist dir
      when exists $ removeDirectoryRecursive dir

writeBuck2Version :: FilePath -> String -> IO ()
writeBuck2Version projectDir' version =
  writeFile (projectDir' </> "buck2" </> "version.bzl") ("CABAL_BUCK2_VERSION = \"" ++ version ++ "\"\n")

copyTree :: FilePath -> FilePath -> IO ()
copyTree from to = do
  createDirectoryIfMissing True to
  names <- listDirectory from
  forM_ names $ \n -> do
    isDir <- doesDirectoryExist (from </> n)
    if isDir then copyTree (from </> n) (to </> n) else copyFile (from </> n) (to </> n)

-- * Assertions

-- | Run @cabal-buck2@ and check that it succeeded.
buck2 :: Project -> [String] -> IO String
buck2 project args = do
  (code, out) <- runBuck2 project args
  when (code /= ExitSuccess) $ failure ("cabal-buck2 " ++ unwords args ++ " failed:\n" ++ out)
  return out

failure :: String -> IO a
failure = ioError . userError

readIn :: Project -> FilePath -> IO String
readIn project path = do
  s <- readFile (projectDir project </> path)
  length s `seq` return s

assertContains :: String -> String -> String -> IO ()
assertContains what needle haystack =
  unless (needle `isInfixOf` haystack) $
    failure (what ++ ": expected to contain " ++ show needle ++ ", but it is:\n" ++ haystack)

assertNotContains :: String -> String -> String -> IO ()
assertNotContains what needle haystack =
  when (needle `isInfixOf` haystack) $
    failure (what ++ ": expected not to contain " ++ show needle ++ ", but it is:\n" ++ haystack)

-- * Tests

-- | The fingerprint of the GHC installation, which keeps the buck2 action
-- cache from serving the output of a different GHC.
fingerprint :: IO ()
fingerprint = do
  let props = Map.fromList [("Project version", "9.6.7"), ("Target platform", "x86_64-unknown-linux"), ("Project Git commit id", "2b22b6ae69c94e721fde8af0108eb0feed97cc82"), ("RTS ways", "debug thr")]
      conf abi = BS8.pack ("name: base\nversion: 4.18\nabi: " ++ abi ++ "\nid: base-4.18\n")
      confs = [("base-4.18.conf", conf "aaaa"), ("ghc-prim-0.10.conf", conf "bbbb")]
      fp = fingerprintOf props confs
  -- Readable: version, platform and source commit come first.
  assertContains "fingerprint" "9.6.7-x86_64-unknown-linux-2b22b6ae-" fp
  -- It doesn't depend on the order of the files.
  assertEqual "fingerprint of reordered confs" fp (fingerprintOf props (reverse confs))
  -- It changes with the interface hash of any package, with the GHC commit,
  -- and with the build properties.
  assertDiffers "changed abi" fp (fingerprintOf props [("base-4.18.conf", conf "aaaa"), ("ghc-prim-0.10.conf", conf "cccc")])
  assertDiffers "changed commit" fp (fingerprintOf (Map.insert "Project Git commit id" "3b22b6ae69c9" props) confs)
  assertDiffers "changed RTS ways" fp (fingerprintOf (Map.insert "RTS ways" "debug thr dyn" props) confs)
  -- Properties it doesn't use don't matter.
  assertEqual "unused property" fp (fingerprintOf (Map.insert "C compiler command" "gcc" props) confs)

-- | The fingerprint of the C toolchain: GHC uses it to preprocess and link, and
-- buck2 to compile and link C and C++.
ccFingerprint :: IO ()
ccFingerprint = do
  let reports =
        [ ("target", "x86_64-pc-linux-gnu")
        , ("libc", "ldd (GNU libc) 2.39")
        , ("libstdc++", "libstdc++.so.6.0.33")
        , ("gcc", "gcc (GCC) 13.2.0")
        , ("ld", "GNU ld 2.42")
        ]
      fp = ccFingerprintOf reports
  assertContains "cc fingerprint" "x86_64-pc-linux-gnu-" fp
  assertEqual "reordered" fp (ccFingerprintOf (reverse reports))
  let changed name new = [(n, if n == name then new else v) | (n, v) <- reports]
  assertDiffers "libc" fp (ccFingerprintOf (changed "libc" "ldd (GNU libc) 2.40"))
  assertDiffers "libstdc++" fp (ccFingerprintOf (changed "libstdc++" "libstdc++.so.6.0.34"))
  assertDiffers "compiler" fp (ccFingerprintOf (changed "gcc" "gcc (GCC) 14.1.0"))
  assertDiffers "linker" fp (ccFingerprintOf (changed "ld" "GNU ld 2.43"))
  assertDiffers "target" fp (ccFingerprintOf (changed "target" "aarch64-linux-gnu"))

-- | Adding, changing and removing the block that @--cache@ puts in a config
-- file, without touching anything else in it.
spliceBlockTest :: IO ()
spliceBlockTest = do
  let user = "[cells]\n  root = .\n\n[build]\n  threads = 4\n"
      add address = spliceBlock (Just (cacheBlock address))
      once = add "grpc://a:1" user
  -- The user's part is kept, and the block comes after it.
  assertContains "added block" "[cells]\n  root = .\n\n[build]\n  threads = 4\n\n# >>> cabal buck2: cache" once
  assertContains "added block" "action_cache_address = grpc://a:1" once
  -- Doing it again changes nothing; a new address replaces the old one.
  assertEqual "idempotent" once (add "grpc://a:1" once)
  let changed = add "grpc://b:2" once
  assertContains "changed address" "action_cache_address = grpc://b:2" changed
  assertNotContains "changed address" "grpc://a:1" changed
  -- Removing it gives back the original, and removing again is a no-op.
  let removed = spliceBlock Nothing changed
  assertEqual "removed" user removed
  assertEqual "removed twice" user (spliceBlock Nothing removed)
  -- A file without a trailing newline or blocks is left alone when there is nothing to remove.
  assertEqual "nothing to remove" "[a]" (spliceBlock Nothing "[a]")
  -- Text after the block is kept too.
  assertContains "text after the block" "[later]" (add "grpc://c:3" (once ++ "[later]\n"))

assertEqual :: String -> String -> String -> IO ()
assertEqual what a b = unless (a == b) $ failure (what ++ ": " ++ show a ++ " /= " ++ show b)

assertDiffers :: String -> String -> String -> IO ()
assertDiffers what a b = when (a == b) $ failure (what ++ ": expected a different fingerprint, got " ++ show a)

-- | The main mapping rules, end to end: a plain library (lib-pkg), a second
-- package (exe-pkg) whose library depends on it, an executable with
-- @c-sources@, an exitcode-stdio-1.0 test-suite and a benchmark (both only
-- generated because tests and benchmarks are enabled), a @detailed-0.9@
-- test-suite (which gets a generated stub @Main@), and a manual flag gating
-- @cpp-options@.
basic :: Project -> IO ()
basic project = do
  _ <- buck2 project ["--enable-tests", "--enable-benchmarks", "-f+loud"]

  -- The GHC fingerprint is generated into tools.bzl, and is stable.
  let toolsPath = "third-party" </> "haskell" </> "tools.bzl"
  tools <- readIn project toolsPath
  assertContains toolsPath "GHC_FINGERPRINT = \"" tools
  assertContains toolsPath "CC_FINGERPRINT = \"" tools
  _ <- buck2 project ["--enable-tests", "--enable-benchmarks", "-f+loud"]
  tools' <- readIn project toolsPath
  assertEqual "tools.bzl after a second run" tools tools'

  -- The generated file is a build spec - a plain dict describing each
  -- component as Cabal sees it - interpreted by buck2/cabal.bzl, which
  -- decides which rules, labels and flags that becomes.
  libBzl <- readIn project ("lib-pkg" </> "BUCK.cabal.bzl")
  let lib = assertContains "lib-pkg/BUCK.cabal.bzl"
      noLib = assertNotContains "lib-pkg/BUCK.cabal.bzl"
  lib "local_build_spec" libBzl
  lib "'kind': 'library'" libBzl
  lib "'name': 'lib-pkg'" libBzl
  noLib "haskell_library(" libBzl
  -- Project-level options and test options are only recorded where they
  -- apply.
  noLib "'ghc_options'" libBzl
  noLib "'test_args'" libBzl

  exeBzl <- readIn project ("exe-pkg" </> "BUCK.cabal.bzl")
  let exe = assertContains "exe-pkg/BUCK.cabal.bzl"
  forM_ ["library", "executable", "test-suite", "benchmark"] $ \kind ->
    exe ("'kind': '" ++ kind ++ "'") exeBzl
  exe "'name': 'exe-pkg-bench'" exeBzl

  -- A dependency on another local package carries that package's directory
  -- (from which the target label is made).
  exe "'package': 'lib-pkg'" exeBzl
  exe "'dir': 'lib-pkg'" exeBzl

  -- C sources and include directories are recorded as written in the .cabal
  -- file; cabal.bzl turns them into a cxx_library().
  exe "'cbits/helper.c'" exeBzl
  exe "'include_dirs'" exeBzl
  -- The manual flag's cpp-options.
  exe "'-DLOUD'" exeBzl

  -- `ghc-options:` and `test-options:` from cabal.project (not the .cabal
  -- file) reach the spec: the former once per package, the latter as
  -- `test_args` with template variables expanded per test-suite. The
  -- `-hide-all-packages` that cabal-install always adds (a workaround for
  -- custom Setup.hs scripts) is deliberately not copied over.
  exe "'ghc_options'" exeBzl
  exe "'-fno-ignore-asserts'" exeBzl
  assertNotContains "exe-pkg/BUCK.cabal.bzl" "-hide-all-packages" exeBzl
  exe "'test_args'" exeBzl
  exe "'--opt-one'" exeBzl
  exe "'--opt-two=exe-pkg-test'" exeBzl
  exe "'exe-pkg-detailed-test'" exeBzl

  -- Paths_<pkg>.hs and the detailed-0.9 stub Main both live under
  -- cabal-buck2/autogen/, which has its own BUCK file (see below): the spec
  -- names them, and cabal.bzl refers to them by that file's export_file()
  -- target.
  exe "'Paths_exe_pkg': {" exeBzl
  exe "'autogen': 'Paths_exe_pkg'" exeBzl
  exe "'autogen': 'exe-pkg-detailed-test-stub-main'" exeBzl

  -- The hand-editable BUCK wrapper is created (only once) and loads the
  -- generated file, whose entry point passes customisation through to
  -- cabal.bzl's cabal_targets().
  wrapper <- readIn project ("exe-pkg" </> "BUCK")
  assertContains "exe-pkg/BUCK" "generated_targets" wrapper
  exe "def generated_targets(**kwargs):" exeBzl

  -- The detailed-0.9 test-suite's stub Main is our own generated driver
  -- (not Cabal's stdin-driven one), importing the user's named test-module
  -- directly.
  let autogen = "exe-pkg" </> "cabal-buck2" </> "autogen"
  stub <- readIn project (autogen </> "exe-pkg-detailed-test" </> "Main.hs")
  assertContains "stub Main" "import qualified DetailedTests as CabalBuck2TestModule" stub

  -- cabal_macros.h and Paths_<pkg>.hs come from Cabal's own generators, not
  -- hand-rolled stand-ins.
  macros <- readIn project (autogen </> "exe-pkg" </> "cabal_macros.h")
  assertContains "cabal_macros.h" "CURRENT_PACKAGE_KEY" macros
  paths <- readIn project (autogen </> "Paths_exe_pkg.hs")
  assertContains "Paths_exe_pkg.hs" "version =" paths

  -- cabal-buck2/autogen/BUCK exports every autogen file as a real target
  -- via export_file(): both what makes cabal_component's $(location ...)
  -- reference a buck2-tracked dependency, and what lets a hand-written BUCK
  -- file elsewhere refer to e.g. Paths_<pkg>.
  autogenBuck <- readIn project (autogen </> "BUCK")
  let ab = assertContains "cabal-buck2/autogen/BUCK"
  ab "name = 'exe-pkg-cabal-macros'" autogenBuck
  ab "name = 'Paths_exe_pkg'" autogenBuck
  ab "name = 'exe-pkg-detailed-test-stub-main'" autogenBuck
  -- Each export_file() must set `out` to the real file's basename: without
  -- it, `out` defaults to the rule's name, the artifact loses its extension,
  -- and buck2 silently stops treating it as a Haskell source.
  ab "out = 'Paths_exe_pkg.hs'" autogenBuck
  ab "out = 'cabal_macros.h'" autogenBuck
  ab "out = 'Main.hs'" autogenBuck

-- | Make a repository of the packages in the project's @dep@ directory, which is
-- the only one used, and a @cabal.project@ for the project's @app@ package.
localRepository :: Project -> [String] -> IO ()
localRepository project names = do
  let dir = projectDir project
  createDirectoryIfMissing True (dir </> "repo")
  forM_ names $ \name ->
    callProcess "tar" ["-C", dir </> "dep", "-czf", dir </> "repo" </> (name ++ ".tar.gz"), name]
  writeFile (dir </> "cabal.project") $
    unlines ["packages: app", "repository localrepo", "  url: file+noindex://" ++ dir </> "repo", "active-repositories: localrepo"]

-- | @--source-deps@ unpacks the dependencies (here one package from a local
-- repository) under @dist-newstyle/src@ and generates their targets like
-- those of the project's own packages; without it they are left to the cabal
-- store, and what an earlier run generated for them is removed.
sourceDeps :: Project -> IO ()
sourceDeps project = do
  let dir = projectDir project
  localRepository project ["dep-1.0"]

  -- With the dependency already in the store, from a run without the flag.
  _ <- buck2 project []
  _ <- buck2 project ["--source-deps"]
  let depBzlPath = "dist-newstyle" </> "src" </> "dep-1.0" </> "BUCK.cabal.bzl"
  depBzl <- readIn project depBzlPath
  assertContains depBzlPath "'name': 'dep'" depBzl
  assertContains depBzlPath "'version': '1.0'" depBzl
  assertContains depBzlPath "'dir': 'dist-newstyle/src/dep-1.0'" depBzl
  -- Its data files are recorded, for the tools that read them.
  assertContains depBzlPath "'data/*.txt'" depBzl
  -- hsc2hs is told what it is compiling for.
  assertContains depBzlPath "'-D__GLASGOW_HASKELL__=" depBzl
  assertContains depBzlPath "'asm_sources'" depBzl
  assertContains depBzlPath "'asm_options'" depBzl
  assertContains depBzlPath "'cmm_sources'" depBzl
  assertContains depBzlPath "'cmm/Dep.cmm'" depBzl
  appBzl <- readIn project ("app" </> "BUCK.cabal.bzl")
  -- A literate happy grammar is a module source.
  assertContains "app/BUCK.cabal.bzl" "'Parser': 'Parser.ly'" appBzl
  assertContains "app/BUCK.cabal.bzl" "'dir': 'dist-newstyle/src/dep-1.0'" appBzl

  -- Without the flag the dependency is the store's again.
  _ <- buck2 project []
  removed <- not <$> doesDirectoryExist (dir </> "dist-newstyle" </> "src" </> "dep-1.0")
  unless removed $ failure "dist-newstyle/src/dep-1.0 should have been removed"
  appBzl' <- readIn project ("app" </> "BUCK.cabal.bzl")
  assertNotContains "app/BUCK.cabal.bzl" "dist-newstyle/src/dep-1.0" appBzl'

-- | @reexported-modules@: a module re-exported from another package (by name
-- or found in a dependency, renamed or not) or from another library of the
-- package is resolved as Cabal does, and recorded in the spec with where it comes from, which is how the
-- library claims it - so that a package that depends on the library, and not
-- on where the module is from, can import it. A module of the library itself is recorded without a library.
reexports :: Project -> IO ()
reexports project = do
  _ <- buck2 project []
  reBzl <- readIn project ("re-lib" </> "BUCK.cabal.bzl")
  let re = assertContains "re-lib/BUCK.cabal.bzl"
  re "'reexports'" reBzl
  re "'module': 'Orig.A',\n                    'original': 'Orig.A',\n                    'from': {\n                        'package': 'base-lib',\n                        'dir': 'base-lib'," reBzl
  re "'module': 'New.B',\n                    'original': 'Orig.B'," reBzl
  re "'module': 'Sub.M',\n                    'original': 'Sub.M',\n                    'from': {\n                        'package': 're-lib',\n                        'library': 'sub'," reBzl
  -- One of its own modules has no library it comes from, and no module of its own.
  re "'module': 'New.X',\n                    'original': 'Own.X',\n                }," reBzl
  assertNotContains "re-lib/BUCK.cabal.bzl" "'New.X': {" reBzl

  -- What a library re-exports from another that re-exports it is recorded as
  -- where it is defined, and by that name, as Cabal does.
  re2Bzl <- readIn project ("re2" </> "BUCK.cabal.bzl")
  assertContains "re2/BUCK.cabal.bzl" "'module': 'Newer.B',\n                    'original': 'Orig.B',\n                    'from': {\n                        'package': 'base-lib'," re2Bzl
  assertContains "re2/BUCK.cabal.bzl" "'module': 'Orig.A',\n                    'original': 'Orig.A',\n                    'from': {\n                        'package': 'base-lib'," re2Bzl

  -- A library with no modules has one that doesn't need base.
  emptyBzl <- readIn project ("empty" </> "BUCK.cabal.bzl")
  assertContains "empty/BUCK.cabal.bzl" "'CabalBuck2Empty'" emptyBzl
  stub <- readIn project ("empty" </> "cabal-buck2" </> "autogen" </> "empty" </> "empty" </> "CabalBuck2Empty.hs")
  assertContains "the empty module" "NoImplicitPrelude" stub

  -- What depends on the library doesn't need to depend on where they are from.
  userBzl <- readIn project ("user" </> "BUCK.cabal.bzl")
  assertContains "user/BUCK.cabal.bzl" "'package': 're-lib'" userBzl
  assertNotContains "user/BUCK.cabal.bzl" "base-lib" userBzl

-- | The checkout of haskell-buck2 has to be for this release of cabal-buck2:
-- if it is not, or doesn't say, the error says which branch is needed.
buck2Version :: Project -> IO ()
buck2Version project = do
  let wrong (code', msg) = when (code' == ExitSuccess) $ failure ("accepted: " ++ msg)
  -- An earlier release didn't have a version.bzl.
  removeFile (projectDir project </> "buck2" </> "version.bzl")
  (code, out) <- runBuck2 project []
  wrong (code, "buck2/ with no version")
  assertContains "no version" ("needs the " ++ cabalBuck2Version ++ " branch") out
  assertContains "no version" "buck2/ is for no release" out
  assertContains "no version" "git -C buck2 checkout " out

  writeBuck2Version (projectDir project) "0.0.0.1"
  (code', out') <- runBuck2 project []
  wrong (code', "buck2/ for another release")
  assertContains "other version" ("This is cabal-buck2 " ++ cabalBuck2Version ++ ", and buck2/ is for cabal-buck2 0.0.0.1.") out'

  writeBuck2Version (projectDir project) cabalBuck2Version
  _ <- buck2 project []
  return ()

-- | A package with a @Custom@ build type needs its @Setup.hs@ to be run, which
-- is not done: it, and what depends on it, can't be built.
customSetup :: Project -> IO ()
customSetup project = do
  localRepository project ["custom-dep-0.1"]
  (code, failed) <- runBuck2 project ["--source-deps"]
  when (code == ExitSuccess) $ failure "a package with build-type: Custom was accepted"
  assertContains "error" "custom-dep" failed
  assertContains "error" "build-type: Custom" failed
  assertContains "error" "--keep-going" failed

  out <- buck2 project ["--source-deps", "--keep-going"]
  assertContains "warning" "build-type: Custom" out
  assertContains "warning" "skipping executable app" out
  depBzl <- readIn project ("dist-newstyle" </> "src" </> "custom-dep-0.1" </> "BUCK.cabal.bzl")
  assertNotContains "custom-dep BUCK.cabal.bzl" "'kind': 'library'" depBzl

-- | @--cache=ADDRESS@ adds the cache's settings to @.buckconfig@, which are
-- kept by later runs without the flag, and @--no-cache@ removes.
cacheConfig :: Project -> IO ()
cacheConfig project = do
  let buckconfig = ".buckconfig"
      block = "# >>> cabal buck2: cache"
  before <- readIn project buckconfig
  assertNotContains buckconfig block before

  out <- buck2 project ["--cache=grpc://127.0.0.1:9092"]
  -- A running buck2 daemon would keep the old settings.
  assertContains "notice" "buck2 kill" out
  withCache <- readIn project buckconfig
  assertContains buckconfig "[cabal_buck2]\n  cache = true" withCache
  assertContains buckconfig "default_allow_cache_upload = true" withCache
  assertContains buckconfig "action_cache_address = grpc://127.0.0.1:9092" withCache
  assertContains buckconfig "tls = false" withCache
  -- What was there is kept.
  assertContains buckconfig (take 40 before) withCache

  -- Later runs leave it alone.
  _ <- buck2 project []
  again <- readIn project buckconfig
  assertEqual "after a run without --cache" withCache again

  -- Removing it restores the original file.
  _ <- buck2 project ["--no-cache"]
  without <- readIn project buckconfig
  assertEqual "after --no-cache" before without

  -- Mistakes are reported.
  (badCode, badOut) <- runBuck2 project ["--cache=http://example.org"]
  when (badCode == ExitSuccess) $ failure "--cache=http://... was accepted"
  assertContains "bad address" "grpc://" badOut
  (code', out') <- runBuck2 project ["--cache=grpc://a:1", "--no-cache"]
  when (code' == ExitSuccess) $ failure "--cache with --no-cache was accepted"
  assertContains "both flags" "can't be used together" out'

-- | A plain run, without @--enable-tests@ or @--enable-benchmarks@, must
-- succeed even though the package has test-suites and a benchmark. The
-- benchmark depends on @stm@, which nothing else in the fixture uses: since
-- its stanza isn't enabled, @stm@ is (correctly) absent from the dependency
-- plan that the installed package index is built from, so configuring the
-- benchmark anyway would fail with "the given installed package instance
-- does not exist". The disabled components just get no rule.
disabledStanzas :: Project -> IO ()
disabledStanzas project = do
  _ <- buck2 project []
  exeBzl <- readIn project ("exe-pkg" </> "BUCK.cabal.bzl")
  let exe = "exe-pkg/BUCK.cabal.bzl"
  assertContains exe "'kind': 'library'" exeBzl
  assertContains exe "'kind': 'executable'" exeBzl
  assertNotContains exe "'exe-pkg-bench'" exeBzl
  assertNotContains exe "'kind': 'test-suite'" exeBzl
  assertNotContains exe "'kind': 'benchmark'" exeBzl

-- | A component whose sources can't all be found is skipped with a warning
-- saying why (a rule that names a missing file would take down the whole
-- buck2 build), and so is every component of the same package that depends
-- on it. By default that is an error and nothing is generated; with
-- @--keep-going@ everything else is generated.
missingModule :: Project -> IO ()
missingModule project = do
  (code, failed) <- runBuck2 project []
  when (code == ExitSuccess) $ failure "a component that can't be built was accepted"
  assertContains "error" "for module Absent" failed
  assertContains "error" "skipping library broken-pkg" failed
  assertContains "error" "--keep-going" failed
  generated <- doesFileExist (projectDir project </> "broken-pkg" </> "BUCK.cabal.bzl")
  when generated $ failure "files were generated despite the error"

  out <- buck2 project ["--keep-going"]
  assertContains "output" "for module Absent" out
  assertContains "output" "skipping library broken-pkg" out
  assertContains "output" "skipping executable uses-lib" out

  bzl <- readIn project ("broken-pkg" </> "BUCK.cabal.bzl")
  assertContains "broken-pkg/BUCK.cabal.bzl" "'name': 'standalone'" bzl
  assertNotContains "broken-pkg/BUCK.cabal.bzl" "'name': 'uses-lib'" bzl
  assertNotContains "broken-pkg/BUCK.cabal.bzl" "'kind': 'library'" bzl