packages feed

hix-0.9.0: lib/Hix/Managed/Cabal/Config.hs

module Hix.Managed.Cabal.Config where

import Control.Exception (displayException)
import Control.Monad (foldM)
import qualified Data.ByteString.Lazy as LByteString
import qualified Data.Map.Strict as Map
import Data.Map.Strict ((!?))
import qualified Data.Set as Set
import qualified Data.Text as Text
import Exon (exon)
import qualified Path
import Path (SomeBase (..), toFilePath)
import qualified Path.IO as Path
import System.Environment (lookupEnv)
import System.Exit (ExitCode (..))
import System.IO.Error (tryIOError)
import qualified System.Process.Typed as Process

import qualified Hix.Color as Color
import Hix.Data.Monad (M)
import Hix.Data.Options (CabalOptions (..))
import qualified Hix.Data.PackageId
import Hix.Data.PackageId (PackageId)
import Hix.Data.PackageName (PackageName)
import qualified Hix.Log as Log
import Hix.Managed.Cabal.Data.Config (CabalConfig (..))
import Hix.Managed.Cabal.Data.ContextHackageRepo (
  ContextHackageLocation (..),
  ContextHackagePassword (..),
  ContextHackageRepo (..),
  )
import qualified Hix.Managed.Cabal.Data.HackageLocation as HackageLocation
import Hix.Managed.Cabal.Data.HackageLocation (HackageLocation (auth), HackagePassword (HackagePassword), HackageUser)
import Hix.Managed.Cabal.Data.HackageRepo (HackageName, HackageRepo (..), centralName)
import Hix.Managed.Cabal.HackageLocation (parseLocation)
import Hix.Managed.Cabal.HackageRepo (hackageDescription)
import Hix.Managed.Data.Mutable (MutableDep, depName)
import Hix.Monad (appContextVerbose, clientError, eitherClient, noteClient, tryIOM)

nonReinstallableNames :: Set PackageName
nonReinstallableNames =
  [
    "base",
    "ghc-bignum",
    "ghc-prim",
    "ghc",
    "integer-gmp",
    "integer-simple",
    "template-haskell"
  ]

isNonReinstallable :: PackageName -> Bool
isNonReinstallable = flip Set.member nonReinstallableNames

isReinstallable :: PackageName -> Bool
isReinstallable = not . isNonReinstallable

isNonReinstallableId :: PackageId -> Bool
isNonReinstallableId package = isNonReinstallable package.name

isReinstallableId :: PackageId -> Bool
isReinstallableId package = isReinstallable package.name

isNonReinstallableDep :: MutableDep -> Bool
isNonReinstallableDep = isNonReinstallable . depName

resolvePasswordEnvVar :: Text -> M HackagePassword
resolvePasswordEnvVar name =
  lookup >>= \case
    [] -> clientError (message "is empty")
    value -> pure (HackagePassword (toText value))
  where
    lookup = noteClient (message "does not exist") =<< tryIOM (lookupEnv (toString name))

    message problem = [exon|The specified environment variable #{Color.cyan name} #{problem}|]

resolvePasswordExec :: Text -> M HackagePassword
resolvePasswordExec spec =
  noteClient (message "is not a valid path") (Path.parseSomeFile (toString spec)) >>= \case
    Abs path -> checkPath path
    Rel rel ->
      liftIO (tryIOError (Path.findExecutable rel)) >>= \case
        Right (Just path) -> checkPath path
        _ -> failure [exon|could not be found in #{Color.cyan ("$PATH" :: Text)}|]
  where
    checkPath path =
      liftIO (tryIOError (Path.getPermissions path)) >>= \case
        Right perms | Path.executable perms -> exec path
                    | otherwise -> failure "is not an executable file"
        Left _ -> failure "could not be read"

    exec path =
      liftIO (tryIOError (Process.readProcessStdout (Process.proc (toFilePath path) []))) >>= \case
        Right (ExitSuccess, output)
          | LByteString.null output -> failure "printed nothing on stdout"
          | [pw] <- Text.lines (decodeUtf8 output) -> pure (HackagePassword pw)
          | otherwise -> failure "printed multiple lines"
        Right (ExitFailure code, _) -> failure [exon|exited with code #{show @_ @Int code}|]
        Left err -> do
          Log.debug [exon|Subprocess error: #{toText (displayException err)}|]
          failure [exon|caused an exception (pass #{Color.cyan @Text "--debug"} to see it)|]

    failure = clientError . message

    message problem = [exon|The specified executable #{Color.path spec} #{problem}|]

resolvePassword :: ContextHackagePassword -> M HackagePassword
resolvePassword =
  appContextVerbose ctx . \case
    PasswordUnobscured pw -> pure pw
    PasswordPlain pw -> pure pw
    PasswordEnvVar name -> resolvePasswordEnvVar name
    PasswordExec path -> resolvePasswordExec path
  where
    ctx = "resolving the password"

withAuth ::
  HackageLocation ->
  Maybe HackageUser ->
  Maybe ContextHackagePassword ->
  M HackageLocation
withAuth location = \cases
  (Just user) Nothing ->
    onlyOne [exon|user (##{user})|] "password"
  Nothing (Just _) ->
    onlyOne "password" "user"
  Nothing Nothing ->
    pure location
  (Just user) (Just passwordSpec) -> do
    password <- resolvePassword passwordSpec
    pure location {auth = Just (user, password)}
  where
    onlyOne present absent = clientError [exon|Specified a #{present}, but no #{absent}|]

validateContextRepo :: ContextHackageRepo -> M HackageRepo
validateContextRepo ContextHackageRepo {location = location0, ..} = do
  appContextVerbose [exon|validating the Hackage config #{Color.yellow name}|] do
    location1 <- for location0 \ (ContextHackageLocation spec) ->
      eitherClient (first toText (parseLocation (toString spec)))
    location <- withAuth (fromMaybe HackageLocation.central location1) user password
    pure HackageRepo {
      name,
      description = fromMaybe (hackageDescription location) description,
      enable = fromMaybe True enable,
      location,
      solver = fromMaybe True solver,
      publish = fromMaybe False publish,
      ..
    }

-- | Merge Hackage repos from different sources, prioritizing CLI arguments over flake config.
cabalConfig :: Map HackageName ContextHackageRepo -> CabalOptions -> M CabalConfig
cabalConfig contextRepos CabalOptions {hackage = cliOverrides} = do
  amended <- foldM applyOverride contextRepos cliOverrides
  valid <- traverse validateContextRepo amended
  pure CabalConfig {
    hackageMain = valid !? centralName,
    hackageExtra = Map.elems (Map.delete centralName valid)
  }
  where
    applyOverride repos (name :: HackageName, override) = do
      repo <- noteClient [exon|Invalid Hackage repo name: ##{name}|] (repos !? name)
      pure (Map.insert name (override repo) repos)