packages feed

hwm-0.4.0: src/HWM/Domain/Build.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE NoImplicitPrelude #-}

module HWM.Domain.Build
  ( Builder (..),
    BuilderCommand (..),
    TargetScope (..),
    BuildFlag (..),
    toExec,
    comandLabel,
  )
where

import Control.Monad.Error.Class (MonadError)
import Control.Monad.Except (throwError)
import Data.Aeson (FromJSON (..), ToJSON (toJSON))
import Data.Aeson.Types (Value (..))
import HWM.Core.Common (Name)
import HWM.Core.Formatting (Format (..), toCamelCase)
import HWM.Core.Parsing (Parse (..))
import HWM.Core.Pkg (Pkg (..), PkgName)
import HWM.Core.Result (Issue)
import HWM.Runtime.Process (EnvVars, Exec (..), mkExec)
import Relude
import System.Directory (copyFile, createDirectoryIfMissing, doesFileExist, doesPathExist, emptyPermissions, listDirectory, removeFile, removePathForcibly, setOwnerExecutable, setOwnerReadable, setOwnerWritable, setPermissions)
import System.FilePath ((</>))

data Builder
  = CabalBuilder {inNixDevelopment :: Bool}
  | StackBuilder
  | NixBuilder
  deriving (Generic, Show, Ord, Eq)

instance FromJSON Builder where
  parseJSON (String s) = parse s
  parseJSON _ = fail "Invalid builder. Expected 'cabal', 'stack', or 'nix'."

instance Parse Builder where
  parse "cabal" = pure CabalBuilder {inNixDevelopment = False}
  parse "nix/cabal" = pure CabalBuilder {inNixDevelopment = True}
  parse "stack" = pure StackBuilder
  parse "nix" = pure NixBuilder
  parse _ = fail "Invalid builder. Expected 'cabal', 'stack', or 'nix'."

instance ToJSON Builder where
  toJSON = String . format

instance Format Builder where
  format (CabalBuilder False) = "cabal"
  format (CabalBuilder True) = "nix/cabal"
  format StackBuilder = "stack"
  format NixBuilder = "nix"

data Cmd = ExecCmd Text [Text] EnvVars | CusomCmd Text
  deriving (Eq, Show)

comandLabel :: BuilderCommand -> Text
comandLabel Build {} = "build"
comandLabel Test {} = "test"
comandLabel Install {} = "build"

extractNixArtifact :: (MonadIO m, MonadError Issue m) => PkgName -> FilePath -> m ()
extractNixArtifact pkgName distDir = do
  let resultLink = distDir </> "result"
      finalDest = distDir </> toString pkgName
      pkgStr = toString (format pkgName)

  liftIO $ createDirectoryIfMissing True distDir
  isLink <- liftIO $ doesPathExist resultLink
  unless isLink
    $ throwError
    $ fromString
    $ "Nix build completed, but did not create an output at: "
    <> resultLink
    <> "\n(This usually means the Nix derivation is empty or 'exec' hid a build failure.)"
  let searchPaths =
        [ resultLink </> "bin" </> pkgStr, -- Standard Haskell (Cabal/Stack)
          resultLink </> pkgStr, -- Simple/Single-binary derivation
          resultLink -- Derivation is the binary itself
        ]
  maybeSource <- findM (liftIO . doesFileExist) searchPaths

  case maybeSource of
    Just sourcePath -> do
      liftIO $ copyFile sourcePath finalDest
      -- Ensure the user can execute it (Nix store is read-only)
      liftIO $ do
        let properPerms =
              setOwnerReadable True
                $ setOwnerWritable True
                $ setOwnerExecutable True emptyPermissions
        setPermissions finalDest properPerms
      -- Cleanup: Remove the 'result' symlink to keep the folder clean
      liftIO $ removeFile resultLink
    Nothing -> throwError $ fromString $ "Nix build succeeded, but binary '" <> pkgStr <> "' not found inside the Nix store path.\n"

extractGlobalNixArtifacts :: (MonadIO m, MonadError Issue m) => FilePath -> m ()
extractGlobalNixArtifacts distDir = do
  let resultLink = distDir </> "result-global"
      binDir = resultLink </> "bin"

  isLink <- liftIO $ doesPathExist resultLink
  unless isLink $ throwError "Nix global build failed to produce a result symlink."

  hasBin <- liftIO $ doesPathExist binDir
  if hasBin
    then do
      -- Copy everything inside result-global/bin/
      files <- liftIO $ listDirectory binDir
      for_ files $ \file -> do
        let sourcePath = binDir </> file
        let destPath = distDir </> file
        liftIO $ copyFile sourcePath destPath
    -- (Apply the same executable permissions here as before)
    else
      -- Fallback if the default package is just a single binary at the root
      throwError "Global Nix install succeeded, but no 'bin/' directory was found in the output."

  -- Clean up the symlink
  liftIO $ removePathForcibly resultLink

