packages feed

pushme-3.1.0: src/Pushme/Options.hs

{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}

module Pushme.Options where

import Control.Lens hiding (argument)
import Control.Logging
import Control.Monad (forM_, when)
import Data.Aeson hiding (Options)
import Data.Aeson.Types (modifyFailure)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Data.Text (Text, pack, unpack)
import qualified Data.Text as T
import Options.Applicative hiding (Success)
import Text.Regex.Posix ((=~))

version :: String
version = "3.1.0"

copyright :: String
copyright = "2012-2026"

pushmeSummary :: String
pushmeSummary =
  "pushme " ++ version ++ ", (C) " ++ copyright ++ " John Wiegley"

data RsyncOptions = RsyncOptions
  { _rsyncFilters :: Maybe Text
  , _rsyncExtraFilters :: Maybe Text
  , _rsyncNoBasicOptions :: Bool
  , _rsyncNoDelete :: Bool
  , _rsyncPreserveACLs :: Maybe Bool -- -A
  , _rsyncPreserveXattrs :: Maybe Bool -- -X
  , _rsyncPreserveAtimes :: Maybe Bool -- -U
  , _rsyncPreserveCrtimes :: Maybe Bool -- -N
  , _rsyncPreserveHardLinks :: Maybe Bool -- -H
  , _rsyncPreserveExecutability :: Maybe Bool -- -E
  , _rsyncProtectTopLevel :: Bool
  , _rsyncOptions :: Maybe [Text]
  , _rsyncReceiveFrom :: Maybe [Text]
  , _rsyncActive :: Bool
  }
  deriving (Show, Eq)

instance FromJSON RsyncOptions where
  parseJSON (Object v) = do
    preserveAll <- v .:? "PreserveAttrs"
    RsyncOptions
      <$> v .:? "Filters"
      <*> v .:? "ExtraFilters"
      <*> v .:? "NoBasicOptions" .!= False
      <*> v .:? "NoDelete" .!= False
      <*> ((<|> preserveAll) <$> v .:? "PreserveACLs")
      <*> ((<|> preserveAll) <$> v .:? "PreserveXattrs")
      <*> ((<|> preserveAll) <$> v .:? "PreserveAtimes")
      <*> ((<|> preserveAll) <$> v .:? "PreserveCrtimes")
      <*> ((<|> preserveAll) <$> v .:? "PreserveHardLinks")
      <*> ((<|> preserveAll) <$> v .:? "PreserveExecutability")
      <*> v .:? "ProtectTopLevel" .!= False
      <*> v .:? "Options"
      <*> v .:? "ReceiveFrom"
      <*> v .:? "Active" .!= True
  parseJSON _ = errorL "Error parsing Rsync"

instance Semigroup RsyncOptions where
  RsyncOptions a1 b1 c1 d1 e1 f1 g1 h1 i1 j1 k1 l1 m1 n1
    <> RsyncOptions a2 b2 c2 d2 e2 f2 g2 h2 i2 j2 k2 l2 m2 n2 =
      RsyncOptions
        (a2 <|> a1) -- Filters
        (combineFilters b1 b2) -- ExtraFilters
        (c2 || c1) -- NoBasicOptions
        (d2 || d1) -- NoDelete
        (e2 <|> e1) -- PreserveACLs
        (f2 <|> f1) -- PreserveXattrs
        (g2 <|> g1) -- PreserveAtimes
        (h2 <|> h1) -- PreserveCrtimes
        (i2 <|> i1) -- PreserveHardLinks
        (j2 <|> j1) -- PreserveExecutability
        (k2 || k1) -- ProtectTopLevel
        (l2 <|> l1) -- Options
        (m2 <|> m1) -- ReceiveFrom
        (n2 && n1) -- Active

combineFilters :: Maybe Text -> Maybe Text -> Maybe Text
combineFilters Nothing Nothing = Nothing
combineFilters (Just a) Nothing = Just a
combineFilters Nothing (Just b) = Just b
combineFilters (Just a) (Just b) = Just (a <> "\n" <> b)

makeLenses ''RsyncOptions

