heni-cli-0.1.0.0: app/Heni/Cli.hs
-- | Command application: parsing and presentation only; the library owns behavior.
module Heni.Cli (mainCli, selectDefinitions, exitCodeFor) where
import Control.Exception
import Control.Monad (forM_, unless)
import Data.Aeson hiding (Options)
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Version (showVersion)
import Paths_heni_cli (version)
import System.Environment (getArgs)
import System.Exit
import System.FilePath ((</>))
import System.IO
import Test.Heni
import Text.Read (readMaybe)
data Options = Options !Bool !String ![Text] ![Text] !(Maybe FilePath) !Int !Bool
defaults :: Options
defaults = Options False "auto" [] [] Nothing 0 False
mainCli :: IO ()
mainCli = do
args <- getArgs
case args of
["--version"] -> putStrLn ("heni " ++ showVersion version)
[] -> putStrLn help >> exitWith (ExitFailure 2)
_ | "--help" `elem` args -> putStrLn help
command : path : rest | command `elem` ["check", "list", "run", "report"] -> case parseOptions command defaults rest of
Left why -> hPutStrLn stderr why >> exitWith (ExitFailure 2)
Right options -> do
attempted <- try (dispatch command path options)
case attempted of
Right code -> exitCode code
Left (e :: SomeException) -> case fromException e :: Maybe AsyncException of
Just UserInterrupt -> hPutStrLn stderr "Interrupted; inspect the partial report or recover the journal." >> exitWith (ExitFailure 130)
Just ThreadKilled -> exitWith (ExitFailure 130)
_ -> hPutStrLn stderr (diagnostic e) >> exitWith (ExitFailure 2)
_ -> hPutStrLn stderr "Expected check, list, run, or report and a path. Use --help." >> exitWith (ExitFailure 2)
parseOptions :: String -> Options -> [String] -> Either String Options
parseOptions _ options [] = Right options
parseOptions command (Options _ color cases tags output jobs recover) ("--json" : rest) = parseOptions command (Options True color cases tags output jobs recover) rest
parseOptions command (Options json _ cases tags output jobs recover) ("--color" : color : rest)
| color `elem` ["auto", "always", "never"] = parseOptions command (Options json color cases tags output jobs recover) rest
parseOptions "run" (Options json color cases tags output jobs recover) ("--case" : ident : rest) = parseOptions "run" (Options json color (cases ++ [Text.pack ident]) tags output jobs recover) rest
parseOptions "run" (Options json color cases tags output jobs recover) ("--tag" : tag : rest) = parseOptions "run" (Options json color cases (tags ++ [Text.pack tag]) output jobs recover) rest
parseOptions "run" (Options json color cases tags _ jobs recover) ("--output" : path : rest) = parseOptions "run" (Options json color cases tags (Just path) jobs recover) rest
parseOptions "run" (Options json color cases tags output _ recover) ("--jobs" : value : rest) = case readMaybe value of
Just jobs | jobs > 0 && jobs <= 256 -> parseOptions "run" (Options json color cases tags output jobs recover) rest
_ -> Left "--jobs must be between 1 and 256"
parseOptions "report" (Options json color cases tags output jobs _) ("--recover" : rest) = parseOptions "report" (Options json color cases tags output jobs True) rest
parseOptions _ _ (unknown : _) = Left ("Unknown, incomplete, or unsupported option: " ++ unknown)
dispatch :: String -> FilePath -> Options -> IO Int
dispatch "report" path (Options json color _ _ _ _ recover) = do
stored <- (if recover then recoverReport path else readReport path) >>= either throwIO pure
if json then LBS.putStrLn (encode (reportJSON stored)) else case stored of StoredReport {result} -> render color result
pure 0
dispatch command path (Options json color cases tags output jobs _) = do
manifest <- readManifest path >>= either (throwIO . ConfigurationFailure) pure
let campaign = manifestCampaign manifest
definitions = campaignCases campaign
case command of
"list" -> do
if json
then LBS.putStrLn (encode (object ["schemaVersion" .= (1 :: Int), "cases" .= map caseDefinitionJSON definitions]))
else forM_ definitions $ \CaseDefinition {caseId, tags = caseTags, selector, expectation, description} ->
putStrLn (Text.unpack (caseIdText caseId) ++ " tags=" ++ show (Set.toAscList caseTags) ++ " selector=" ++ show selector ++ " expect=" ++ show expectation ++ "\n " ++ Text.unpack description)
pure 0
"check" -> do
checks <- checkManifest manifest >>= either (throwIO . ConfigurationFailure) pure
if json
then
LBS.putStrLn
( encode
( object
[ "schemaVersion" .= (1 :: Int),
"checksPerformed" .= (["static-validation", "command-availability", "isolated-edit-applicability"] :: [Text]),
"testsExecuted" .= False,
"cases" .= [object ["id" .= caseIdText caseId, "applicable" .= applicable, "diagnostics" .= diagnostics] | Applicability {caseId, applicable, diagnostics} <- checks]
]
)
)
else do
putStrLn "Checked structure, command availability and isolated edit applicability. Tests and discovery were not run."
forM_ checks $ \Applicability {caseId, applicable, diagnostics} -> putStrLn (Text.unpack (caseIdText caseId) ++ if applicable then ": applicable" else ": " ++ show diagnostics)
pure (if all (\Applicability {applicable} -> applicable) checks then 0 else 2)
"run" -> do
selected <- either (throwIO . ConfigurationFailure) pure (selectDefinitions cases tags definitions)
let directory = maybe (manifestSourceRoot manifest </> "mutation-results") id output
inheritedOptions = manifestRunOptions directory manifest
options =
inheritedOptions
{ selectedCases = Just selected,
workers = if jobs == 0 then workers inheritedOptions else jobs,
progress = \case CaseStarted i -> hPutStrLn stderr ("running " ++ Text.unpack (caseIdText i)); _ -> pure ()
}
result <- runCampaign options campaign
if json
then LBS.putStrLn (encode (object ["schemaVersion" .= (1 :: Int), "reportPath" .= (directory </> "report.json"), "selectionRule" .= String "union within filter kind, intersection across case/tag kinds", "result" .= runResultJSON result]))
else render color result >> putStrLn ("Evidence: " ++ directory </> "report.json")
pure (exitCodeFor result)
_ -> error "validated command dispatch"
-- | Union repeated values within a filter kind; intersect the case and tag sets.
-- Unknown explicit IDs and empty intersections are configuration errors.
selectDefinitions :: [Text] -> [Text] -> [CaseDefinition] -> Either ConfigError SelectedCases
selectDefinitions requested tags definitions = do
ids <- mapM mkCaseId requested
let known = [caseId | CaseDefinition {caseId} <- definitions]
unless (all (`elem` known) ids) (Left (ConfigError "case" "unknown requested case ID"))
mkSelectedCases
[ caseId
| CaseDefinition {caseId, tags = caseTags} <- definitions,
(null ids || caseId `elem` ids) && (null tags || not (Set.null (Set.intersection (Set.fromList tags) caseTags)))
]
-- | Infrastructure has precedence because RunStatus is library-owned.
exitCodeFor :: RunResult -> Int
exitCodeFor RunResult {status} = case status of RunPassed -> 0; RunFailed -> 1; RunUntrustworthy -> 2; RunInterrupted -> 130
exitCode :: Int -> IO ()
exitCode 0 = exitSuccess
exitCode n = exitWith (ExitFailure n)
render :: String -> RunResult -> IO ()
render color RunResult {results, status, score, failures} = do
terminal <- hIsTerminalDevice stdout
let colored = color == "always" || color == "auto" && terminal
paint text = if colored then "\ESC[" ++ (if status == RunPassed then "32" else "31") ++ "m" ++ text ++ "\ESC[0m" else text
counts = Map.fromListWith (+) [(show verdict, 1 :: Int) | CaseResult {verdict} <- results]
putStrLn (paint (show status) ++ " " ++ show (Map.toAscList counts))
forM_ results $ \CaseResult {caseId, verdict, reason, evidence = CaseEvidence {stages}} -> do
putStrLn (Text.unpack (caseIdText caseId) ++ ": " ++ show verdict ++ " — " ++ Text.unpack reason)
forM_ stages $ \StageEvidence {transcript} -> unless (Text.null transcript) (putStrLn (" witness: " ++ Text.unpack transcript))
forM_ score $ \(SemanticScore caught total complete) -> putStrLn ("Semantic detections: " ++ show caught ++ "/" ++ show total ++ if complete then "" else " (incomplete)")
forM_ failures (putStrLn . ("Run failure: " ++) . show)
help :: String
help =
unlines
[ "heni — deliberate mutation testing with attributable evidence",
"",
"Usage:",
" heni check MANIFEST [--json]",
" heni list MANIFEST [--json]",
" heni run MANIFEST [--case ID] [--tag TAG] [--output DIRECTORY] [--jobs N] [--json]",
" heni report REPORT.json [--json]",
" heni report DIRECTORY --recover [--json]",
"",
"Selection:",
" Repeated filters use union within each kind and intersection across case/tag kinds.",
" Unknown explicit IDs and empty selection fail. Manifest paths resolve from its location.",
" --output is an explicit caller-relative path; the default is SOURCE/mutation-results.",
"",
"Presentation:",
" --color auto|always|never Applies to human output only; redirected auto output has no ANSI.",
" --json JSON on stdout, progress and errors on stderr.",
" --help Show this help.",
" --version Show the source-package version without Git.",
"",
"Checks and exits:",
" check runs static/availability/applicability checks, never tests or discovery.",
" run: 0 met contract; 1 failed contract; 2 configuration/infrastructure; SIGINT 130.",
" report renders stored evidence only; recovery never resumes commands.",
" Runs require a new output directory. Inspect report.json or recover journal.jsonl after interruption."
]
diagnostic :: SomeException -> String
diagnostic e = case fromException e of
Just (ConfigurationFailure (ConfigError field message)) -> Text.unpack field ++ ": " ++ Text.unpack message
Nothing -> case fromException e of
Just (ReportError message) -> Text.unpack message
Nothing -> displayException e