packages feed

hwm-0.4.0: src/HWM/Runtime/Files.hs

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

module HWM.Runtime.Files
  ( readYaml,
    rewrite_,
    statusM,
    aesonYAMLOptions,
    select,
    addHash,
    forbidOverride,
    cleanRelativePath,
    aesonYAMLOptionsAdvanced,
    genSignature,
    Signature,
    getFileSignature,
    prepareDir,
    syncFile,
    rewriteMaybe_,
    getLocalBinDir,
    warnBindDir,
  )
where

import Control.Exception (tryJust)
import Control.Monad.Error.Class (MonadError (..))
import qualified Crypto.Hash.SHA256 as SHA256
import Data.Aeson
  ( FromJSON (..),
    Object,
    Options (..),
    ToJSON (..),
    Value (..),
    defaultOptions,
  )
import Data.ByteString (readFile, writeFile)
import qualified Data.ByteString.Base16 as Base16
import Data.Char (isUpper, toLower)
import Data.List (elemIndex, stripPrefix)
import Data.Map (lookup)
import Data.Text (toTitle)
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Data.Text.IO as TIO
import Data.Yaml (decodeThrow)
import Data.Yaml.Pretty (defConfig, encodePretty, setConfCompare, setConfDropNull)
import HWM.Core.Formatting (Format (..), Status (..))
import HWM.Core.Result (Issue, MonadIssue (injectIssue))
import Relude hiding (readFile, writeFile)
import System.Directory (createDirectoryIfMissing, doesFileExist, getHomeDirectory)
import System.FilePath (dropTrailingPathSeparator, joinPath, splitDirectories, splitSearchPath, (</>))

data Signature = Signed Text | Unsigned
  deriving (Ord, Show)

instance Eq Signature where
  (Signed hash1) == (Signed hash2) = hash1 == hash2
  _ == _ = False

genSignature :: [Text] -> Signature
genSignature txt =
  let hashInput = T.encodeUtf8 (T.intercalate ":" txt)
      hashBytes = SHA256.hash hashInput
   in Signed (T.decodeUtf8 (Base16.encode hashBytes))

instance Format Signature where
  format (Signed hash) = "# hash: " <> hash <> "\n"
  format Unsigned = ""

printException :: SomeException -> String
printException = show

safeIO :: IO a -> IO (Either String a)
safeIO = tryJust (Just . printException)

safeRead :: (MonadIO m) => FilePath -> m (Either String ByteString)
safeRead = liftIO . safeIO . readFile

safeWrite :: (MonadIO m) => FilePath -> ByteString -> m (Either String ())
safeWrite file content = liftIO $ safeIO (writeFile file content)

serializeYaml :: (ToJSON a) => a -> ByteString
serializeYaml =
  encodePretty
    $ setConfDropNull True
    $ setConfCompare compareFields defConfig

data Yaml t = Yaml
  { getData :: t,
    rawValue :: Object
  }
  deriving (Generic)

instance (FromJSON t) => FromJSON (Yaml t) where
  parseJSON v = Yaml <$> parseJSON v <*> parseJSON v

instance (ToJSON t) => ToJSON (Yaml t) where
  toJSON (Yaml t v) = Object (toObject (toJSON t) <> v)

toObject :: Value -> Object
toObject (Object x) = x
toObject _ = mempty

mapYaml :: (Functor m) => (Maybe t -> m t) -> Maybe (Yaml t) -> m (Yaml t)
mapYaml f (Just (Yaml v props)) = (`Yaml` props) <$> f (Just v)
mapYaml f Nothing = (`Yaml` mempty) <$> f Nothing

fromEither :: (MonadError Issue m, FromJSON b, MonadIO m) => Either a ByteString -> m (Maybe b)
fromEither = either (const $ pure Nothing) (fmap Just . liftIO . decodeThrow)

withThrow :: (MonadError Issue m) => m (Either String a) -> m a
withThrow x = x >>= either (throwError . fromString) pure

rewrite_ :: (MonadError Issue m, MonadIO m, FromJSON t, ToJSON t) => FilePath -> (Maybe t -> m t) -> m Status
rewrite_ pkg f = do
  prevYaml <- safeRead pkg >>= fromEither
  nextYaml <- mapYaml f prevYaml
  let same = Just (toJSON nextYaml) == (toJSON <$> prevYaml)
  if same
    then pure Checked
    else withThrow (safeWrite pkg (serializeYaml nextYaml)) $> Updated

rewriteMaybe_ :: (MonadError Issue m, MonadIO m, FromJSON t, ToJSON t) => FilePath -> (Maybe t -> m (Maybe t)) -> m Status
rewriteMaybe_ pkg f = do
  prevYaml <- safeRead pkg >>= fromEither
  maybeNext <- f (getData <$> prevYaml)
  case maybeNext of
    Nothing -> pure Checked -- nothing to change, so we consider it checked
    Just next -> do
      nextYaml <- mapYaml (const (pure next)) prevYaml
      let same = Just (toJSON nextYaml) == (toJSON <$> prevYaml)
      if same
        then pure Checked
        else withThrow (safeWrite pkg (serializeYaml nextYaml)) $> Updated