{- | Interpolate all $filterSet references in rsync filter text.
Variable names must match pattern: $[a-zA-Z_][a-zA-Z0-9_]*.
-}
interpolateFilterReferences :: Map Text Text -> Text -> Text
interpolateFilterReferences filterSets filters
  | "$" `T.isInfixOf` filters = foldl replaceFilter filters filterRefs
  | otherwise = filters
 where
  varPattern :: String
  varPattern = "\\$[a-zA-Z_][a-zA-Z0-9_]*"

  filterRefs :: [String]
  filterRefs =
    [m | (m : _) <- unpack filters =~ varPattern :: [[String]]]

  replaceFilter :: Text -> String -> Text
  replaceFilter acc filterRef =
    let filterName = pack $ drop 1 filterRef
     in case M.lookup filterName filterSets of
          Nothing ->
            error $
              "Filter contains undefined filter set "
                <> filterRef
                <> "\nAvailable filter sets: "
                <> show (M.keys filterSets)
          Just filterText ->
            T.replace (pack filterRef) filterText acc

resolveRsyncFilterReferences :: Map Text Text -> RsyncOptions -> RsyncOptions
resolveRsyncFilterReferences filterSets opts =
  opts
    & rsyncFilters . _Just %~ interpolateFilterReferences filterSets
    & rsyncExtraFilters . _Just %~ interpolateFilterReferences filterSets

-- | Alias definition for host names with optional configuration overrides
data Alias = Alias
  { _aliasName :: Text
  , _aliasHost :: Text
  , _aliasMaxJobs :: Maybe Int
  , _aliasVariables :: Map Text Text
  , _aliasOptions :: Maybe [Text]
  }
  deriving (Show, Eq)

instance FromJSON Alias where
  parseJSON (Object v) = modifyFailure addContext $ do
    -- Parse Variables if present
    variables <- v .:? "Variables" .!= M.empty
    -- Check for legacy Prefix field and merge it
    legacyPrefix <- v .:? "Prefix"
    let finalVariables = case legacyPrefix of
          Just prefix -> M.insert "prefix" (pack prefix) variables
          Nothing -> variables
    -- Explicitly parse Host to provide better error message
    mHost <- v .:? "Host"
    host <- case mHost of
      Nothing -> fail "Missing required field 'Host' in alias definition"
      Just h -> pure h
    Alias "" host -- Name will be filled in from the Map key
      <$> v .:? "MaxJobs"
      <*> pure finalVariables
      <*> v .:? "Options"
   where
    addContext msg = "Error parsing Alias: " ++ msg
  parseJSON _ = errorL "Error parsing Alias"

makeLenses ''Alias

-- Note: Host type is defined in Main.hs as:
-- data Host = Host { _hostName :: Text, _hostMaxJobs :: Int }

-- | Host reference combining logical name (for fileset matching) with actual host details
data HostRef = HostRef
  { _hostRefLogicalName :: Text
  , _hostRefActualHost :: (Text, Int) -- (hostName, maxJobs) - avoiding circular dependency
  , _hostRefVariables :: Map Text Text
  , _hostRefOptions :: Maybe [Text] -- Per-host rsync options from alias
  }
  deriving (Show, Eq, Ord)

makeLenses ''HostRef

data Options = Options
  { _optsConfigDir :: FilePath
  , _optsDryRun :: Bool
  , _optsFilesets :: Maybe [Text]
  , _optsClasses :: Maybe [Text]
  , _optsSiUnits :: Bool
  , _optsVerbose :: Bool
  , _optsNoColor :: Bool
  , _optsReverse :: Bool
  , _optsRsyncOpts :: Maybe RsyncOptions
  , _optsAliases :: Map Text Alias
  , _optsFilterSets :: Map Text Text
  , _optsCliArgs :: [String]
  }
  deriving (Show, Eq)

instance FromJSON Options where
  parseJSON (Object v) = do
    aliasMap <- v .:? "Aliases" .!= M.empty
    -- Fill in the _aliasName field from the Map keys
    let aliasMapWithNames = M.mapWithKey (\k a -> a{_aliasName = k}) aliasMap
    -- Validate that all aliases have a non-empty Host field
    forM_ (M.toList aliasMapWithNames) $ \(name, alias) ->
      when (T.null (alias ^. aliasHost)) $
        fail $
          "Alias '" ++ unpack name ++ "' is missing required field 'Host'"
    Options
      <$> v .:? "Config" .!= "~/.config/pushme"
      <*> v .:? "DryRun" .!= False
      <*> v .:? "Filesets"
      <*> v .:? "Classes"
      <*> v .:? "SIUnits" .!= False
      <*> v .:? "Verbose" .!= False
      <*> v .:? "NoColor" .!= False
      <*> v .:? "Reverse" .!= False
      <*> v .:? "GlobalOptions"
      <*> pure aliasMapWithNames
      <*> v .:? "Filters" .!= M.empty
      <*> pure []
  parseJSON _ = errorL "Error parsing Options"

