packages feed

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

{-# LANGUAGE OverloadedStrings #-}

-- | Project detection layer for synapse-cc init.
--
-- Inspects the current working directory to infer appropriate config values
-- (transport mode, output directory, etc.) so that generated synapse.config.json
-- is correct out of the box for common project structures.
--
-- Detectors are plain values — extend by prepending to the list passed to
-- 'runDetectors'. Earlier detectors win on a per-field basis.
module SynapseCC.Detect
  ( -- * Types
    ProjectHint(..)
  , Detector(..)
  , emptyHint

    -- * Running detectors
  , runDetectors

    -- * Default detector set
  , defaultDetectors

    -- * Individual detectors (for composition and testing)
  , detectTauri
  , detectVite
  , detectNextJS
  , detectNodeProject

    -- * Backend inference (Z2H-5)
  , BackendInference(..)
  , InferenceOutcome(..)
  , inferBackend
  , inferBackendFromCrate
  , inferBackendFromRegistry
  , parseServiceCrateName
  ) where

import Control.Applicative ((<|>))
import Control.Exception (try, SomeException)
import Control.Monad (foldM)
import Data.Maybe (listToMaybe, mapMaybe)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import System.Directory (doesDirectoryExist, doesFileExist, getCurrentDirectory)
import System.FilePath ((</>), takeDirectory)

import qualified Synapse.Backend.Discovery as D

import SynapseCC.Types (TransportType(..))

-- ============================================================================
-- Hint type
-- ============================================================================

-- | Inferred preferences for a new synapse.config.json.
-- 'Nothing' means "no opinion" — another detector or the hardcoded default wins.
data ProjectHint = ProjectHint
  { phTransport :: Maybe TransportType
    -- ^ 'WsTransport' (Node.js) or 'BrowserTransport' (native WebSocket)
  , phReason    :: Maybe T.Text
    -- ^ Human-readable explanation of why transport was chosen
  } deriving (Show, Eq)

-- | Hint with no opinions on any field.
emptyHint :: ProjectHint
emptyHint = ProjectHint Nothing Nothing

-- | Merge two hints: earlier/left values win per field.
-- Uses 'Control.Applicative.<|>' which picks the first 'Just'.
mergeHints :: ProjectHint -> ProjectHint -> ProjectHint
mergeHints a b = ProjectHint
  { phTransport = phTransport a <|> phTransport b
  , phReason    = phReason a    <|> phReason b
  }

-- ============================================================================
-- Detector type
-- ============================================================================

-- | A named detector that inspects the project and returns a hint.
--
-- Detectors are run in order; the first non-Nothing value wins per field.
-- Add custom detectors by prepending them to the list passed to 'runDetectors'.
data Detector = Detector
  { detectorName :: T.Text
    -- ^ Short label for debug output (e.g. "tauri", "vite")
  , runDetector  :: IO ProjectHint
    -- ^ IO action: inspect CWD, return hints (or 'emptyHint' if not applicable)
  }

-- ============================================================================
-- Running detectors
-- ============================================================================

-- | Run all detectors in order, merging results.
-- The first non-Nothing value per field wins (leftmost detector takes priority).
runDetectors :: [Detector] -> IO ProjectHint
runDetectors = foldM step emptyHint
  where
    step acc det = mergeHints acc <$> runDetector det

-- ============================================================================
-- Default detector set
-- ============================================================================

-- | Default detector list, in priority order:
--
-- @
-- detectTauri      -- src-tauri/          → browser
-- detectVite       -- vite.config.*       → browser
-- detectNextJS     -- next.config.*       → browser
-- detectNodeProject-- package.json        → ws (fallback)
-- @
--
-- Inject additional detectors by prepending to this list:
--
-- @
-- myDetector : defaultDetectors
-- @
defaultDetectors :: [Detector]
defaultDetectors =
  [ detectTauri
  , detectVite
  , detectNextJS
  , detectNodeProject
  ]

-- ============================================================================
-- Individual detectors
-- ============================================================================

-- | Tauri desktop-app projects.
-- Looks for 'src-tauri/' directory or 'tauri.conf.json' (v1 or v2).
-- Sets transport to 'BrowserTransport' — Tauri's WebView exposes a native
-- 'WebSocket' global, so the @ws@ npm package must not be imported.
detectTauri :: Detector
detectTauri = Detector "tauri" $ do
  hasSrcTauri  <- doesDirectoryExist "src-tauri"
  hasTauriConf <- anyExist
    [ "tauri.conf.json"
    , "src-tauri/tauri.conf.json"
    , "src-tauri/tauri.conf.json5"
    ]
  if hasSrcTauri || hasTauriConf
    then pure emptyHint
      { phTransport = Just BrowserTransport
      , phReason    = Just "Tauri project detected (src-tauri/) — using browser WebSocket"
      }
    else pure emptyHint

-- | Vite projects (browser-native bundler context).
-- Looks for 'vite.config.ts', 'vite.config.js', or 'vite.config.mts'.
-- Sets transport to 'BrowserTransport'.
detectVite :: Detector
detectVite = Detector "vite" $ do
  found <- anyExist
    [ "vite.config.ts"
    , "vite.config.js"
    , "vite.config.mts"
    , "vite.config.mjs"
    ]
  if found
    then pure emptyHint
      { phTransport = Just BrowserTransport
      , phReason    = Just "Vite project detected — using browser WebSocket"
      }
    else pure emptyHint

-- | Next.js projects.
-- Looks for 'next.config.js', 'next.config.ts', or 'next.config.mjs'.
-- Sets transport to 'BrowserTransport' (client components use native WebSocket).
detectNextJS :: Detector
detectNextJS = Detector "nextjs" $ do
  found <- anyExist
    [ "next.config.js"
    , "next.config.ts"
    , "next.config.mjs"
    ]
  if found
    then pure emptyHint
      { phTransport = Just BrowserTransport
      , phReason    = Just "Next.js project detected — using browser WebSocket"
      }
    else pure emptyHint

-- | Bare Node.js projects (package.json present, no framework marker).
-- Sets transport to 'WsTransport' — the @ws@ npm package is available in Node.
-- This is the fallback: runs last, so framework detectors override it.
detectNodeProject :: Detector
detectNodeProject = Detector "node" $ do
  found <- doesFileExist "package.json"
  if found
    then pure emptyHint
      { phTransport = Just WsTransport
      , phReason    = Just "Node.js project detected — using ws WebSocket"
      }
    else pure emptyHint

-- ============================================================================
-- Helpers
-- ============================================================================

-- | Return True if any of the given paths exists as a file.
anyExist :: [FilePath] -> IO Bool
anyExist paths = foldM step False paths
  where
    step True  _    = pure True
    step False path = doesFileExist path

-- ============================================================================
-- Backend inference (Z2H-5)
-- ============================================================================

-- | A successfully inferred backend name plus a human-readable explanation
-- of where it came from. The explanation is ALWAYS surfaced to the user —
-- bare @synapse-cc init@ must never silently pick a backend.
data BackendInference = BackendInference
  { biBackend :: !T.Text  -- ^ Inferred backend identifier
  , biReason  :: !T.Text  -- ^ Why (announced to the user)
  } deriving (Show, Eq)

-- | Outcome of trying to infer a backend when none was given.
data InferenceOutcome
  = Inferred !BackendInference
    -- ^ Exactly one candidate found
  | AmbiguousRegistry ![T.Text]
    -- ^ Registry reachable but lists multiple backends — user must choose
  | NoInference
    -- ^ Nothing inferable (no service crate, registry empty/unreachable)
  deriving (Show, Eq)

-- | Infer the backend for @synapse-cc init@ when no argument was given.
--
-- Sources, in priority order:
--
--  1. A co-located Plexus service crate: a @Cargo.toml@ in the current
--     directory (or its parent) whose package depends on a plexus service
--     crate (@plexus-rpc@, @plexus-core@, or @plexus-transport@). The crate
--     name becomes the backend name.
--  2. The local Plexus registry at the given host:port (default
--     @127.0.0.1:4444@), reusing the existing
--     'Synapse.Backend.Discovery' mechanism — but only when it lists
--     exactly one backend. Multiple backends are ambiguous.
inferBackend
  :: T.Text  -- ^ registry host
  -> Int     -- ^ registry port
  -> IO InferenceOutcome
inferBackend regHost regPort = do
  cwd <- getCurrentDirectory
  fromCrate <- inferBackendFromCrate cwd
  case fromCrate of
    Just bi -> pure (Inferred bi)
    Nothing -> inferBackendFromRegistry regHost regPort

-- | Look for a co-located Plexus service crate: @Cargo.toml@ in the given
-- directory, then in its parent. Returns the crate's package name when the
-- crate looks like a Plexus service (see 'parseServiceCrateName').
inferBackendFromCrate :: FilePath -> IO (Maybe BackendInference)
inferBackendFromCrate dir = go [dir, takeDirectory dir]
  where
    go [] = pure Nothing
    go (d:rest) = do
      let manifest = d </> "Cargo.toml"
      exists <- doesFileExist manifest
      if not exists
        then go rest
        else do
          content <- TIO.readFile manifest
          case parseServiceCrateName content of
            Just name -> pure $ Just BackendInference
              { biBackend = name
              , biReason  = "Inferred backend '" <> name
                         <> "' from co-located Plexus service crate ("
                         <> T.pack manifest <> ")"
              }
            Nothing -> go rest

-- | Extract the package name from Cargo.toml text — but only when the crate
-- depends on a Plexus service crate (@plexus-rpc@, @plexus-core@, or
-- @plexus-transport@). A random Rust crate is NOT a backend.
--
-- This is a line-oriented scan, not a full TOML parser: it understands
-- @[package]@ section @name = "..."@ entries, @[dependencies]@-style section
-- keys, and table-form deps like @[dependencies.plexus-rpc]@.
parseServiceCrateName :: T.Text -> Maybe T.Text
parseServiceCrateName toml =
  let lns = map T.strip (T.lines toml)
      -- Pair each line with its enclosing [section]
      sectioned = go "" lns
        where
          go _ [] = []
          go sec (l:rest)
            | Just hdr <- sectionHeader l = (hdr, l) : go hdr rest
            | otherwise                   = (sec, l) : go sec rest
      sectionHeader l = do
        inner <- T.stripPrefix "[" l
        body  <- T.stripSuffix "]" inner
        pure (T.strip body)
      isDepSection sec =
        sec == "dependencies" || "dependencies." `T.isPrefixOf` sec
        || sec == "workspace.dependencies" || "workspace.dependencies." `T.isPrefixOf` sec
      plexusCrates = ["plexus-rpc", "plexus-core", "plexus-transport"]
      -- A dep either appears as a key in a deps section…
      keyOf l = T.strip (fst (T.breakOn "=" l))
      hasInlineDep = any (\(sec, l) -> isDepSection sec && keyOf l `elem` plexusCrates) sectioned
      -- …or as a table-form section header [dependencies.plexus-rpc]
      hasTableDep = any (\(sec, _) ->
        any (\c -> sec == "dependencies." <> c
                || sec == "workspace.dependencies." <> c) plexusCrates) sectioned
      pkgName = listToMaybe $ mapMaybe
        (\(sec, l) ->
          if sec == "package" && keyOf l == "name"
            then unquote (T.strip (T.drop 1 (snd (T.breakOn "=" l))))
            else Nothing)
        sectioned
      unquote t = T.stripPrefix "\"" =<< T.stripSuffix "\"" t
  in if hasInlineDep || hasTableDep then pkgName else Nothing

-- | Query the local registry for registered backends, reusing the existing
-- 'Synapse.Backend.Discovery' machinery (no new registry protocol — Z2H-6
-- owns registration mechanics). Infers only when EXACTLY one backend is
-- registered; multiple candidates are ambiguous and reported as such.
-- Unreachable or empty registry → 'NoInference' (never an error here —
-- the caller decides how to phrase the failure).
inferBackendFromRegistry :: T.Text -> Int -> IO InferenceOutcome
inferBackendFromRegistry regHost regPort = do
  let discovery = D.registryDiscovery regHost regPort
  result <- try (D.discoverBackends discovery) :: IO (Either SomeException [D.Backend])
  case result of
    Left _        -> pure NoInference
    Right []      -> pure NoInference
    Right [b]     -> pure $ Inferred BackendInference
      { biBackend = D.backendName b
      , biReason  = "Inferred backend '" <> D.backendName b
                 <> "' — the only backend registered on the local registry at "
                 <> regHost <> ":" <> T.pack (show regPort)
      }
    Right many    -> pure $ AmbiguousRegistry (map D.backendName many)