packages feed

gachi-cli-0.1.0.0: src/Gachi/CLI/Init.hs

{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | Module: Gachi.CLI.Init
-- Description: Core execution engine and interactive wizard for 'gachi init'
--
-- This module implements the operational workflow for 'gachi init', providing
-- turnkey generation of standardized Haskell repositories from Dhall schemas,
-- command-line options, or interactive terminal prompts.
--
-- = Architectural Context & Invariants
--
-- {-
-- Note [CLI Ergonomics and Non-Destructive Initialization]
-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-- [tag:note-cli-init-ergonomics]
--
-- The `gachi init` command serves as the turnkey front door for the entire ecosystem.
-- To satisfy the ecosystem standardization matrix (Note [Ecosystem Standardization Invariants]
-- [ref:note-ecosystem-standardization]), the CLI must uphold four foundational ergonomic invariants:
--
-- 1. Dual-Path Specification:
--    The generator must support both declarative, machine-readable specifications via
--    Dhall configuration files (`--config project.dhall`), and zero-overhead CLI flags /
--    interactive wizardry for rapid prototyping.
--
-- 2. Non-Destructive Safety:
--    File materialization is governed by explicit execution modes ('FailOnExisting',
--    'SkipExisting', 'OverwriteExisting'). The default mode is 'FailOnExisting' to prevent
--    accidental data loss. Dry-run mode (`--dry-run`) previews all file paths, sizes,
--    and permissions without touching the filesystem.
--
-- 3. Turnkey Git Governance:
--    Immediately upon materializing files, `gachi init` executes hermetic Git initialization
--    unless explicitly disabled via `--no-git`. It pins `core.hooksPath` to `.githooks` and
--    `commit.template` to `.gitmessage`, enforcing Sarutahiko RFC 2822 commit trailer
--    discipline from commit zero.
--
-- 4. Five-Gate Verification Readiness:
--    Any workspace instantiated by `gachi init` must immediately pass all five physical
--    verification gates (`./bin/<project>-verify`) without requiring manual fixes or edits.
-- -}
--
-- = Worked Example
--
-- >>> import Gachi.CLI.Options
-- >>> import Gachi.Scaffold
-- >>> let opts = InitOptions (Just "sample") Nothing (Just "/tmp/sample") FailOnExisting Nothing Nothing Nothing [] True True False
-- >>> exitCode <- runInit opts
-- >>> exitCode == ExitSuccess
-- True
module Gachi.CLI.Init
  ( -- * High-Level Execution Entry Point
    runInit

    -- * Project Resolution Operations
  , resolveProject
  , detectGitAuthor
  , promptUser

    -- * Filesystem Materialization & Git Setup
  , executeScaffolding
  , initializeGitRepository
  ) where

import Control.Exception (SomeException, catch, try)
import Control.Monad (forM_, unless, when)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import System.Directory
  ( createDirectoryIfMissing
  , doesDirectoryExist
  , doesFileExist
  )
import System.Exit (ExitCode(..))
import System.FilePath ((</>))
import System.IO (hFlush, stderr, stdout)
import System.Process (readProcessWithExitCode)

import Gachi.CLI.Options (InitOptions(..))
import Gachi.Schema.Dhall (decodeProjectFile)
import Gachi.Schema.Project
  ( Author(..)
  , PackageSpec(..)
  , Project(..)
  , authorEmail
  , authorName
  , defaultProject
  , projectAuthor
  , projectDescription
  , projectName
  , projectPackages
  )
import Gachi.Scaffold
  ( FileEntry(..)
  , ScaffoldError(..)
  , ScaffoldResult(..)
  , generateAndWriteScaffold
  , generateProjectScaffold
  )

--------------------------------------------------------------------------------
-- 1. Main Subcommand Dispatcher
--------------------------------------------------------------------------------

-- | Executes the complete 'gachi init' lifecycle.
--
-- Dispatches across Dhall configuration decoding, interactive terminal wizardry,
-- filesystem materialization, and hermetic Git repository setup.
--
-- Returns 'ExitSuccess' upon successful completion, or 'ExitFailure 1' on error.
runInit :: InitOptions -> IO ExitCode
runInit opts = do
  res <- resolveProject opts
  case res of
    Left err -> do
      TIO.hPutStrLn stderr ("[-] Error: " <> err)
      pure (ExitFailure 1)
    Right (proj, targetDir) -> do
      handleScaffold proj targetDir opts

-- | Encapsulates scaffolding execution and exception handling.
--
-- Catches known 'ScaffoldError' exceptions ('DestinationExists' and 'FileWriteFailure')
-- and renders user-friendly diagnostic messages to stderr rather than crashing with an
-- unhandled runtime exception stack trace.
--
-- Returns 'ExitFailure 1' on error, or 'ExitSuccess' upon complete materialization.
handleScaffold :: Project -> FilePath -> InitOptions -> IO ExitCode
handleScaffold proj targetDir opts =
  executeScaffolding proj targetDir opts `catch` handler
  where
    handler :: ScaffoldError -> IO ExitCode
    handler (DestinationExists path) = do
      TIO.hPutStrLn stderr
        ( "[-] Error: Target file or directory already exists: "
       <> T.pack path
       <> "\n    Use '--mode overwrite' or '--mode skip' to permit writing to existing trees."
        )
      pure (ExitFailure 1)
    handler (FileWriteFailure path msg) = do
      TIO.hPutStrLn stderr
        ( "[-] Filesystem I/O Error at "
       <> T.pack path
       <> ": "
       <> T.pack msg
        )
      pure (ExitFailure 1)

--------------------------------------------------------------------------------
-- 2. Project Specification Resolution
--------------------------------------------------------------------------------

-- | Resolves a strongly-typed 'Project' specification and target destination directory.
--
-- Evaluates initialization configuration across three strictly prioritized channels:
--
-- 1. __Declarative Dhall File (@--config FILE@):__
--    Evaluates a user-supplied Dhall specification file on disk via 'decodeProjectFile'.
--    Relative schema imports are resolved hermetically relative to the file.
--    Any command-line flags provided simultaneously (@--author-name@, @--desc@, etc.)
--    are overlaid as overrides onto the resulting 'Project'.
--
-- 2. __Interactive Wizard (@--interactive@ / @-i@):__
--    Launches a friendly interactive terminal dialogue prompting for missing or
--    customized parameters, using ambient Git defaults as baseline suggestions.
--
-- 3. __CLI Flags & Ambient Defaults:__
--    When neither a configuration file nor interactive mode is specified, synthesizes
--    a canonical 'Project' using 'defaultProject', reading ambient Git author metadata
--    and applying CLI overrides.
--
-- Returns 'Left error' on missing configuration files or decoding failure, or
-- 'Right (project, targetDirectory)' ready for filesystem materialization.
resolveProject :: InitOptions -> IO (Either Text (Project, FilePath))
resolveProject opts = case initConfigFile opts of
  Just cfgPath -> do
    exists <- doesFileExist cfgPath
    if not exists
      then pure (Left ("Dhall configuration file not found: " <> T.pack cfgPath))
      else do
        try (decodeProjectFile cfgPath) >>= \case
          Left (err :: SomeException) -> pure (Left ("Failed to decode Dhall specification: " <> T.show err))
          Right baseProj -> do
            let proj = applyCliOverrides opts baseProj
                targetDir = case initTargetDir opts of
                  Just d  -> d
                  Nothing -> T.unpack (projectName proj)
            pure (Right (proj, targetDir))
  Nothing
    | initInteractive opts -> do
        (proj, targetDir) <- runInteractiveWizard opts
        pure (Right (proj, targetDir))
    | otherwise -> do
        gitAuthor <- detectGitAuthor
        let pName = fromMaybe "my-project" (initProjectName opts)
            baseProj = defaultProject pName gitAuthor
            proj = applyCliOverrides opts baseProj
            targetDir = case initTargetDir opts of
              Just d  -> d
              Nothing -> T.unpack pName
        pure (Right (proj, targetDir))

-- | Overlays optional CLI command-line flags onto an existing 'Project' record.
--
-- Merges CLI flag inputs into the baseline specification according to precedence:
-- * 'initProjectName': Updates canonical project slug 'name' if provided.
-- * 'initDescription': Overrides 'description' if specified.
-- * 'initAuthorName' / 'initAuthorEmail': Overlays contact fields onto 'author'.
-- * 'initPackages': Replaces package list with new 'PackageSpec' records when non-empty.
--
-- Preserves all non-overridden fields (such as pinned toolchain versions, licenses,
-- and git remote mirrors).
applyCliOverrides :: InitOptions -> Project -> Project
applyCliOverrides opts proj =
  let pName = fromMaybe (projectName proj) (initProjectName opts)
      pDesc = fromMaybe (projectDescription proj) (initDescription opts)
      curAuth = projectAuthor proj
      aName = fromMaybe (authorName curAuth) (initAuthorName opts)
      aEmail = fromMaybe (authorEmail curAuth) (initAuthorEmail opts)
      auth = Author { name = aName, email = aEmail }
      pkgs = case initPackages opts of
        [] -> projectPackages proj
        ps -> [ PackageSpec p ("Domain package for " <> p) [] | p <- ps ]
  in proj
       { name = pName
       , description = pDesc
       , author = auth
       , packages = pkgs
       }

-- | Inspects ambient Git user configuration to auto-populate author metadata.
--
-- Queries @git config --get user.name@ and @git config --get user.email@ via
-- 'readProcessWithExitCode'. If either call fails (for example, when Git is not
-- installed or no global Git config exists), falls back to canonical ecosystem
-- defaults:
--
-- * Name: @"Nadia Yvette Chambers"@
-- * Email: @"nadia.yvette.chambers@ik.me"@
--
-- This ensures that automated CI environments and container builds always succeed
-- with reproducible author metadata.
detectGitAuthor :: IO Author
detectGitAuthor = do
  (cName, oName, _) <- readProcessWithExitCode "git" ["config", "--get", "user.name"] ""
    `catch` (\(_ :: SomeException) -> pure (ExitFailure 1, "", ""))
  (cEmail, oEmail, _) <- readProcessWithExitCode "git" ["config", "--get", "user.email"] ""
    `catch` (\(_ :: SomeException) -> pure (ExitFailure 1, "", ""))
  let nameTrimmed = T.strip (T.pack oName)
      emailTrimmed = T.strip (T.pack oEmail)
      authName = if cName == ExitSuccess && not (T.null nameTrimmed)
                   then nameTrimmed
                   else "Nadia Yvette Chambers"
      authEmail = if cEmail == ExitSuccess && not (T.null emailTrimmed)
                    then emailTrimmed
                    else "nadia.yvette.chambers@ik.me"
  pure Author { name = authName, email = authEmail }

--------------------------------------------------------------------------------
-- 3. Interactive Wizard
--------------------------------------------------------------------------------

-- | Interactive terminal prompt wizard gathering project initialization parameters.
runInteractiveWizard :: InitOptions -> IO (Project, FilePath)
runInteractiveWizard opts = do
  TIO.putStrLn "================================================================================"
  TIO.putStrLn "  gachi init — Interactive Workspace Scaffolding Wizard"
  TIO.putStrLn "================================================================================"
  defaultAuth <- detectGitAuthor

  let defaultName = fromMaybe "my-project" (initProjectName opts)
  pName <- promptUser "Project identifier slug" defaultName

  let defaultDesc = fromMaybe "Standardized multi-package Haskell workspace" (initDescription opts)
  pDesc <- promptUser "Project synopsis" defaultDesc

  let defaultAName = fromMaybe (authorName defaultAuth) (initAuthorName opts)
  aName <- promptUser "Author full name" defaultAName

  let defaultAEmail = fromMaybe (authorEmail defaultAuth) (initAuthorEmail opts)
  aEmail <- promptUser "Author email address" defaultAEmail

  let defaultPkgs = case initPackages opts of
        [] -> pName <> "-core"
        ps -> T.intercalate "," ps
  pkgsRaw <- promptUser "Packages to scaffold (comma-separated)" defaultPkgs
  let pkgNames = filter (not . T.null) (map T.strip (T.splitOn "," pkgsRaw))
      pkgSpecs = [ PackageSpec p ("Domain implementation for " <> p) [] | p <- pkgNames ]

  let defaultTarget = maybe pName T.pack (initTargetDir opts)
  targetRaw <- promptUser "Destination directory" defaultTarget

  let proj = Project
        { name = pName
        , description = pDesc
        , license = "EUPL-1.2"
        , author = Author { name = aName, email = aEmail }
        , ghcVersion = "9.14.1"
        , cabalVersion = "3.18"
        , packages = pkgSpecs
        , remotes = []
        }
  pure (proj, T.unpack targetRaw)

-- | Prompts the user on stdout with a default value fallback.
promptUser :: Text -> Text -> IO Text
promptUser label defVal = do
  TIO.putStr (label <> " [" <> defVal <> "]: ")
  hFlush stdout
  line <- TIO.getLine
  let trimmed = T.strip line
  pure (if T.null trimmed then defVal else trimmed)

--------------------------------------------------------------------------------
-- 4. Scaffolding & Filesystem Materialization
--------------------------------------------------------------------------------

-- | Materializes project assets or prints dry-run execution preview.
--
-- Evaluates execution branching:
-- * __Dry-Run Mode ('initDryRun' is True):__
--   Presents an in-memory preview of all planned files, their sizes in characters,
--   and executable permission flags without performing any disk mutations.
-- * __Materialization Mode ('initDryRun' is False):__
--   Creates the destination directory tree, writes files according to 'initMode'
--   ('FailOnExisting', 'SkipExisting', or 'OverwriteExisting'), initializes Git
--   governance unless disabled via 'initNoGit', and outputs the quick-start guide.
--
-- Returns 'ExitSuccess' upon successful execution, or raises 'ScaffoldError'.
executeScaffolding :: Project -> FilePath -> InitOptions -> IO ExitCode
executeScaffolding proj targetDir opts
  | initDryRun opts = do
      let entries = generateProjectScaffold proj
      TIO.putStrLn "--------------------------------------------------------------------------------"
      TIO.putStrLn ("DRY RUN: Simulating workspace generation for '" <> projectName proj <> "'")
      TIO.putStrLn ("Target directory: " <> T.pack targetDir)
      TIO.putStrLn "Planned file materialization:"
      forM_ entries \e -> do
        let execMark = if entryExecutable e then " [executable]" else ""
            size = T.show (T.length (entryContent e))
        TIO.putStrLn ("  - " <> T.pack (entryPath e) <> " (" <> size <> " chars)" <> execMark)
      TIO.putStrLn ("Total: " <> T.show (length entries) <> " files planned.")
      TIO.putStrLn "--------------------------------------------------------------------------------"
      pure ExitSuccess
  | otherwise = do
      createDirectoryIfMissing True targetDir
      res <- generateAndWriteScaffold targetDir (initMode opts) proj
      TIO.putStrLn ("[+] Successfully materialized " <> T.show (length (writtenFiles res)) <> " files in " <> T.pack targetDir)
      unless (null (skippedFiles res)) $ do
        TIO.putStrLn ("[!] Skipped " <> T.show (length (skippedFiles res)) <> " pre-existing files (mode: SkipExisting)")

      unlessNoGit (initNoGit opts) $ do
        initializeGitRepository targetDir

      printNextSteps proj targetDir
      pure ExitSuccess
  where
    unlessNoGit True _    = pure ()
    unlessNoGit False act = act

-- | Configures canonical Git governance (.githooks and .gitmessage) within target directory.
--
-- Executes hermetic Git setup steps:
-- 1. Checks if a @.git@ directory exists; if not, invokes @git init <targetDir>@.
-- 2. Sets local repository configuration @core.hooksPath = .githooks@ to activate
--    the Sarutahiko RFC 2822 commit message hook and pre-commit validation hook.
-- 3. Sets @commit.template = .gitmessage@ to standardize commit metadata prompts.
--
-- Silently handles failures if the @git@ executable is absent, allowing offline
-- workspace bootstrapping to proceed gracefully.
initializeGitRepository :: FilePath -> IO ()
initializeGitRepository targetDir = do
  let dotGit = targetDir </> ".git"
  gitExists <- doesDirectoryExist dotGit
  unless gitExists $ do
    (cInit, _, _) <- readProcessWithExitCode "git" ["init", targetDir] ""
      `catch` (\(_ :: SomeException) -> pure (ExitFailure 1, "", ""))
    when (cInit == ExitSuccess) $ do
      TIO.putStrLn "  [+] Initialized new Git repository."

  -- Configure Git hooks and commit template
  (cHooks, _, _) <- readProcessWithExitCode "git" ["-C", targetDir, "config", "core.hooksPath", ".githooks"] ""
    `catch` (\(_ :: SomeException) -> pure (ExitFailure 1, "", ""))
  (cTmpl, _, _) <- readProcessWithExitCode "git" ["-C", targetDir, "config", "commit.template", ".gitmessage"] ""
    `catch` (\(_ :: SomeException) -> pure (ExitFailure 1, "", ""))

  when (cHooks == ExitSuccess && cTmpl == ExitSuccess) $ do
    TIO.putStrLn "  [+] Configured Git governance (.githooks and .gitmessage commit trailer template)."

-- | Outputs standard quick-start instructions for the newly scaffolded project.
--
-- Displays terminal navigation commands and the canonical verification script
-- invocation (@./bin/<project>-verify@) to guide the developer immediately to
-- successful compilation and test execution.
printNextSteps :: Project -> FilePath -> IO ()
printNextSteps proj targetDir = do
  let pName = projectName proj
  TIO.putStrLn "================================================================================"
  TIO.putStrLn ("  Workspace '" <> pName <> "' is ready!")
  TIO.putStrLn ""
  TIO.putStrLn "  Quick Start:"
  TIO.putStrLn ("    cd " <> T.pack targetDir)
  TIO.putStrLn "    cabal build all"
  TIO.putStrLn "    cabal test all"
  TIO.putStrLn ("    ./bin/" <> pName <> "-verify")
  TIO.putStrLn "================================================================================"