packages feed

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

{-# LANGUAGE ApplicativeDo #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

-- | Command-line option parsing for the @sydtest-mutation-driver@
-- executable.  Everything the driver needs comes from CLI flags — no
-- YAML config file, no env vars.
module Test.Syd.Mutation.Driver.OptParse
  ( -- * Top-level dispatch
    Dispatch (..),
    ComponentKind (..),
    getDispatch,

    -- * 'run' subcommand
    SuiteConfig (..),
    SuitePkgSpec (..),
    parseSuitePkgSpec,
    MutationDriverSettings (..),
    defaultCoverageRetry,
    defaultFailFast,

    -- * 'coverage' subcommand
    CoverageSettings (..),

    -- * 'diff' subcommand
    DiffSettings (..),
    DiffSource (..),
    defaultBaseBranch,
  )
where

import GHC.Generics (Generic)
import OptEnvConf
import Path
import Paths_sydtest_mutation_driver (version)

-- | Configuration for one sydtest test suite executable that the driver
-- spawns as a coverage child and as a mutation child.  The driver builds
-- the @Map Text SuiteConfig@ at run time by walking each
-- 'SuitePkgSpec'.
data SuiteConfig = SuiteConfig
  { -- | Absolute path to the test-suite executable on disk.
    suiteConfigExe :: !(Path Abs File),
    -- | Optional resource directory to @cd@ into before spawning the
    -- executable.  Useful so the test suite can find its golden files and
    -- data files via relative paths, just as it would from a Cabal
    -- @checkPhase@.
    suiteConfigResourceDir :: !(Maybe (Path Abs Dir))
  }
  deriving (Show, Eq, Generic)

-- | Specification for one test-package whose installed test-suite
-- executables should be added to the run's suite map.  Parsed from a
-- @--suite-pkg PNAME=BUILT_TEST_PKG_ROOT=RESOURCE_DIR@ flag.
--
-- At run time the driver walks @<built-test-pkg-root>/test/*@ and
-- emits one 'SuiteConfig' per file, keyed by the file's basename.  The
-- resource dir is recorded on every emitted suite.
--
-- The package name does not directly appear in the suite map (suites
-- are keyed by their executable basename, matching the previous bash
-- behaviour) but is carried for error-message attribution.
data SuitePkgSpec = SuitePkgSpec
  { suitePkgSpecPname :: !String,
    suitePkgSpecBuiltTestPkgRoot :: !(Path Abs Dir),
    suitePkgSpecResourceDir :: !(Path Abs Dir)
  }
  deriving (Show, Eq, Generic)

-- | Parse a @PNAME=BUILT_TEST_PKG_ROOT=RESOURCE_DIR@ spec.  '=' is used
-- as the delimiter because Cabal package names exclude it and Nix store
-- paths never contain it; absolute paths in normal use never contain it
-- either.
parseSuitePkgSpec :: String -> Either String SuitePkgSpec
parseSuitePkgSpec s = case splitOnEq s of
  [pname, root, rd]
    | null pname -> Left ("empty pname in --suite-pkg spec: " ++ show s)
    | otherwise -> do
        rootDir <-
          maybe
            (Left ("invalid built-test-pkg root in --suite-pkg spec: " ++ root))
            Right
            (parseAbsDir root)
        rdDir <-
          maybe
            (Left ("invalid resource dir in --suite-pkg spec: " ++ rd))
            Right
            (parseAbsDir rd)
        Right
          SuitePkgSpec
            { suitePkgSpecPname = pname,
              suitePkgSpecBuiltTestPkgRoot = rootDir,
              suitePkgSpecResourceDir = rdDir
            }
  _ -> Left ("--suite-pkg expects PNAME=BUILT_TEST_PKG_ROOT=RESOURCE_DIR, got: " ++ show s)
  where
    splitOnEq :: String -> [String]
    splitOnEq input = case break (== '=') input of
      (a, []) -> [a]
      (a, _ : rest) -> a : splitOnEq rest

-- | The fully resolved settings for one driver run.  Produced directly
-- from CLI flags; the @--suite-pkg@ specs are expanded into the suite
-- map at run time, not at parse time, so the parser does not touch the
-- filesystem.
data MutationDriverSettings = MutationDriverSettings
  { mutationDriverSettingManifests :: ![Path Abs Dir],
    mutationDriverSettingSuitePkgs :: ![SuitePkgSpec],
    -- | Pre-computed per-package coverage directories (each holding
    -- @augmented/@), as produced by the @coverage@ subcommand.  The @run@
    -- subcommand does NOT gather coverage itself: it unions these directories'
    -- augmented manifests, restricts them to the mutations in
    -- @mutationDriverSettingManifests@, and runs only the mutation phase.  This
    -- both honours the cross-package coverage the per-package @coverage@ runs
    -- capture (a test suite in one package covering another package's
    -- mutations) and avoids recomputing coverage once per instrumented library.
    -- At least one is required.
    mutationDriverSettingCoverageDirs :: ![Path Abs Dir],
    mutationDriverSettingChildMemLimit :: !(Maybe String),
    -- | Max mutation children to run concurrently ('Nothing' =
    -- 'getNumCapabilities').
    mutationDriverSettingMutationJobs :: !(Maybe Word),
    mutationDriverSettingAugmentedManifestDir :: !(Path Abs Dir),
    mutationDriverSettingOutDir :: !(Path Abs Dir),
    mutationDriverSettingFailFast :: !Bool,
    -- | Print each mutation's full source diff as it is tested (concise
    -- one-line progress is always printed regardless).
    mutationDriverSettingDebug :: !Bool
  }
  deriving (Show, Eq, Generic)

-- | Settings for the @coverage@ subcommand: build only the coverage cache
-- (the augmented manifest) that the diff-scoped runner depends on, without
-- running the mutation phase.  This is what lets the diff cache be a much
-- cheaper derivation than the full check.
data CoverageSettings = CoverageSettings
  { coverageSettingManifests :: ![Path Abs Dir],
    coverageSettingSuitePkgs :: ![SuitePkgSpec],
    coverageSettingCoverageJobs :: !(Maybe Word),
    coverageSettingCoverageRetry :: !Word,
    coverageSettingAugmentedManifestDir :: !(Path Abs Dir),
    coverageSettingFailFast :: !Bool
  }
  deriving (Show, Eq, Generic)

-- | Where the @diff@ subcommand gets the unified diff to scope the run by.
data DiffSource
  = -- | Read the diff from this file.
    DiffSourceFile !(Path Abs File)
  | -- | Read the diff from standard input.
    DiffSourceStdin
  | -- | Compute @git diff@ against the merge-base of @HEAD@ and the given
    -- base branch, run from this working directory.  This is the default.
    DiffSourceGitMergeBase
      -- | Base branch to find the merge-base with.
      !String
  deriving (Show, Eq, Generic)

-- | Settings for the @diff@ subcommand: the diff-scoped mutation runner.
--
-- It reads the /already-built/ augmented manifest (the cached
-- which-test-covers-which-mutation map) and the cached per-suite
-- @TestId -> source-location@ listings, selects the mutations implied by the
-- diff, and runs only those mutation children.  No compilation and no
-- coverage phase happen.
data DiffSettings = DiffSettings
  { -- | Where the unified diff comes from.
    diffSettingSource :: !DiffSource,
    -- | Test-package specs, expanded to the suite-exe map at run time
    -- (same as the @run@ subcommand).
    diffSettingSuitePkgs :: ![SuitePkgSpec],
    -- | Per-package coverage directories.  Each directory holds that
    -- package's @augmented/manifest-augmented.json@ and its
    -- @test-locations/\<suite\>.tsv@ listings.  The driver unions the
    -- augmented manifests in-memory and reads every directory's
    -- test-locations, so no separate merge step is needed.
    diffSettingCoverageDirs :: ![Path Abs Dir],
    -- | RTS heap cap for each mutation child.
    diffSettingChildMemLimit :: !(Maybe String),
    -- | Max mutation children to run concurrently ('Nothing' =
    -- 'getNumCapabilities').
    diffSettingMutationJobs :: !(Maybe Word),
    -- | Output directory for report.json\/report.txt\/per-suite logs.
    diffSettingOutDir :: !(Path Abs Dir),
    -- | Whether to abort on the first surviving or uncovered mutation.
    diffSettingFailFast :: !Bool,
    -- | Print each mutation's full source diff as it is tested (concise
    -- one-line progress is always printed regardless).
    diffSettingDebug :: !Bool
  }
  deriving (Show, Eq, Generic)

-- | Default base branch the @diff@ subcommand finds the merge-base with when
-- no @--diff@ is given and no @--base@ is set.
defaultBaseBranch :: String
defaultBaseBranch = "master"

-- | Top-level CLI parser for the @run@ subcommand.
mutationDriverSettingsParser :: Parser MutationDriverSettings
mutationDriverSettingsParser = do
  mutationDriverSettingManifests <-
    many $
      directoryPathSetting
        [ help "Mutation manifest directory (one per instrumented library; may be repeated)",
          option,
          long "manifest",
          metavar "DIR"
        ]
  mutationDriverSettingSuitePkgs <-
    many $
      setting
        [ help "Test-package: PNAME=BUILT_TEST_PKG_ROOT=RESOURCE_DIR (may be repeated)",
          reader $ eitherReader parseSuitePkgSpec,
          option,
          long "suite-pkg",
          metavar "PNAME=ROOT=RESOURCE_DIR"
        ]
  mutationDriverSettingCoverageDirs <-
    many $
      directoryPathSetting
        [ help "Pre-computed per-package coverage directory (holding augmented/), from the 'coverage' subcommand; the union of these IS the coverage (the run subcommand does not gather coverage itself). At least one is required (may be repeated).",
          option,
          long "coverage-dir",
          metavar "DIR"
        ]
  mutationDriverSettingChildMemLimit <-
    optional $
      setting
        [ help "RTS heap cap for each mutation child, e.g. 4g",
          reader str,
          option,
          long "child-mem-limit",
          metavar "LIMIT"
        ]
  mutationDriverSettingMutationJobs <- mutationJobsParser
  mutationDriverSettingAugmentedManifestDir <-
    directoryPathSetting
      [ help "Directory for manifest-augmented.json (required)",
        option,
        long "mutation-augmented-manifest-dir"
      ]
  mutationDriverSettingOutDir <-
    directoryPathSetting
      [ help "Output directory: where the driver writes report.txt, report.json, and per-suite *.log files (required)",
        option,
        long "out-dir"
      ]
  mutationDriverSettingFailFast <-
    yesNoSwitch
      [ help "Whether to abort on the first surviving or uncovered mutation",
        long "fail-fast",
        value defaultFailFast
      ]
  mutationDriverSettingDebug <- debugSwitch
  pure MutationDriverSettings {..}

-- | CLI parser for the @coverage@ subcommand.  Shares the coverage-relevant
-- flags with @run@ but takes no @--out-dir@ (it writes no report) and no
-- @--child-mem-limit@ (it spawns no mutation children).
coverageSettingsParser :: Parser CoverageSettings
coverageSettingsParser = do
  coverageSettingManifests <-
    many $
      directoryPathSetting
        [ help "Mutation manifest directory (one per instrumented library; may be repeated)",
          option,
          long "manifest",
          metavar "DIR"
        ]
  coverageSettingSuitePkgs <-
    many $
      setting
        [ help "Test-package: PNAME=BUILT_TEST_PKG_ROOT=RESOURCE_DIR (may be repeated)",
          reader $ eitherReader parseSuitePkgSpec,
          option,
          long "suite-pkg",
          metavar "PNAME=ROOT=RESOURCE_DIR"
        ]
  coverageSettingCoverageJobs <-
    optional $
      setting
        [ help "Maximum number of coverage children to run concurrently",
          reader auto,
          option,
          long "coverage-jobs",
          metavar "INT"
        ]
  coverageSettingCoverageRetry <-
    setting
      [ help "How many times to retry a failing coverage child before giving up",
        reader auto,
        option,
        long "coverage-retry",
        metavar "INT",
        value defaultCoverageRetry
      ]
  coverageSettingAugmentedManifestDir <-
    directoryPathSetting
      [ help "Directory to write manifest-augmented.json into (required)",
        option,
        long "mutation-augmented-manifest-dir"
      ]
  coverageSettingFailFast <-
    yesNoSwitch
      [ help "Whether to abort the coverage run on a baseline test failure",
        long "fail-fast",
        value defaultFailFast
      ]
  pure CoverageSettings {..}

-- | CLI parser for the @diff@ subcommand.
diffSettingsParser :: Parser DiffSettings
diffSettingsParser = do
  diffSettingSource <- diffSourceParser
  diffSettingSuitePkgs <-
    many $
      setting
        [ help "Test-package: PNAME=BUILT_TEST_PKG_ROOT=RESOURCE_DIR (may be repeated)",
          reader $ eitherReader parseSuitePkgSpec,
          option,
          long "suite-pkg",
          metavar "PNAME=ROOT=RESOURCE_DIR"
        ]
  diffSettingCoverageDirs <-
    many $
      directoryPathSetting
        [ help "Per-package coverage directory holding augmented/ and test-locations/ (may be repeated)",
          option,
          long "coverage-dir",
          metavar "DIR"
        ]
  diffSettingChildMemLimit <-
    optional $
      setting
        [ help "RTS heap cap for each mutation child, e.g. 4g",
          reader str,
          option,
          long "child-mem-limit",
          metavar "LIMIT"
        ]
  diffSettingMutationJobs <- mutationJobsParser
  diffSettingOutDir <-
    directoryPathSetting
      [ help "Output directory: where the driver writes report.txt, report.json, and per-suite *.log files (required)",
        option,
        long "out-dir"
      ]
  diffSettingFailFast <-
    yesNoSwitch
      [ help "Whether to abort on the first surviving or uncovered mutation",
        long "fail-fast",
        value defaultFailFast
      ]
  diffSettingDebug <- debugSwitch
  pure DiffSettings {..}

-- | The @--mutation-jobs@ option, shared by the @run@ and @diff@
-- subcommands: cap how many mutation children run concurrently.  Absent =
-- 'getNumCapabilities'.  Capping avoids the false kills that DB-backed
-- suites produce when their per-test resource (e.g. tmp-postgres) flakes
-- under full core-count contention.
mutationJobsParser :: Parser (Maybe Word)
mutationJobsParser =
  optional $
    setting
      [ help "Maximum number of mutation children to run concurrently (default: number of capabilities)",
        reader auto,
        option,
        long "mutation-jobs",
        metavar "INT"
      ]

-- | The @--debug@ switch, shared by the @run@ and @diff@ subcommands:
-- additionally print each mutation's full source diff as it is tested.
debugSwitch :: Parser Bool
debugSwitch =
  setting
    [ help "Print each mutation's full source diff as it is tested (off by default; concise progress is always shown)",
      switch True,
      long "debug",
      value False
    ]

-- | Parse the diff source.  Precedence: an explicit @--diff FILE@ wins, then
-- @--diff-stdin@, otherwise the default git merge-base computation against
-- @--base@ (defaulting to 'defaultBaseBranch').
diffSourceParser :: Parser DiffSource
diffSourceParser = do
  mFile <-
    optional $
      filePathSetting
        [ help "Read the unified diff from this file instead of computing it from git",
          option,
          long "diff",
          metavar "FILE"
        ]
  fromStdin <-
    setting
      [ help "Read the unified diff from standard input instead of computing it from git",
        switch True,
        long "diff-stdin",
        value False
      ]
  base <-
    setting
      [ help "Base branch to compute the merge-base diff against (default git mode)",
        reader str,
        option,
        long "base",
        metavar "BRANCH",
        value defaultBaseBranch
      ]
  pure $ case mFile of
    Just f -> DiffSourceFile f
    Nothing
      | fromStdin -> DiffSourceStdin
      | otherwise -> DiffSourceGitMergeBase base

-- | Which component kind to enumerate or install: Cabal @executables@ or
-- @test-suites@.
data ComponentKind = ComponentExecutables | ComponentTestSuites
  deriving (Show, Eq, Generic)

componentKindSetting :: Parser ComponentKind
componentKindSetting =
  setting
    [ help "Which component kind to operate on",
      reader $ eitherReader $ \case
        "executables" -> Right ComponentExecutables
        "test-suites" -> Right ComponentTestSuites
        s -> Left ("expected 'executables' or 'test-suites', got: " ++ s),
      argument,
      metavar "KIND"
    ]

absDirArgument :: String -> Parser (Path Abs Dir)
absDirArgument helpText =
  setting
    [ help helpText,
      reader $ eitherReader $ \s ->
        maybe (Left ("invalid absolute directory path: " ++ s)) Right (parseAbsDir s),
      argument,
      metavar "DIR"
    ]

pnameArgument :: Parser String
pnameArgument =
  setting
    [ help "Cabal package name; the driver resolves <pname>.cabal in the current working directory",
      reader str,
      argument,
      metavar "PNAME"
    ]

-- | What the driver should do this invocation.  Each constructor corresponds
-- to one subcommand.  The default (no subcommand) is 'DispatchRun'.
data Dispatch
  = -- | Run the coverage and mutation phases (the original driver flow).
    DispatchRun !MutationDriverSettings
  | -- | Print the component names of one kind in a cabal file, one per
    -- line.  Used by the Nix harness to enumerate executables and
    -- test-suites at build time.  The cabal file is looked up by
    -- 'Test.Syd.Mutation.Driver.Components.findCabalFile' relative to
    -- the driver's current working directory.
    DispatchListComponents !ComponentKind !String
  | -- | Install (copy) the built executables of one kind from
    -- @dist/build/<n>/<n>@ into the given output directory.  Used by the
    -- Nix harness in @postInstall@.  The cabal file is looked up by
    -- 'Test.Syd.Mutation.Driver.Components.findCabalFile' relative to
    -- the driver's current working directory.
    DispatchInstallComponents !ComponentKind !String !(Path Abs Dir)
  | -- | Check a @report.json@ for survivors and (optionally) uncovered
    -- mutations.  Exits non-zero on assertion failure.  On success,
    -- when 'Just' an output directory is given, symlink @report.txt@
    -- and @report.json@ from the report directory into the output
    -- directory.  This second job exists so the Nix harness's
    -- @assertMutationScore@ can be a single subcommand invocation
    -- rather than a Bash buildCommand with shell-level
    -- @mkdir -p $out; ln -s ...@.
    DispatchAssertScore
      -- | Whether to also fail on uncovered mutations.
      !Bool
      -- | Report directory containing @report.json@ and @report.txt@.
      !(Path Abs Dir)
      -- | Optional output directory to symlink the report files into.
      !(Maybe (Path Abs Dir))
  | -- | Run only the coverage phase, writing the augmented manifest.  This
    -- builds the cheap coverage cache the diff-scoped runner depends on,
    -- without the full mutation run.
    DispatchCoverage !CoverageSettings
  | -- | Run the mutation phase over only the subset of mutations implied by
    -- a diff, using the cached augmented manifest and cached per-suite test
    -- location listings.  No coverage phase; no compilation.
    DispatchDiff !DiffSettings
  deriving (Show, Eq, Generic)

dispatchParser :: Parser Dispatch
dispatchParser =
  commands
    [ command "run" "Run the coverage and mutation phases" $
        withoutConfig $
          DispatchRun <$> mutationDriverSettingsParser,
      command "list-components" "Print the component names of one kind in a cabal file" $
        withoutConfig $
          DispatchListComponents
            <$> componentKindSetting
            <*> pnameArgument,
      command "install-components" "Copy built executables to an install directory" $
        withoutConfig $
          DispatchInstallComponents
            <$> componentKindSetting
            <*> pnameArgument
            <*> absDirArgument "Output directory to copy executables into",
      command "assert-score" "Check a mutation-run report for survivors and (optionally) uncovered mutations" $
        withoutConfig $
          DispatchAssertScore
            <$> yesNoSwitch
              [ help "Also fail on uncovered mutations",
                long "assert-none-uncovered",
                value True
              ]
            <*> absDirArgument "Directory containing report.json and report.txt"
            <*> optional
              ( setting
                  [ help "Directory to symlink report.txt and report.json into on success",
                    reader $ eitherReader $ \s ->
                      maybe (Left ("invalid absolute directory path: " ++ s)) Right (parseAbsDir s),
                    option,
                    long "out-dir",
                    metavar "DIR"
                  ]
              ),
      command "coverage" "Run only the coverage phase and write the augmented manifest" $
        withoutConfig $
          DispatchCoverage <$> coverageSettingsParser,
      command "diff" "Run only the mutations implied by a diff, against cached coverage" $
        withoutConfig $
          DispatchDiff <$> diffSettingsParser,
      defaultCommand "run"
    ]

-- | Default coverage retry budget.  Set conservatively so a flaky coverage
-- child does not lose the entire run.
defaultCoverageRetry :: Word
defaultCoverageRetry = 3

-- | Default fail-fast.  True suits CI so a single survivor aborts the run;
-- flip to False locally for the full report.
defaultFailFast :: Bool
defaultFailFast = True

-- | Parse the top-level dispatch from argv only.  The driver no longer
-- reads environment variables or YAML config files; everything goes
-- through CLI flags.
getDispatch :: IO Dispatch
getDispatch =
  runParser
    version
    "Out-of-process mutation testing driver for sydtest"
    dispatchParser