statusM :: (MonadIO m) => FilePath -> m t -> m Status
statusM pkg m = do
  before <- safeRead pkg
  _ <- m
  after <- safeRead pkg
  pure (if before == after then Checked else Updated)

readYaml :: (MonadError Issue m, MonadIO m, FromJSON a) => FilePath -> m a
readYaml = withThrow . safeRead >=> (liftIO . decodeThrow)

fields :: [Text]
fields =
  map
    toTitle
    [ "name",
      "version",
      "github",
      "license",
      "author",
      "category",
      "synopsis",
      "maintainer",
      "homepage",
      "copyright",
      "license-file",
      "description",
      "bounds",
      "ghc",
      "resolver",
      "packages",
      "workspace",
      "builds",
      "extra-source-files",
      "data-files",
      "main",
      "source-dirs",
      "ghc-options",
      "dependencies",
      "library",
      "executables",
      "include",
      "exclude",
      "allow-newer",
      "save-hackage-creds",
      "extra-deps",
      "stackYaml",
      "components",
      "path",
      "component"
    ]

toPriority :: Text -> Int
toPriority = fromMaybe (length fields) . (`elemIndex` fields)

mapTuple :: (a -> b) -> (b -> b -> c) -> a -> a -> c
mapTuple f g a b = g (f a) (f b)

compareFields :: Text -> Text -> Ordering
compareFields = mapTuple toTitle (mapTuple toPriority compare <> compare)

toKebabCase :: String -> String
toKebabCase = concatMap toKebab
  where
    toKebab x
      | isUpper x = ['-', toLower x]
      | otherwise = [x]

aesonYAMLOptions :: Options
aesonYAMLOptions = defaultOptions {fieldLabelModifier = toKebabCase . fieldLabelModifier defaultOptions, omitNothingFields = True}

aesonYAMLOptionsAdvanced :: String -> Options
aesonYAMLOptionsAdvanced prefix = defaultOptions {fieldLabelModifier = toKebabCase . stripFieldNamespace prefix . fieldLabelModifier defaultOptions, omitNothingFields = True}

dropPrefix :: String -> String -> String
dropPrefix name = drop (length name)

stripFieldNamespace :: String -> String -> String
stripFieldNamespace prefix = __uncapitalize . dropPrefix prefix
  where
    __uncapitalize [] = []
    __uncapitalize (x : xs) = toLower x : xs

select :: (MonadError Issue m, Format t, Ord t) => Text -> t -> Map t a -> m a
select e k = maybe (throwError $ fromString $ "Unknown " <> toString e <> ": " <> toString (format k) <> "!") pure . lookup k

addHash :: (MonadIO m) => FilePath -> Signature -> m ()
addHash filePath (Signed hash) = do
  content <- liftIO $ T.decodeUtf8 <$> readFileBS filePath
  let contentWithHash = "# hash: " <> hash <> "\n" <> content
  liftIO $ writeFileBS filePath (T.encodeUtf8 contentWithHash)
addHash _ Unsigned = pure ()

getFileSignature :: (MonadIO m) => FilePath -> m Signature
getFileSignature filePath = do
  content <- liftIO $ T.decodeUtf8 <$> readFileBS filePath
  case T.lines content of
    (firstLine : _) ->
      case T.stripPrefix "# hash: " firstLine of
        Just hash -> pure (Signed hash)
        Nothing -> pure Unsigned
    [] -> pure Unsigned

forbidOverride :: (MonadIO m, MonadError e m, IsString e) => FilePath -> m ()
forbidOverride path = do
  exists <- liftIO $ doesFileExist path
  when exists $ throwError $ fromString $ "File \"" <> path <> "\" already exists!"

cleanRelativePath :: Maybe String -> Maybe String
cleanRelativePath Nothing = Nothing
cleanRelativePath (Just "") = Nothing
cleanRelativePath (Just "./") = Nothing
cleanRelativePath (Just ".") = Nothing
cleanRelativePath (Just name) =
  Just
    $ joinPath
    $ splitDirectories
    $ fromMaybe name (stripPrefix "./" name)

prepareDir :: (MonadIO m) => FilePath -> m ()
prepareDir dir = liftIO $ createDirectoryIfMissing True dir

syncFile :: (MonadIO m) => FilePath -> Text -> m Status
syncFile path newFile = do
  exist <- liftIO $ doesFileExist path
  old <-
    if exist
      then Just <$> liftIO (TIO.readFile path)
      else pure Nothing
  if old == Just newFile
    then pure Checked
    else liftIO $ TIO.writeFile path newFile $> Updated

getLocalBinDir :: (MonadIssue m, MonadIO m) => m FilePath
getLocalBinDir = do
  home <- liftIO getHomeDirectory
  let dir = home </> ".local" </> "bin"
  liftIO $ createDirectoryIfMissing True dir
  pure dir

warnBindDir :: (MonadIssue m, MonadIO m) => FilePath -> m ()
warnBindDir dir = do
  pathStr <- concatMap splitSearchPath . maybeToList <$> liftIO (lookupEnv "PATH")
  let pathDirs = map dropTrailingPathSeparator pathStr
  unless (dropTrailingPathSeparator dir `elem` pathDirs) $ do
    injectIssue (fromString $ "Directory " <> dir <> " is not on your $PATH. Executables installed here will not be globally available.")