gachi-cli-0.1.0.0: test/Main.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
-- | Module: Main
-- Description: Comprehensive specification and verification suite for gachi-cli
--
-- Validates:
-- 1. CLI option and flag parsing across subcommands and modes.
-- 2. Pure resolution of project specifications from Dhall files and CLI options.
-- 3. Headless workspace instantiation and filesystem materialization.
-- 4. Hermetic Git repository initialization and trailer governance (.githooks / .gitmessage).
-- 5. Execution modes ('FailOnExisting', 'SkipExisting', 'OverwriteExisting', '--dry-run').
module Main (main) where
import Control.Exception (SomeException, bracket, catch)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import Data.Time.Clock.POSIX (getPOSIXTime)
import Options.Applicative
( ParserResult(..)
, defaultPrefs
, execParserPure
)
import System.Directory
( createDirectoryIfMissing
, doesDirectoryExist
, doesFileExist
, executable
, getPermissions
, getTemporaryDirectory
, removeDirectoryRecursive
)
import System.Exit (ExitCode(..))
import System.FilePath ((</>), takeFileName)
import System.Process (readProcessWithExitCode)
import Test.Hspec
import Gachi.CLI
import Gachi.Schema.Dhall (exampleProjectDhall)
import Gachi.Schema.Project
( authorEmail
, authorName
, projectAuthor
, projectName
)
import Gachi.Scaffold (ScaffoldMode(..))
-- | Helper executing an action within an isolated temporary directory.
withTempDir :: String -> (FilePath -> IO a) -> IO a
withTempDir prefix action = do
base <- getTemporaryDirectory
t <- getPOSIXTime
let dirPath = base </> (prefix ++ "-" ++ show (round (t * 1000) :: Integer))
bracket
(createDirectoryIfMissing True dirPath >> pure dirPath)
(\p -> catch (removeDirectoryRecursive p) (\(_ :: SomeException) -> pure ()))
action
main :: IO ()
main = hspec $ do
describe "Gachi.CLI.Options (Option Parsing)" $ do
it "parses default 'init' with project name positional" $ do
let res = execParserPure defaultPrefs (commandParser "0.1.0.0") ["init", "my-project"]
case res of
Success (CmdInit opts) -> do
initProjectName opts `shouldBe` Just "my-project"
initConfigFile opts `shouldBe` Nothing
initMode opts `shouldBe` FailOnExisting
initNoGit opts `shouldBe` False
initDryRun opts `shouldBe` False
_ -> expectationFailure "Expected Success (CmdInit opts)"
it "parses 'init' with Dhall config file and target directory" $ do
let res = execParserPure defaultPrefs (commandParser "0.1.0.0")
["init", "-c", "custom.dhall", "-d", "/tmp/target", "-m", "overwrite", "--no-git"]
case res of
Success (CmdInit opts) -> do
initConfigFile opts `shouldBe` Just "custom.dhall"
initTargetDir opts `shouldBe` Just "/tmp/target"
initMode opts `shouldBe` OverwriteExisting
initNoGit opts `shouldBe` True
_ -> expectationFailure "Expected Success (CmdInit opts)"
it "parses 'init' with satellite package list and description" $ do
let res = execParserPure defaultPrefs (commandParser "0.1.0.0")
["init", "multi-pkg", "--packages", "pkg-core,pkg-api,pkg-cli", "--desc", "My API"]
case res of
Success (CmdInit opts) -> do
initProjectName opts `shouldBe` Just "multi-pkg"
initPackages opts `shouldBe` ["pkg-core", "pkg-api", "pkg-cli"]
initDescription opts `shouldBe` Just "My API"
_ -> expectationFailure "Expected Success (CmdInit opts)"
it "correctly parses mode strings" $ do
parseMode "fail" `shouldBe` Right FailOnExisting
parseMode "SKIP" `shouldBe` Right SkipExisting
parseMode "Overwrite" `shouldBe` Right OverwriteExisting
parseMode "invalid" `shouldSatisfy` (\case Left _ -> True; _ -> False)
it "parses version command" $ do
let res = execParserPure defaultPrefs (commandParser "0.1.0.0") ["version"]
case res of
Success CmdVersion -> pure ()
_ -> expectationFailure "Expected Success CmdVersion"
it "parses 'sync' subcommand with target, dry-run, and all flags" $ do
let res = execParserPure defaultPrefs (commandParser "0.1.0.0")
["sync", "-t", "/path/to/repo", "--dry-run", "-a"]
case res of
Success (CmdSync opts) -> do
syncTargetDir opts `shouldBe` Just "/path/to/repo"
syncDryRun opts `shouldBe` True
syncAll opts `shouldBe` True
syncRemotes opts `shouldBe` False
_ -> expectationFailure "Expected Success (CmdSync opts)"
it "parses 'sync' subcommand with --remotes / -r flag" $ do
let res = execParserPure defaultPrefs (commandParser "0.1.0.0")
["sync", "-t", "/path/to/repo", "-r"]
case res of
Success (CmdSync opts) -> do
syncTargetDir opts `shouldBe` Just "/path/to/repo"
syncRemotes opts `shouldBe` True
_ -> expectationFailure "Expected Success (CmdSync opts)"
it "parses 'doctor' subcommand with target, sheaf, and all flags" $ do
let res1 = execParserPure defaultPrefs (commandParser "0.1.0.0") ["doctor"]
case res1 of
Success (CmdDoctor opts) -> do
doctorTargetDir opts `shouldBe` Nothing
doctorSheaf opts `shouldBe` False
doctorAll opts `shouldBe` False
_ -> expectationFailure "Expected Success (CmdDoctor opts) for default doctor"
let res2 = execParserPure defaultPrefs (commandParser "0.1.0.0") ["doctor", "-t", "/path/to/repo", "-s", "-a"]
case res2 of
Success (CmdDoctor opts) -> do
doctorTargetDir opts `shouldBe` Just "/path/to/repo"
doctorSheaf opts `shouldBe` True
doctorAll opts `shouldBe` True
_ -> expectationFailure "Expected Success (CmdDoctor opts) with flags"
it "parses 'retrofit' subcommand with target, dry-run, and markdown flags" $ do
let res1 = execParserPure defaultPrefs (commandParser "0.1.0.0") ["retrofit"]
case res1 of
Success (CmdRetrofit opts) -> do
retrofitTargetDir opts `shouldBe` Nothing
retrofitDryRun opts `shouldBe` False
retrofitMarkdown opts `shouldBe` False
_ -> expectationFailure "Expected Success (CmdRetrofit opts) for default retrofit"
let res2 = execParserPure defaultPrefs (commandParser "0.1.0.0")
["retrofit", "-t", "/path/to/legacy", "--dry-run", "-m"]
case res2 of
Success (CmdRetrofit opts) -> do
retrofitTargetDir opts `shouldBe` Just "/path/to/legacy"
retrofitDryRun opts `shouldBe` True
retrofitMarkdown opts `shouldBe` True
_ -> expectationFailure "Expected Success (CmdRetrofit opts) with flags"
describe "Gachi.CLI.Init (Project Resolution)" $ do
it "resolves default project when no config is provided" $ do
let opts = InitOptions
{ initProjectName = Just "test-slug"
, initConfigFile = Nothing
, initTargetDir = Nothing
, initMode = FailOnExisting
, initAuthorName = Just "Alice Smith"
, initAuthorEmail = Just "alice@example.com"
, initDescription = Just "Test project"
, initPackages = ["test-slug-core"]
, initNoGit = True
, initDryRun = False
, initInteractive = False
}
res <- resolveProject opts
case res of
Right (proj, targetDir) -> do
projectName proj `shouldBe` "test-slug"
authorName (projectAuthor proj) `shouldBe` "Alice Smith"
authorEmail (projectAuthor proj) `shouldBe` "alice@example.com"
targetDir `shouldBe` "test-slug"
Left err -> expectationFailure (T.unpack err)
it "resolves project from Dhall configuration file" $ do
withTempDir "gachi-dhall-resolve" $ \tmpDir -> do
let dhallPath = tmpDir </> "project.dhall"
TIO.writeFile dhallPath exampleProjectDhall
let opts = InitOptions
{ initProjectName = Nothing
, initConfigFile = Just dhallPath
, initTargetDir = Just (tmpDir </> "target")
, initMode = FailOnExisting
, initAuthorName = Nothing
, initAuthorEmail = Nothing
, initDescription = Nothing
, initPackages = []
, initNoGit = True
, initDryRun = False
, initInteractive = False
}
res <- resolveProject opts
case res of
Right (proj, targetDir) -> do
projectName proj `shouldBe` "gongju"
targetDir `shouldBe` (tmpDir </> "target")
Left err -> expectationFailure (T.unpack err)
describe "Gachi.CLI.Init (Filesystem Materialization & Git Setup)" $ do
it "performs dry-run without writing any files to target directory" $ do
withTempDir "gachi-dry-run" $ \tmpDir -> do
let targetDir = tmpDir </> "uncreated-dir"
opts = InitOptions
{ initProjectName = Just "dry-project"
, initConfigFile = Nothing
, initTargetDir = Just targetDir
, initMode = FailOnExisting
, initAuthorName = Nothing
, initAuthorEmail = Nothing
, initDescription = Nothing
, initPackages = []
, initNoGit = True
, initDryRun = True
, initInteractive = False
}
exitCode <- runInit opts
exitCode `shouldBe` ExitSuccess
exists <- doesDirectoryExist targetDir
exists `shouldBe` False
it "initializes full standardized workspace with Git governance" $ do
withTempDir "gachi-init-full" $ \tmpDir -> do
let targetDir = tmpDir </> "demo-project"
opts = InitOptions
{ initProjectName = Just "demo-project"
, initConfigFile = Nothing
, initTargetDir = Just targetDir
, initMode = FailOnExisting
, initAuthorName = Just "Nadia Yvette Chambers"
, initAuthorEmail = Just "nadia.yvette.chambers@ik.me"
, initDescription = Just "Full demonstration workspace"
, initPackages = ["demo-project-core"]
, initNoGit = False
, initDryRun = False
, initInteractive = False
}
exitCode <- runInit opts
exitCode `shouldBe` ExitSuccess
-- Verify files created
readmeExists <- doesFileExist (targetDir </> "README.md")
readmeExists `shouldBe` True
verifyScriptExists <- doesFileExist (targetDir </> "bin/demo-project-verify")
verifyScriptExists `shouldBe` True
cabalExists <- doesFileExist (targetDir </> "cabal.project")
cabalExists `shouldBe` True
-- Verify Git repository and governance configuration
gitDirExists <- doesDirectoryExist (targetDir </> ".git")
gitDirExists `shouldBe` True
(c1, hooksOut, _) <- readProcessWithExitCode "git" ["-C", targetDir, "config", "--get", "core.hooksPath"] ""
c1 `shouldBe` ExitSuccess
T.strip (T.pack hooksOut) `shouldBe` ".githooks"
(c2, tmplOut, _) <- readProcessWithExitCode "git" ["-C", targetDir, "config", "--get", "commit.template"] ""
c2 `shouldBe` ExitSuccess
T.strip (T.pack tmplOut) `shouldBe` ".gitmessage"
it "respects FailOnExisting mode on pre-existing files" $ do
withTempDir "gachi-fail-mode" $ \tmpDir -> do
let targetDir = tmpDir </> "existing-proj"
opts = InitOptions
{ initProjectName = Just "existing-proj"
, initConfigFile = Nothing
, initTargetDir = Just targetDir
, initMode = FailOnExisting
, initAuthorName = Nothing
, initAuthorEmail = Nothing
, initDescription = Nothing
, initPackages = []
, initNoGit = True
, initDryRun = False
, initInteractive = False
}
exit1 <- runInit opts
exit1 `shouldBe` ExitSuccess
-- Second run with FailOnExisting should fail gracefully
exit2 <- runInit opts
exit2 `shouldBe` ExitFailure 1
describe "Gachi.CLI.Sync (Harmonic Synchronization Command)" $ do
it "executes dry-run synchronization without modifying files" $ do
withTempDir "gachi-cli-sync-dry" $ \tmpDir -> do
let targetDir = tmpDir </> "sync-proj"
initOpts = InitOptions
{ initProjectName = Just "sync-proj"
, initConfigFile = Nothing
, initTargetDir = Just targetDir
, initMode = FailOnExisting
, initAuthorName = Nothing
, initAuthorEmail = Nothing
, initDescription = Nothing
, initPackages = []
, initNoGit = True
, initDryRun = False
, initInteractive = False
}
initCode <- runInit initOpts
initCode `shouldBe` ExitSuccess
let syncOpts = SyncOptions
{ syncTargetDir = Just targetDir
, syncDryRun = True
, syncAll = False
, syncRemotes = False
}
syncCode <- runSync syncOpts
syncCode `shouldBe` ExitSuccess
it "synchronizes and repairs configuration in an initialized workspace" $ do
withTempDir "gachi-cli-sync-apply" $ \tmpDir -> do
let targetDir = tmpDir </> "repair-proj"
initOpts = InitOptions
{ initProjectName = Just "repair-proj"
, initConfigFile = Nothing
, initTargetDir = Just targetDir
, initMode = FailOnExisting
, initAuthorName = Nothing
, initAuthorEmail = Nothing
, initDescription = Nothing
, initPackages = []
, initNoGit = True
, initDryRun = False
, initInteractive = False
}
initCode <- runInit initOpts
initCode `shouldBe` ExitSuccess
-- Mutate .hlint.yaml to cause drift
let hlintPath = targetDir </> ".hlint.yaml"
TIO.writeFile hlintPath "# Drifted HLint configuration\n"
-- Apply synchronization to repair drift
let syncOpts = SyncOptions
{ syncTargetDir = Just targetDir
, syncDryRun = False
, syncAll = False
, syncRemotes = False
}
syncCode1 <- runSync syncOpts
syncCode1 `shouldBe` ExitSuccess
-- Verify file content was repaired
repairedContent <- TIO.readFile hlintPath
repairedContent `shouldSatisfy` (\t -> "Ecosystem HLint Configuration" `T.isInfixOf` t)
-- Re-run sync to verify CLI idempotency
syncCode2 <- runSync syncOpts
syncCode2 `shouldBe` ExitSuccess
it "executes ecosystem-wide synchronization with --all" $ do
withTempDir "gachi-cli-sync-all" $ \tmpDir -> do
let repoA = tmpDir </> "repo-one"
repoB = tmpDir </> "repo-two"
makeOpts dir = InitOptions
{ initProjectName = Just (T.pack (takeFileName dir))
, initConfigFile = Nothing
, initTargetDir = Just dir
, initMode = FailOnExisting
, initAuthorName = Nothing
, initAuthorEmail = Nothing
, initDescription = Nothing
, initPackages = []
, initNoGit = True
, initDryRun = False
, initInteractive = False
}
_ <- runInit (makeOpts repoA)
_ <- runInit (makeOpts repoB)
let allOpts = SyncOptions
{ syncTargetDir = Just tmpDir
, syncDryRun = True
, syncAll = True
, syncRemotes = False
}
allCode <- runSync allOpts
allCode `shouldBe` ExitSuccess
it "executes remote topology audit and synchronization with --remotes" $ do
withTempDir "gachi-cli-sync-remotes" $ \tmpDir -> do
let targetDir = tmpDir </> "remote-proj"
initOpts = InitOptions
{ initProjectName = Just "remote-proj"
, initConfigFile = Nothing
, initTargetDir = Just targetDir
, initMode = FailOnExisting
, initAuthorName = Nothing
, initAuthorEmail = Nothing
, initDescription = Nothing
, initPackages = []
, initNoGit = False
, initDryRun = False
, initInteractive = False
}
initCode <- runInit initOpts
initCode `shouldBe` ExitSuccess
-- Audit & sync remotes via CLI
let syncOpts = SyncOptions
{ syncTargetDir = Just targetDir
, syncDryRun = False
, syncAll = False
, syncRemotes = True
}
syncCode <- runSync syncOpts
syncCode `shouldBe` ExitSuccess
-- Verify git remote output contains canonical remotes
(cGit, remotesOut, _) <- readProcessWithExitCode "git" ["-C", targetDir, "remote"] ""
cGit `shouldBe` ExitSuccess
let rList = words remotesOut
rList `shouldContain` ["github"]
rList `shouldContain` ["framagit"]
rList `shouldContain` ["disroot"]
describe "Gachi.CLI.Doctor (Diagnostics & Sheaf Auditing)" $ do
it "audits ambient toolchain binaries successfully" $ do
toolchainOk <- checkToolchainHealth
toolchainOk `shouldBe` True
it "runs sheaf coherence audit via doctor --sheaf" $ do
let docOpts = DoctorOptions
{ doctorTargetDir = Nothing
, doctorSheaf = True
, doctorAll = False
}
exitCode <- runDoctor docOpts
exitCode `shouldBe` ExitSuccess
it "runs general doctor on an initialized workspace successfully" $ do
withTempDir "gachi-cli-doctor" $ \tmpDir -> do
let targetDir = tmpDir </> "doctor-proj"
initOpts = InitOptions
{ initProjectName = Just "doctor-proj"
, initConfigFile = Nothing
, initTargetDir = Just targetDir
, initMode = FailOnExisting
, initAuthorName = Just "Doctor Tester"
, initAuthorEmail = Just "doctor@example.com"
, initDescription = Just "Test workspace for doctor"
, initPackages = []
, initNoGit = False
, initDryRun = False
, initInteractive = False
}
initCode <- runInit initOpts
initCode `shouldBe` ExitSuccess
let docOpts = DoctorOptions
{ doctorTargetDir = Just targetDir
, doctorSheaf = False
, doctorAll = False
}
-- Pre-sync doctor detects missing CI workflow obstruction
preSyncExit <- runDoctor docOpts
preSyncExit `shouldBe` ExitFailure 1
-- Sync repository to install standardized CI workflows
let syncOpts = SyncOptions
{ syncTargetDir = Just targetDir
, syncDryRun = False
, syncAll = False
, syncRemotes = False
}
syncCode <- runSync syncOpts
syncCode `shouldBe` ExitSuccess
-- Post-sync doctor confirms harmonic equilibrium (10/10 contracts coherent)
postSyncExit <- runDoctor docOpts
postSyncExit `shouldBe` ExitSuccess
describe "Gachi.CLI.Retrofit (Diagnostic Inspection Execution)" $ do
it "fails gracefully with ExitFailure 1 when target directory does not exist" $ do
let opts = RetrofitOptions
{ retrofitTargetDir = Just "/nonexistent/directory/path/xyz"
, retrofitDryRun = True
, retrofitMarkdown = False
}
exitCode <- runRetrofit opts
exitCode `shouldBe` ExitFailure 1
it "runs diagnostic inspection on an initialized workspace successfully" $ withTempDir "gachi-cli-retrofit" $ \baseDir -> do
let targetDir = baseDir </> "retrofit-proj"
initOpts = InitOptions
{ initProjectName = Just "retrofit-proj"
, initConfigFile = Nothing
, initTargetDir = Just targetDir
, initMode = FailOnExisting
, initAuthorName = Just "Nadia Yvette Chambers"
, initAuthorEmail = Just "nadia.yvette.chambers@ik.me"
, initDescription = Just "Retrofit Target Testing"
, initPackages = ["core"]
, initNoGit = False
, initDryRun = False
, initInteractive = False
}
initCode <- runInit initOpts
initCode `shouldBe` ExitSuccess
let retroOpts = RetrofitOptions
{ retrofitTargetDir = Just targetDir
, retrofitDryRun = True
, retrofitMarkdown = False
}
retroCode <- runRetrofit retroOpts
retroCode `shouldBe` ExitSuccess
let mdOpts = RetrofitOptions
{ retrofitTargetDir = Just targetDir
, retrofitDryRun = False
, retrofitMarkdown = True
}
mdCode <- runRetrofit mdOpts
mdCode `shouldBe` ExitSuccess
it "applies non-destructive skeleton overlay to an uninitialized legacy checkout" $ withTempDir "gachi-cli-retrofit-live" $ \baseDir -> do
let targetDir = baseDir </> "legacy-app"
createDirectoryIfMissing True (targetDir </> "src")
let origCode = "module Lib (greet) where\ngreet :: String -> String\ngreet name = \"Hello, \" ++ name\n"
TIO.writeFile (targetDir </> "src" </> "Lib.hs") origCode
TIO.writeFile (targetDir </> "legacy-app.cabal") "cabal-version: 3.0\nname: legacy-app\nversion: 0.1.0.0\n"
let retroOpts = RetrofitOptions
{ retrofitTargetDir = Just targetDir
, retrofitDryRun = False
, retrofitMarkdown = False
}
retroCode <- runRetrofit retroOpts
retroCode `shouldBe` ExitSuccess
-- Invariant 1: Verification runner materialized with executable permissions
let verifyScript = targetDir </> "bin" </> "legacy-app-verify"
doesFileExist verifyScript `shouldReturn` True
p <- getPermissions verifyScript
executable p `shouldBe` True
-- Invariant 2: Standard documentation hierarchy and agent advisories installed
doesFileExist (targetDir </> "docs" </> "INDEX.md") `shouldReturn` True
doesFileExist (targetDir </> "AGENTS.md") `shouldReturn` True
doesFileExist (targetDir </> ".githooks" </> "commit-msg") `shouldReturn` True
-- Invariant 3: Git repository initialized
doesDirectoryExist (targetDir </> ".git") `shouldReturn` True
-- Invariant 4: Domain source files preserved completely untouched
codeAfter <- TIO.readFile (targetDir </> "src" </> "Lib.hs")
codeAfter `shouldBe` origCode
it "evaluates progressive quality gates during retrofit execution" $ withTempDir "gachi-cli-retrofit-gates" $ \baseDir -> do
let targetDir = baseDir </> "quality-app"
createDirectoryIfMissing True (targetDir </> "src")
TIO.writeFile (targetDir </> "src" </> "Main.hs") "module Main where\nmain = pure ()\n"
TIO.writeFile (targetDir </> "quality-app.cabal") "cabal-version: 3.0\nname: quality-app\nversion: 0.1.0.0\n"
let retroOpts = RetrofitOptions
{ retrofitTargetDir = Just targetDir
, retrofitDryRun = False
, retrofitMarkdown = False
}
retroCode <- runRetrofit retroOpts
retroCode `shouldBe` ExitSuccess
-- Verify that overlay was applied and quality gates evaluated cleanly
doesFileExist (targetDir </> "bin" </> "quality-app-verify") `shouldReturn` True
doesFileExist (targetDir </> "PLAN.md") `shouldReturn` True
doesFileExist (targetDir </> "tagref.yml") `shouldReturn` True