packages feed

synapse-cc-0.4.0: src/SynapseCC/Config.hs

{-# LANGUAGE OverloadedStrings #-}

-- | synapse.config.json loader and validator
module SynapseCC.Config
  ( loadSynapseConfig
  , validateSynapseConfig
  , initSynapseConfig
  , InitResult(..)
  , synapseConfigPath

    -- * Shared config-to-runtime conversions
  , parseWsUrl
  , buildConfigFromTarget
  ) where

import Data.Aeson (eitherDecodeFileStrict)
import qualified Data.Aeson.Encode.Pretty as Pretty
import qualified Data.ByteString.Lazy as BL
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import System.Directory (doesFileExist)

import SynapseCC.Detect
  ( Detector, ProjectHint(..), runDetectors
  , BackendInference(..), InferenceOutcome(..), inferBackend
  )
import SynapseCC.Types

-- | Standard config file name (looked up from CWD)
synapseConfigPath :: FilePath
synapseConfigPath = "synapse.config.json"

-- | Load synapse.config.json from the current directory.
-- Returns Left if the file doesn't exist or fails to parse.
loadSynapseConfig :: IO (Either Text SynapseConfig)
loadSynapseConfig = loadSynapseConfigFrom synapseConfigPath

-- | Load from an explicit path (for hot-reload)
loadSynapseConfigFrom :: FilePath -> IO (Either Text SynapseConfig)
loadSynapseConfigFrom path = do
  exists <- doesFileExist path
  if not exists
    then pure $ Left $ "synapse.config.json not found at " <> T.pack path
                    <> " — run 'synapse-cc init' to create one"
    else do
      result <- eitherDecodeFileStrict path
      case result of
        Left err  -> pure $ Left $ T.pack err
        Right cfg -> case validateSynapseConfig cfg of
          Left verr -> pure $ Left verr
          Right ()  -> pure $ Right cfg

-- | Validate a loaded SynapseConfig, returning the first error found.
validateSynapseConfig :: SynapseConfig -> Either Text ()
validateSynapseConfig cfg = do
  requireNonEmpty "language" (scLanguage cfg)
  -- backend and url are required only when at least one target needs a backend.
  -- A target with generate: ["transport"] is backend-free.
  -- Each non-transport-only target needs a backend from either its own fields or top-level
  let targetsNeedingBackend = filter ((/= ["transport"]) . tcGenerate . snd) (Map.toList (scTargets cfg))
      missingBackend = filter (\(_, tc) ->
        case (tcBackend tc, scBackend cfg) of
          (Nothing, Nothing) -> True
          _                  -> False
        ) targetsNeedingBackend
  case missingBackend of
    ((name, _):_) -> Left $ "target \"" <> name <> "\": no \"backend\" specified (set per-target or top-level)"
    []            -> Right ()
  if Map.null (scTargets cfg)
    then Left "At least one entry under \"targets\" is required"
    else mapM_ (validateTargetConfig (scTargets cfg)) (Map.toList (scTargets cfg))
  where
    requireNonEmpty field val =
      if T.null (T.strip val)
        then Left $ "\"" <> field <> "\" must not be empty"
        else Right ()

validateTargetConfig :: Map Text TargetConfig -> (Text, TargetConfig) -> Either Text ()
validateTargetConfig allTargets (name, tc) = do
  if null (tcGenerate tc)
    then Left $ "target \"" <> name <> "\": \"generate\" list must not be empty"
    else Right ()
  if null (tcOutputDir tc)
    then Left $ "target \"" <> name <> "\": \"outputDir\" must not be empty"
    else Right ()
  -- Validate sharedTransport references
  case tcSharedTransport tc of
    Nothing -> Right ()
    Just ref -> do
      -- 1. Referenced target must exist
      case Map.lookup ref allTargets of
        Nothing -> Left $ "target \"" <> name <> "\": sharedTransport references unknown target \"" <> ref <> "\""
        Just provider -> do
          -- 2. Provider must have "transport" in its generate list
          if "transport" `notElem` tcGenerate provider && tcGenerate provider /= ["all"]
            then Left $ "target \"" <> name <> "\": sharedTransport target \"" <> ref
                      <> "\" does not generate transport"
            else Right ()
          -- 3. Both targets must use the same transport mode
          if tcTransport tc /= tcTransport provider
            then Left $ "target \"" <> name <> "\": transport mode differs from sharedTransport target \"" <> ref <> "\""
            else Right ()
          -- 4. Consumer must NOT include "transport" or "rpc" in its generate list
          if "transport" `elem` tcGenerate tc || "rpc" `elem` tcGenerate tc
            then Left $ "target \"" <> name
                      <> "\": generate must not include \"transport\" or \"rpc\" when using sharedTransport"
            else Right ()
          -- 5. No circular references
          case tcSharedTransport provider of
            Just _ -> Left $ "target \"" <> name <> "\": sharedTransport chain detected (\"" <> ref
                           <> "\" also uses sharedTransport)"
            Nothing -> Right ()

-- ============================================================================
-- Config-to-Runtime Conversions
-- ============================================================================

-- | Parse host and port from a ws:// or wss:// URL.
-- Defaults port to "4444" when absent.
parseWsUrl :: Text -> (Text, Text)
parseWsUrl url =
  let stripped = case T.stripPrefix "wss://" url of
        Just s  -> s
        Nothing -> case T.stripPrefix "ws://" url of
          Just s  -> s
          Nothing -> url
      (host, rest) = T.breakOn ":" stripped
      port         = T.takeWhile (/= '/') (T.drop 1 rest)
  in (host, if T.null port then "4444" else port)

-- | Build a runtime Config from a set of CLI options, a SynapseConfig, and one target.
-- The target's outputDir and transport override the options; all other options
-- (--debug, --force, --no-install, etc.) are inherited as-is.
buildConfigFromTarget :: Options -> SynapseConfig -> TargetConfig -> Config
buildConfigFromTarget opts sc tc =
  let url            = fromMaybe (fromMaybe "ws://127.0.0.1:4444" (scUrl sc)) (tcUrl tc)
      (host, port)   = parseWsUrl url
      backendName    = fromMaybe (fromMaybe "" (scBackend sc)) (tcBackend tc)
  in Config
       { cfgTarget  = parseLanguage (scLanguage sc)
       , cfgBackend = Backend backendName
       , cfgHost    = host
       , cfgPort    = port
       , cfgOptions = opts
           { optOutput    = tcOutputDir tc
           , optTransport = tcTransport tc
           }
       , cfgPlugins = tcPlugins tc
       }

-- | Result of a successful @synapse-cc init@.
data InitResult = InitResult
  { irHint        :: !ProjectHint   -- ^ Transport detection result ('phReason' explains)
  , irBackend     :: !Text          -- ^ Backend name written into the config
  , irBackendNote :: !(Maybe Text)  -- ^ Announcement when the backend was INFERRED
                                    --   (Nothing when given explicitly)
  } deriving (Show, Eq)

-- | Scaffold a synapse.config.json, using 'detectors' to infer the right
-- transport and other settings for the current project.
--
-- Z2H-5: the backend name is either given explicitly (@Just backend@) or
-- inferred via 'inferBackend' (co-located service crate, then local
-- registry at @regHost:regPort@). There is no hardcoded fallback — when
-- nothing is inferable the result is a @Left@ naming the missing argument.
--
-- Returns @Left@ if the file already exists or no backend could be
-- determined. Returns @Right InitResult@ on success — 'irBackendNote'
-- carries the inference announcement the caller MUST surface.
--
-- Pass 'SynapseCC.Detect.defaultDetectors' for the standard set, or prepend
-- additional detectors for project-specific overrides.
initSynapseConfig
  :: FilePath      -- ^ Config path (usually 'synapseConfigPath')
  -> [Detector]    -- ^ Transport detectors
  -> Maybe Text    -- ^ Explicit backend name (Nothing = infer)
  -> Text          -- ^ Registry host for inference (default "127.0.0.1")
  -> Int           -- ^ Registry port for inference (default 4444)
  -> IO (Either Text InitResult)
initSynapseConfig path detectors mBackend regHost regPort = do
  exists <- doesFileExist path
  if exists
    then pure $ Left $ T.pack path <> " already exists"
    else do
      backendResult <- case mBackend of
        Just b  -> pure $ Right (b, Nothing)
        Nothing -> do
          outcome <- inferBackend regHost regPort
          pure $ case outcome of
            Inferred bi -> Right (biBackend bi, Just (biReason bi))
            AmbiguousRegistry known -> Left $
              "missing BACKEND argument — multiple backends are registered ("
              <> T.intercalate ", " known
              <> "). Pick one: synapse-cc init <backend>"
            NoInference -> Left $
              "missing BACKEND argument — no co-located Plexus service crate found"
              <> " and no backend discoverable on the registry at "
              <> regHost <> ":" <> T.pack (show regPort)
              <> ". Run: synapse-cc init <backend>"
      case backendResult of
        Left err -> pure $ Left err
        Right (backend, note) -> do
          hint <- runDetectors detectors
          let config = applyHint hint (defaultSynapseConfig backend)
          BL.writeFile path (Pretty.encodePretty' prettyConfig config)
          pure $ Right InitResult
            { irHint        = hint
            , irBackend     = backend
            , irBackendNote = note
            }

-- | Apply detected hints to the default config.
applyHint :: ProjectHint -> SynapseConfig -> SynapseConfig
applyHint hint cfg =
  case phTransport hint of
    Nothing -> cfg
    Just t  -> cfg
      { scTargets = Map.map (\tc -> tc { tcTransport = t }) (scTargets cfg)
      }

-- | aeson-pretty config: 2-space indent, semantic field ordering.
prettyConfig :: Pretty.Config
prettyConfig = Pretty.defConfig
  { Pretty.confCompare = Pretty.keyOrder
      [ "schema", "language", "backend", "url", "packageManager"
      , "watch", "pollInterval", "hotReload"
      , "targets", "generate", "transport", "outputDir", "smokePath", "sharedTransport"
      ]
  }