findM :: (Monad m) => (a -> m Bool) -> [a] -> m (Maybe a)
findM _ [] = pure Nothing
findM p (x : xs) = ifM (p x) (pure $ Just x) (findM p xs)

data TargetScope
  = ScopeGlobal -- User typed: hwm build (Build everything)
  | ScopePkgs [Pkg] -- User typed: hwm build -w=libs/... (Build these)
  deriving (Eq, Show)

data BuilderCommand
  = Build
  | Test
  | Install {dirPath :: FilePath}
  deriving (Eq, Show)

data BuildFlag
  = CustomBuildFlag Text
  | BuildFastFlag
  | GHCOptionsFlag Text
  deriving (Eq, Show)

formatFlag :: Builder -> BuildFlag -> [Text]
-- WARNING: Nix does not accept '--ghc-options' via CLI; it must be set in the flake.
formatFlag NixBuilder _ = []
formatFlag CabalBuilder {} BuildFastFlag = ["--disable-optimization"]
formatFlag StackBuilder BuildFastFlag = ["--fast"]
formatFlag _ (GHCOptionsFlag xs) = ["--ghc-options=" <> xs]
formatFlag _ (CustomBuildFlag txt) = [txt]

toExec :: (MonadIO m, MonadError Issue m) => Name -> Builder -> BuilderCommand -> TargetScope -> [BuildFlag] -> [(String, String)] -> m (Exec m)
toExec envName builder cmd scope flags envs = do
  Exec {..} <- toAction (Env envName) builder cmd scope
  pure $ inNixDevelop envName nixEnabled $ Exec execCmd (execArgs <> concatMap (formatFlag builder) flags) envs postCommand
  where
    nixEnabled = CabalBuilder {inNixDevelopment = True} == builder

toNixEnv :: Text -> Text
toNixEnv name = ".#" <> toCamelCase name

inNixDevelop :: Name -> Bool -> Exec m -> Exec m
inNixDevelop envName True (Exec cmd ops env post) = Exec "nix" (["develop", toNixEnv envName, "--command", cmd] <> ops) env post
inNixDevelop _ False e = e

nixScope :: (Format p) => p -> TargetScope -> [Text]
nixScope envName ScopeGlobal =
  let envSuffix = toCamelCase (format envName)
   in [".#env-" <> envSuffix <> "-all"]
nixScope envName (ScopePkgs pkgs) =
  let envSuffix = toCamelCase (format envName)
   in map (\pkg -> ".#" <> format (pkgName pkg) <> "-" <> envSuffix) pkgs

handleScope :: Bool -> TargetScope -> [Text]
handleScope isCabal ScopeGlobal = ["all" | isCabal]
handleScope _ (ScopePkgs pkgs) = map (format . pkgName) pkgs

mkStack :: (Applicative m) => Text -> TargetScope -> [Text] -> m (Exec m)
mkStack cmd scope ops = mkExec "stack" ([cmd] <> handleScope False scope <> ops)

mkCabal :: (Applicative m) => Text -> TargetScope -> [Text] -> m (Exec m)
mkCabal cmd scope ops = mkExec "cabal" ([cmd] <> handleScope True scope <> ops)

newtype Env = Env {envName :: Name}

toAction :: (MonadError Issue m, MonadIO m) => Env -> Builder -> BuilderCommand -> TargetScope -> m (Exec m)
-- Stack
toAction _ StackBuilder Build scope = mkStack "build" scope []
toAction _ StackBuilder Install {..} scope = mkStack "install" scope ["--local-bin-path", format dirPath]
toAction _ StackBuilder Test scope = mkStack "test" scope []
-- Cabal
toAction _ CabalBuilder {} Install {..} scope = mkCabal "install" scope ["--install-method=copy", "--installdir", format dirPath, "--overwrite-policy=always"]
toAction _ CabalBuilder {} Build scope = mkCabal "build" scope []
toAction _ CabalBuilder {} Test scope = mkCabal "test" scope []
-- Nix
toAction ctx NixBuilder Build scope = mkExec "nix" $ ["build", "--no-link"] <> nixScope (envName ctx) scope
toAction ctx NixBuilder Test scope = mkExec "nix" $ ["build", "--no-link"] <> nixScope (envName ctx) scope
toAction _ NixBuilder Install {..} scope =
  case scope of
    ScopeGlobal -> pure $ Exec "nix" ["build", ".#", "-o", format (dirPath </> "result-global")] [] (Just $ extractGlobalNixArtifacts dirPath)
    ScopePkgs [pkg] -> pure $ Exec "nix" ["build", ".#" <> format (pkgName pkg), "-o", format (dirPath </> "result")] [] (Just $ extractNixArtifact (pkgName pkg) dirPath)
    ScopePkgs _ -> throwError "Multiple package install is not supported with Nix builder."