instance Semigroup Options where
  Options _a1 b1 c1 d1 e1 f1 g1 g1r h1 i1 j1 k1
    <> Options a2 b2 c2 d2 e2 f2 g2 g2r h2 i2 j2 k2 =
      Options
        a2
        (b2 || b1)
        (c2 <|> c1)
        (d2 <|> d1)
        (e2 || e1)
        (f2 || f1)
        (g2 || g1)
        (g2r || g1r)
        (h2 <> h1)
        (i2 <> i1) -- Right-biased merge: right Map wins on key conflicts
        (j2 <> j1)
        (k2 <|> k1)

makeLenses ''Options

resolveOptionsFilterReferences :: Options -> Options
resolveOptionsFilterReferences opts =
  opts
    & optsRsyncOpts . _Just %~ resolveRsyncFilterReferences (opts ^. optsFilterSets)

separated :: Char -> ReadM [Text]
separated c = T.split (== c) <$> str

pushmeOpts :: Parser Options
pushmeOpts =
  Options
    <$> strOption
      ( long "config"
          <> value "~/.config/pushme"
          <> help "Config directory (default: ~/.config/pushme)"
      )
    <*> switch
      ( short 'n'
          <> long "dry-run"
          <> help "Do not take any actions, just report"
      )
    <*> optional
      ( option
          (separated ',')
          ( short 'f'
              <> long "filesets"
              <> help "File sets to synchronize (comma-separated)"
          )
      )
    <*> optional
      ( option
          (separated ',')
          ( short 'c'
              <> long "classes"
              <> help "Classes to synchronize (comma-separated)"
          )
      )
    <*> switch
      ( short 's'
          <> long "si-units"
          <> help "Use 1000 instead of 1024 as a divisor"
      )
    <*> switch
      ( short 'v'
          <> long "verbose"
          <> help "Report progress verbosely"
      )
    <*> switch
      ( long "no-color"
          <> help "Do not use ANSI colors in report output"
      )
    <*> switch
      ( short 'R'
          <> long "reverse"
          <> help "Pull from remote hosts instead of pushing to them"
      )
    <*> optional
      ( ( \filters noBasic noDelete preserveAll protectTop opts ->
            let mPreserve = if preserveAll then Just True else Nothing
             in RsyncOptions
                  filters
                  Nothing
                  noBasic
                  noDelete
                  mPreserve
                  mPreserve
                  mPreserve
                  mPreserve
                  mPreserve
                  mPreserve
                  protectTop
                  opts
                  Nothing
                  True
        )
          <$> optional
            ( strOption
                ( long "rsync-filters"
                    <> help "rsync filters to pass using --include-from"
                )
            )
          <*> switch
            ( long "rsync-no-basic-options"
                <> help "Do not pass -a (and possibly other basic options)"
            )
          <*> switch
            ( long "rsync-no-delete"
                <> help "Do not pass --delete"
            )
          <*> switch
            ( long "rsync-preserve-attrs"
                <> help "Preserve all attributes (-AXUNHE)"
            )
          <*> switch
            ( long "rsync-protect-top-level"
                <> help "Protect top-level items from deletion"
            )
          <*> optional
            ( option
                (separated ' ')
                ( long "rsync-options"
                    <> help "Space-separated list of options to pass to rsync"
                )
            )
      )
    <*> pure M.empty -- Aliases come from config file, not CLI
    <*> pure M.empty -- Filter sets come from config file, not CLI
    <*> many (argument (eitherReader Right) (metavar "ARGS"))

optionsDefinition :: ParserInfo Options
optionsDefinition =
  info
    (helper <*> pushmeOpts)
    (fullDesc <> progDesc "" <> header pushmeSummary)

getOptions :: IO Options
getOptions = execParser optionsDefinition