packages feed

sydtest-mutation-driver-0.1.0.0: src/Test/Syd/Mutation/Driver/Components.hs

{-# LANGUAGE QuasiQuotes #-}

-- | Read a Cabal file and extract the declared component names of one
-- kind ('executables' or 'test-suites').  Used by the Nix harness to
-- discover declared components at build time without having to parse the
-- .cabal file with shell tools.
--
-- The @list-components@ subcommand prints the names; the
-- @install-components@ subcommand reads them and copies the built
-- executables.
module Test.Syd.Mutation.Driver.Components
  ( readComponentNames,
    runListComponents,
    listComponentsFromCabalFile,
    runInstallComponents,
    runInstallComponentsWithBuildDir,
    findCabalFile,
    CabalFileLookupError (..),
    MissingBuiltComponent (..),
  )
where

import Control.Exception (Exception, throwIO)
import qualified Data.ByteString as SB
import Data.List (sort)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Distribution.PackageDescription
  ( GenericPackageDescription (..),
  )
import Distribution.Simple.PackageDescription (readGenericPackageDescription)
import Distribution.Types.UnqualComponentName (unUnqualComponentName)
import Distribution.Verbosity (silent)
import Path
import Path.IO (copyFile, doesFileExist, ensureDir, getCurrentDir, listDirRel)
import Test.Syd.Mutation.Driver.OptParse (ComponentKind (..))

-- | Thrown by 'findCabalFile' when the lookup cannot disambiguate the
-- right cabal file.
data CabalFileLookupError
  = -- | The directory contains no @.cabal@ file at all.
    NoCabalFileFound !(Path Abs Dir)
  | -- | The directory has multiple @.cabal@ files and none of them
    -- matches the preferred @<pname>.cabal@ name.  We do not want to
    -- silently pick one (the previous shell logic did, via @head -n1@,
    -- which silently masked typos in the cabal-file basename).
    AmbiguousCabalFile
      !(Path Abs Dir)
      !String -- preferred pname (without extension)
      ![Path Rel File] -- the conflicting candidates
  deriving (Show)

instance Exception CabalFileLookupError

-- | Locate the @.cabal@ file for a package in a directory.  Prefers
-- @<pname>.cabal@; falls back to a single other @.cabal@ in the
-- directory.  Throws 'CabalFileLookupError' on no-match or ambiguous
-- match.
--
-- Mirrors the shell logic the @postInstall@ blocks used to inline:
--
-- @
-- if [ -f "<pname>.cabal" ]; then …
-- else cabalFile=$(ls -1 *.cabal 2>/dev/null | head -n1); fi
-- @
--
-- with one stricter twist: when the preferred name is not found and
-- multiple other @.cabal@ files are present, the shell silently picked
-- the first; we throw 'AmbiguousCabalFile' instead, so a typo in
-- @<pname>@ is not silently masked.
findCabalFile :: String -> Path Abs Dir -> IO (Path Abs File)
findCabalFile pname dir = do
  preferredRel <- parseRelFile (pname ++ ".cabal")
  let preferred = dir </> preferredRel
  preferredExists <- doesFileExist preferred
  if preferredExists
    then pure preferred
    else do
      (_, files) <- listDirRel dir
      let cabals = sort (filter ((== Just ".cabal") . fileExtension) files)
      case cabals of
        [] -> throwIO (NoCabalFileFound dir)
        [one] -> pure (dir </> one)
        _ -> throwIO (AmbiguousCabalFile dir pname cabals)

-- | Read the declared component names of one kind from a .cabal file.
--
-- Order is the order in which Cabal returns them, which is the order they
-- appear in the file.
readComponentNames :: ComponentKind -> Path Abs File -> IO [String]
readComponentNames kind cabalFile = do
  gpd <- readGenericPackageDescription silent (fromAbsFile cabalFile)
  pure $ case kind of
    ComponentExecutables -> map (unUnqualComponentName . fst) (condExecutables gpd)
    ComponentTestSuites -> map (unUnqualComponentName . fst) (condTestSuites gpd)

-- | Print the declared component names from a .cabal file, one per
-- line.  The function used directly by 'runListComponents' once the
-- cabal file has been resolved.
listComponentsFromCabalFile :: ComponentKind -> Path Abs File -> IO ()
listComponentsFromCabalFile kind cabalFile = do
  names <- readComponentNames kind cabalFile
  SB.putStr (TE.encodeUtf8 (T.unlines (map T.pack names)))

-- | Top-level entry point for the @list-components@ subcommand.
-- Resolves @<pname>.cabal@ in the driver's current working directory
-- via 'findCabalFile' and then delegates to 'listComponentsFromCabalFile'.
runListComponents :: ComponentKind -> String -> IO ()
runListComponents kind pname = do
  cwd <- getCurrentDir
  cabalFile <- findCabalFile pname cwd
  listComponentsFromCabalFile kind cabalFile

-- | Thrown by 'runInstallComponents' when a declared component does not
-- have a built executable at the expected @dist/build/<n>/<n>@ path.
-- This matches the previous shell-loop's exit-1-with-message behaviour
-- but surfaces a typed exception instead.
data MissingBuiltComponent = MissingBuiltComponent
  { missingBuiltComponentName :: !String,
    missingBuiltComponentExpectedAt :: !(Path Abs File)
  }
  deriving (Show)

instance Exception MissingBuiltComponent

-- | Read the declared component names from the cabal file, then for
-- each name copy @<buildDir>/<name>/<name>@ to @<outDir>/<name>@.
-- Creates @outDir@ if it does not already exist.  Throws
-- 'MissingBuiltComponent' when a declared component's executable is not
-- present where expected.
--
-- The @buildDir@ is typically @<PWD>/dist/build@ during a Cabal
-- @Setup build@.  It is taken as an explicit argument (rather than
-- derived from the current working directory) so this function does not
-- mutate global process state — tests that exercise it can use
-- independent tmp dirs in parallel without fighting for CWD.
runInstallComponentsWithBuildDir ::
  ComponentKind ->
  -- | Path to the .cabal file.
  Path Abs File ->
  -- | Cabal build directory (e.g. @<pkg>/dist/build@).
  Path Abs Dir ->
  -- | Output directory to copy executables into.
  Path Abs Dir ->
  IO ()
runInstallComponentsWithBuildDir kind cabalFile buildDir outDir = do
  names <- readComponentNames kind cabalFile
  case names of
    [] -> pure ()
    _ -> do
      ensureDir outDir
      mapM_ (installOne outDir) names
  where
    installOne :: Path Abs Dir -> String -> IO ()
    installOne destDir name = do
      relSubDir <- parseRelDir name
      relBin <- parseRelFile name
      let srcFile = buildDir </> relSubDir </> relBin
          destFile = destDir </> relBin
      exists <- doesFileExist srcFile
      if exists
        then copyFile srcFile destFile
        else
          throwIO
            MissingBuiltComponent
              { missingBuiltComponentName = name,
                missingBuiltComponentExpectedAt = srcFile
              }

-- | Top-level entry point for the @install-components@ subcommand.
--
-- Resolves the cabal file by looking up @<pname>.cabal@ in the
-- driver's current working directory (with the same fallback rules as
-- 'findCabalFile'), takes the Cabal build directory to be
-- @<PWD>/dist/build@ (which is how Cabal lays out its build artefacts
-- during a @Setup build@), then delegates to
-- 'runInstallComponentsWithBuildDir'.
runInstallComponents :: ComponentKind -> String -> Path Abs Dir -> IO ()
runInstallComponents kind pname outDir = do
  cwd <- getCurrentDir
  cabalFile <- findCabalFile pname cwd
  runInstallComponentsWithBuildDir
    kind
    cabalFile
    (cwd </> [reldir|dist/build|])
    outDir