packages feed

plexus-synapse-0.3.0.0: src/Synapse/Renderer.hs

{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE TemplateHaskell #-}

-- | Template-based output renderer
--
-- Runtime-configurable output rendering using Mustache templates.
-- Templates are resolved by METHOD name (not content_type), supporting
-- unified templates with all variants visible.
--
-- = Template Resolution
--
-- Templates are searched in order:
-- 1. Project-local: @.substrate/templates/{namespace}/{method}.mustache@
-- 2. User global: @~/.config/synapse/templates/{namespace}/{method}.mustache@
-- 3. Built-in defaults
--
-- = Method Hints
--
-- When a method path is set via 'withMethodPath', template lookup uses
-- that path instead of parsing the content_type. This allows unified
-- templates that handle all variants of a method's return type.
--
-- = Usage
--
-- @
-- cfg <- defaultRendererConfig
-- let cfg' = withMethodPath cfg ["cone", "chat"]
-- result <- renderItem cfg' item
-- case result of
--   Just text -> TIO.putStrLn text
--   Nothing   -> printJson item  -- fallback
-- @
module Synapse.Renderer
  ( -- * Configuration
    RendererConfig(..)
  , defaultRendererConfig
  , withMethodPath
  , OutputMode(..)

    -- * Rendering
  , renderItem
  , renderValue
  , renderWithTemplate
  , prettyValue

    -- * Template Resolution
  , resolveTemplate
  , resolveTemplateForMethod
  , templateSearchPaths

    -- * Template Loading
  , loadTemplate
  , TemplateCache
  , newTemplateCache
  , getCachedTemplate
  ) where

import Control.Exception (catch, SomeException)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.Aeson (Value(..), encode)
import qualified Data.Aeson as A
import qualified Data.Aeson.Key as K
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString.Lazy.Char8 as LBS
import Data.IORef
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Scientific (Scientific, floatingOrInteger)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Vector as V
import System.Directory (doesFileExist, getCurrentDirectory, getHomeDirectory)
import System.FilePath ((</>), (<.>))
import Text.Mustache (Template, toMustache)
import Text.Mustache.Compile (localAutomaticCompile)
import Text.Mustache.Render (substituteValue)
import qualified Text.Mustache.Types as MT

import Synapse.Schema.Types
  ( HubStreamItem
  , pattern HubData
  , pattern HubProgress
  , pattern HubError
  , pattern HubDone
  )

-- ============================================================================
-- Types
-- ============================================================================

-- | Output mode for rendering
data OutputMode
  = ModeTemplate      -- ^ Use templates when available
  | ModeJson          -- ^ Raw JSON stream items
  | ModeRaw           -- ^ Just the content value
  deriving stock (Show, Eq)

-- | Renderer configuration
data RendererConfig = RendererConfig
  { rcSearchPaths :: [FilePath]     -- ^ Template search paths
  , rcMode        :: OutputMode     -- ^ Output mode
  , rcCache       :: TemplateCache  -- ^ Template cache
  , rcMethodPath  :: Maybe [Text]   -- ^ Method path hint for template lookup
  }

-- | Template cache to avoid re-parsing
newtype TemplateCache = TemplateCache (IORef (Map FilePath Template))

-- ============================================================================
-- Configuration
-- ============================================================================

-- | Create a new template cache
newTemplateCache :: IO TemplateCache
newTemplateCache = TemplateCache <$> newIORef Map.empty

-- | Get default renderer configuration
defaultRendererConfig :: IO RendererConfig
defaultRendererConfig = do
  paths <- templateSearchPaths
  cache <- newTemplateCache
  pure $ RendererConfig
    { rcSearchPaths = paths
    , rcMode = ModeTemplate
    , rcCache = cache
    , rcMethodPath = Nothing
    }

-- | Set the method path for template resolution
--
-- When set, template lookup will use this path instead of parsing
-- the content_type. This allows unified templates that handle all
-- variants of a method's return type.
--
-- Example: @withMethodPath cfg ["cone", "chat"]@ will look for
-- @cone/chat.mustache@ regardless of the content_type.
withMethodPath :: RendererConfig -> [Text] -> RendererConfig
withMethodPath cfg path = cfg { rcMethodPath = Just path }

-- | Get template search paths
templateSearchPaths :: IO [FilePath]
templateSearchPaths = do
  cwd <- getCurrentDirectory
  home <- getHomeDirectory
  pure
    [ cwd </> ".substrate" </> "templates"
    , home </> ".config" </> "synapse" </> "templates"
    ]

-- ============================================================================
-- Template Resolution
-- ============================================================================

-- | Resolve template path for a method path
--
-- Search order:
--   1. {searchPath}/{namespace}/{method}.mustache (exact match)
--   2. {searchPath}/{namespace}/default.mustache (namespace default)
--   3. {searchPath}/default.mustache (global default)
resolveTemplateForMethod :: RendererConfig -> [Text] -> IO (Maybe FilePath)
resolveTemplateForMethod cfg methodPath = case methodPath of
  [] -> pure Nothing
  [method] -> resolveTemplateByParts cfg "default" method
  parts ->
    let namespace = T.intercalate "." (init parts)
        method = last parts
    in resolveTemplateByParts cfg namespace method

-- | Resolve template by namespace and method parts
resolveTemplateByParts :: RendererConfig -> Text -> Text -> IO (Maybe FilePath)
resolveTemplateByParts cfg namespace method = do
  let candidates =
        -- Exact method match
        [ path </> T.unpack namespace </> T.unpack method <.> "mustache"
        | path <- rcSearchPaths cfg
        ]
        -- Namespace default
        ++ [ path </> T.unpack namespace </> "default" <.> "mustache"
           | path <- rcSearchPaths cfg
           ]
        -- Global default
        ++ [ path </> "default.mustache" | path <- rcSearchPaths cfg ]
  firstExisting candidates

-- | Resolve template path for a content type (legacy, fallback)
--
-- Content type format: "namespace.variant" (e.g., "cone.start", "health.status")
-- This is used when no method path hint is set.
resolveTemplate :: RendererConfig -> Text -> IO (Maybe FilePath)
resolveTemplate cfg contentType =
  case rcMethodPath cfg of
    -- Method path hint available: use it
    Just methodPath -> resolveTemplateForMethod cfg methodPath
    -- No hint: fall back to content_type parsing
    Nothing -> do
      let (namespace, method) = parseContentType contentType
      resolveTemplateByParts cfg namespace method

-- | Parse content_type into (namespace, method/variant)
parseContentType :: Text -> (Text, Text)
parseContentType ct = case T.splitOn "." ct of
  [ns, m] -> (ns, m)
  [m]     -> ("default", m)
  parts   -> (T.intercalate "." (init parts), last parts)

-- | Find first existing file from candidates
firstExisting :: [FilePath] -> IO (Maybe FilePath)
firstExisting [] = pure Nothing
firstExisting (p:ps) = do
  exists <- doesFileExist p
  if exists then pure (Just p) else firstExisting ps

-- ============================================================================
-- Template Loading
-- ============================================================================

-- | Load a template from disk
loadTemplate :: FilePath -> IO (Either Text Template)
loadTemplate path = do
  result <- localAutomaticCompile path
  case result of
    Left err -> pure $ Left $ T.pack $ show err
    Right template -> pure $ Right template

-- | Get template from cache, loading if needed
getCachedTemplate :: RendererConfig -> FilePath -> IO (Either Text Template)
getCachedTemplate cfg path = do
  let TemplateCache cacheRef = rcCache cfg
  cache <- readIORef cacheRef
  case Map.lookup path cache of
    Just template -> pure $ Right template
    Nothing -> do
      result <- loadTemplate path
      case result of
        Right template -> do
          modifyIORef' cacheRef (Map.insert path template)
          pure $ Right template
        Left err -> pure $ Left err

-- ============================================================================
-- Rendering
-- ============================================================================

-- | Render a stream item using templates
renderItem :: RendererConfig -> HubStreamItem -> IO (Maybe Text)
renderItem cfg item = case rcMode cfg of
  ModeJson -> pure Nothing  -- Caller should use JSON
  ModeRaw  -> pure Nothing  -- Caller should extract content
  ModeTemplate -> case item of
    HubData _ _ contentType content ->
      renderValue cfg contentType content
    HubProgress _ _ msg _ ->
      pure $ Just msg
    HubError _ _ err _ ->
      pure $ Just $ "Error: " <> err
    HubDone _ _ ->
      pure Nothing
    _ -> pure Nothing  -- Handle other variants (e.g., HubGuidance)

-- | Render a value using template for content type
renderValue :: RendererConfig -> Text -> Value -> IO (Maybe Text)
renderValue cfg contentType value = do
  mPath <- resolveTemplate cfg contentType
  case mPath of
    Nothing -> pure Nothing
    Just path -> do
      result <- getCachedTemplate cfg path
      case result of
        Left _err -> pure Nothing
        Right template -> pure $ Just $ renderWithTemplate template value

-- | Render a value with a specific template
renderWithTemplate :: Template -> Value -> Text
renderWithTemplate template value =
  let wrapped = wrapDiscriminatedUnion value
  in substituteValue template (toMustache wrapped)

-- | Wrap discriminated union data for mustache template compatibility
--
-- Templates expect variant data wrapped like: {"echo": {"count": 1, ...}}
-- But actual responses are flat: {"type": "echo", "count": 1, ...}
--
-- This transforms flat discriminated unions into wrapped form so
-- mustache sections like {{#echo}}...{{/echo}} will match.
wrapDiscriminatedUnion :: Value -> Value
wrapDiscriminatedUnion (Object obj) =
  case KM.lookup "type" obj of
    Just (String variant) ->
      -- Wrap: {"variant": original_object}
      Object $ KM.singleton (K.fromText variant) (Object obj)
    _ -> Object obj
wrapDiscriminatedUnion other = other

-- ============================================================================
-- Pretty Printing
-- ============================================================================

-- | Pretty-print a JSON value in human-readable format (no JSON syntax)
prettyValue :: Value -> Text
prettyValue = prettyIndent 0

prettyIndent :: Int -> Value -> Text
prettyIndent indent val = case val of
  Null -> "null"
  Bool True -> "true"
  Bool False -> "false"
  Number n -> case floatingOrInteger n of
    Left d -> T.pack $ show (d :: Double)
    Right i -> T.pack $ show (i :: Integer)
  String s -> s
  Array arr
    | V.null arr -> "[]"
    | otherwise -> T.intercalate "\n" $
        map (\v -> spaces <> "- " <> prettyInline v) (V.toList arr)
  Object obj
    | KM.null obj -> "{}"
    | otherwise -> T.intercalate "\n" $
        map (\(k, v) -> spaces <> K.toText k <> ": " <> prettyChild v) (KM.toList obj)
  where
    spaces = T.replicate indent "  "

    -- For array items and object values, decide inline vs block
    prettyChild v = case v of
      Object o | not (KM.null o) -> "\n" <> prettyIndent (indent + 1) v
      Array a | not (V.null a) -> "\n" <> prettyIndent (indent + 1) v
      _ -> prettyInline v

    -- Inline rendering for simple values
    prettyInline v = case v of
      Null -> "null"
      Bool True -> "true"
      Bool False -> "false"
      Number n -> case floatingOrInteger n of
        Left d -> T.pack $ show (d :: Double)
        Right i -> T.pack $ show (i :: Integer)
      String s -> s
      Array arr -> T.intercalate ", " $ map prettyInline (V.toList arr)
      Object obj -> T.intercalate ", " $
        map (\(k, v') -> K.toText k <> ": " <> prettyInline v') (KM.toList obj)