packages feed

synapse-cc-0.2.0: src/SynapseCC/Cache.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Caching support with version-aware invalidation
module SynapseCC.Cache
  ( -- * Cache operations
    validateCache
  , readIRCacheManifest
  , readCodeCacheManifest
  , writeIRCacheManifest
  , writeCodeCacheManifest
  , getCacheDir
  , clearCache

    -- * Shared utilities
  , expandTilde

    -- * V2 Validation (internal, exposed for testing)
  , validatePluginCaches
  , validatePluginCache
  ) where

import Control.Exception (catch, SomeException)
import Control.Monad (when, forM_)
import Data.Aeson (FromJSON, encode, eitherDecodeFileStrict)
import qualified Data.ByteString.Lazy as BL
import Data.List (isPrefixOf)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Maybe (isJust)
import qualified Data.Set as Set
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (getCurrentTime)
import Data.Time.Format (formatTime, defaultTimeLocale)
import System.Directory (createDirectoryIfMissing, doesFileExist, removeDirectoryRecursive, getHomeDirectory)
import System.FilePath ((</>))

import SynapseCC.Logging (logDebug)
import SynapseCC.Types
import qualified SynapseCC.Dependency as Dep

-- ============================================================================
-- Cache Directory Structure
-- ============================================================================

-- Cache directory structure:
-- ~/.cache/plexus-codegen/
-- ├── synapse/
-- │   ├── schemas/          # Level 1: Schema cache (future)
-- │   └── ir/               # Level 2: IR cache
-- │       ├── manifest.json # IR cache manifest
-- │       ├── cone.json     # Generated IR fragments
-- │       └── arbor.json
-- └── hub-codegen/
--     └── typescript/       # Level 3: Code cache
--         ├── manifest.json # Code cache manifest
--         ├── cone/         # Generated code per plugin
--         └── arbor/

-- | Get the cache base directory, expanding ~ to home directory
getCacheDir :: Options -> IO FilePath
getCacheDir opts = expandTilde (optCacheDir opts)

-- | Expand ~ to home directory if path starts with ~
expandTilde :: FilePath -> IO FilePath
expandTilde path
  | "~/" `isPrefixOf` path = do
      home <- getHomeDirectory
      pure $ home ++ drop 1 path  -- Replace ~ with home, keeping the /
  | path == "~" = getHomeDirectory
  | otherwise = pure path

-- | Get IR cache directory
getIRCacheDir :: Options -> Backend -> IO FilePath
getIRCacheDir opts backend = do
  baseDir <- getCacheDir opts
  pure $ baseDir </> "synapse" </> "ir" </> T.unpack (backendName backend)

-- | Get code cache directory
getCodeCacheDir :: Options -> Backend -> Target -> IO FilePath
getCodeCacheDir opts backend target = do
  baseDir <- getCacheDir opts
  pure $ baseDir </> "synapse-cc" </> "code" </> T.unpack (targetToText target) </> T.unpack (backendName backend)

-- ============================================================================
-- Cache Reading
-- ============================================================================

-- | Generic manifest reader: look for manifest.json in cacheDir and decode it.
readManifest :: FromJSON a => FilePath -> String -> IO (Either SynapseCCError a)
readManifest cacheDir label = do
  let manifestPath = cacheDir </> "manifest.json"
  exists <- doesFileExist manifestPath
  if not exists
    then pure $ Left $ CacheError $ T.pack label <> " manifest not found"
    else do
      result <- eitherDecodeFileStrict manifestPath
      case result of
        Left err     -> pure $ Left $ CacheError $ "Failed to parse " <> T.pack label <> " manifest: " <> T.pack err
        Right manifest -> pure $ Right manifest

-- | Read IR cache manifest
readIRCacheManifest :: Options -> Backend -> IO (Either SynapseCCError IRCacheManifest)
readIRCacheManifest opts backend = do
  cacheDir <- getIRCacheDir opts backend
  readManifest cacheDir "IR cache"

-- | Read code cache manifest
readCodeCacheManifest :: Options -> Backend -> Target -> IO (Either SynapseCCError CodeCacheManifest)
readCodeCacheManifest opts backend target = do
  cacheDir <- getCodeCacheDir opts backend target
  readManifest cacheDir "code cache"

-- ============================================================================
-- Cache Validation
-- ============================================================================

-- | Validate cache with version-aware checking
-- Returns CacheResult indicating what needs regeneration
validateCache :: Config -> ToolLocations -> IO CacheResult
validateCache config tools = do
  let opts = cfgOptions config
      backend = cfgBackend config
      target = cfgTarget config
      debug = optDebug opts
      synapseVer    = toolSynapseVersion tools
      hubCodegenVer = toolHubCodegenVersion tools

  -- If --force flag is set, skip cache entirely
  if optForce opts
    then do
      logDebug debug "--force flag set, skipping cache"
      pure $ CacheMiss ManifestNotFound
    else do
      -- Try to read IR cache manifest
      irManifestResult <- readIRCacheManifest opts backend
      case irManifestResult of
        Left err -> do
          logDebug debug $ "IR cache miss: " <> formatError err
          pure $ CacheMiss ManifestNotFound

        Right irManifest -> do
          -- Check tool versions against IR cache
          let irToolchain = ircmToolchain irManifest
          if tvSynapseCC irToolchain /= synapseCCVersion ||
             tvSynapse irToolchain /= synapseVer
            then do
              logDebug debug "Tool versions changed, invalidating IR cache"
              pure $ CacheMiss ToolVersionChanged
            else do
              -- IR cache valid, check code cache
              codeManifestResult <- readCodeCacheManifest opts backend target
              case codeManifestResult of
                Left err -> do
                  logDebug debug $ "Code cache miss: " <> formatError err
                  -- IR is cached, but code is not
                  pure $ CacheMiss ManifestNotFound

                Right codeManifest -> do
                  -- Check tool versions against code cache
                  let codeToolchain = ccmToolchain codeManifest
                  if tvSynapseCC codeToolchain /= synapseCCVersion ||
                     tvSynapse codeToolchain /= synapseVer ||
                     isJust (tvHubCodegen codeToolchain) && tvHubCodegen codeToolchain /= Just hubCodegenVer
                    then do
                      logDebug debug "Tool versions changed, invalidating code cache"
                      pure $ CacheMiss ToolVersionChanged
                    else do
                      -- V2: Check plugin hashes for granular invalidation
                      logDebug debug "Checking plugin hashes for granular invalidation..."
                      validatePluginCaches irManifest codeManifest debug

-- ============================================================================
-- V2 Granular Cache Validation
-- ============================================================================

-- | Validate plugin caches with V2 granular hash checking
validatePluginCaches :: IRCacheManifest -> CodeCacheManifest -> Bool -> IO CacheResult
validatePluginCaches irManifest codeManifest debug = do
  let irPlugins = ircmPlugins irManifest
      codePlugins = ccmPlugins codeManifest

  -- Monolithic code cache: a single "default" entry means the last run was a
  -- full-output pass with no per-plugin breakdown.  Tool versions already match
  -- (checked before we get here), so treat this as a full cache hit.
  -- Output-directory existence is verified by runPipeline's FullCacheHit handler.
  if Map.size codePlugins == 1 && Map.member "default" codePlugins
    then do
      logDebug debug $ "Full cache hit (" <> T.pack (show (Map.size irPlugins)) <> " plugins)"
      pure FullCacheHit
    else do
      let allPluginNames = Set.toList $ Set.union (Map.keysSet irPlugins) (Map.keysSet codePlugins)

      -- Check each plugin for direct invalidation (schema/IR hash changes)
      results <- mapM (validatePluginCache irManifest codeManifest debug) allPluginNames

      -- Collect directly invalid plugins
      let directlyInvalidPlugins = Set.fromList [name | (name, False) <- results]
          directlyValidPlugins = [name | (name, True) <- results]

      -- Build dependency graph from IR cache
      let depGraph = Map.map ipcDependencies irPlugins

      -- Find transitively invalid plugins using dependency resolution
      let allInvalidPlugins = Dep.findInvalidPlugins directlyInvalidPlugins depGraph
          invalidPluginsList = Set.toList allInvalidPlugins
          validPluginsList = filter (`Set.notMember` allInvalidPlugins) directlyValidPlugins

      -- Report transitive invalidation
      when debug $ do
        let transitivelyInvalid = Set.difference allInvalidPlugins directlyInvalidPlugins
        when (not $ Set.null transitivelyInvalid) $ do
          logDebug debug "Transitive invalidation due to dependencies:"
          forM_ (Set.toList transitivelyInvalid) $ \name ->
            logDebug debug $ "    - " <> name <> " (depends on invalid plugin)"

      if null invalidPluginsList
        then do
          logDebug debug $ "Full cache hit - all " <> T.pack (show (length validPluginsList)) <> " plugins valid"
          pure FullCacheHit
        else do
          when debug $ do
            logDebug debug "Partial cache hit:"
            logDebug debug $ "    Valid: " <> T.pack (show (length validPluginsList)) <> " plugins"
            logDebug debug $ "    Invalid: " <> T.pack (show (length invalidPluginsList)) <> " plugins (including transitive)"
            forM_ invalidPluginsList $ \name ->
              logDebug debug $ "      - " <> name
          pure $ PartialCacheHit validPluginsList invalidPluginsList

-- | Validate a single plugin's cache
-- Returns (plugin name, is valid)
validatePluginCache :: IRCacheManifest -> CodeCacheManifest -> Bool -> Text -> IO (Text, Bool)
validatePluginCache irManifest codeManifest debug pluginName = do
  let irPlugins = ircmPlugins irManifest
      codePlugins = ccmPlugins codeManifest

  case (Map.lookup pluginName irPlugins, Map.lookup pluginName codePlugins) of
    (Nothing, Nothing) -> do
      -- Plugin not in either cache - invalid
      logDebug debug $ "Plugin '" <> pluginName <> "' not found in cache"
      pure (pluginName, False)

    (Just _irCache, Nothing) -> do
      -- IR cached but code not generated - invalid
      logDebug debug $ "Plugin '" <> pluginName <> "' has IR cache but no code cache"
      pure (pluginName, False)

    (Nothing, Just _codeCache) -> do
      -- Code exists but no IR cache - invalid (shouldn't happen)
      logDebug debug $ "Plugin '" <> pluginName <> "' has code cache but no IR cache"
      pure (pluginName, False)

    (Just irCache, Just codeCache) -> do
      -- Both caches exist - perform V2 granular validation
      validatePluginV2 irCache codeCache pluginName debug

-- | V2 validation logic using self_hash and children_hash
validatePluginV2 :: IRPluginCache -> CodePluginCache -> Text -> Bool -> IO (Text, Bool)
validatePluginV2 irCache codeCache pluginName debug = do
  -- Check if IR hash in code cache matches IR hash in IR cache
  let cachedIRHash = ipcIRHash irCache
      codeIRHash = cpcIRHash codeCache
      cachedSelfHash = ipcSelfHash irCache
      cachedChildrenHash = ipcChildrenHash irCache

  if T.null cachedIRHash || T.null codeIRHash
    then do
      -- Missing hash data - consider invalid for safety
      reportInvalidation pluginName "Missing hash data" debug
      pure (pluginName, False)
    else if cachedIRHash == codeIRHash
      then do
        -- IR hash matches - cache is valid
        reportCacheHit pluginName cachedSelfHash cachedChildrenHash debug
        pure (pluginName, True)
      else do
        -- IR hash changed - analyze granularity with V2 hashes
        analyzeHashChange pluginName irCache codeIRHash debug
        pure (pluginName, False)

-- | Report cache hit with V2 hash details
reportCacheHit :: Text -> Text -> Text -> Bool -> IO ()
reportCacheHit pluginName selfHash childrenHash debug = do
  logDebug debug $ "Cache hit: '" <> pluginName <> "'"
  when (not $ T.null selfHash) $
    logDebug debug $ "    self_hash:     " <> T.take 8 selfHash <> "..."
  when (not $ T.null childrenHash) $
    logDebug debug $ "    children_hash: " <> T.take 8 childrenHash <> "..."

-- | Report cache invalidation with detailed reason
reportInvalidation :: Text -> String -> Bool -> IO ()
reportInvalidation pluginName reason debug = do
  logDebug debug $ "Cache miss: '" <> pluginName <> "'"
  logDebug debug $ "    Reason: " <> T.pack reason

-- | Analyze what changed using V2 granular hashes
analyzeHashChange :: Text -> IRPluginCache -> Text -> Bool -> IO ()
analyzeHashChange pluginName irCache newCodeHash debug = do
  let cachedIRHash = ipcIRHash irCache
      cachedSelfHash = ipcSelfHash irCache
      cachedChildrenHash = ipcChildrenHash irCache

  logDebug debug $ "Cache miss: '" <> pluginName <> "'"
  logDebug debug "    IR hash changed:"
  logDebug debug $ "      cached: " <> T.take 8 cachedIRHash <> "..."
  logDebug debug $ "      new:    " <> T.take 8 newCodeHash <> "..."

  -- V2 granular analysis (when fresh hashes are available)
  if T.null cachedSelfHash && T.null cachedChildrenHash
    then do
      logDebug debug "    V2 hashes unavailable - using V1 validation"
      logDebug debug "    Reason: Schema or IR changed (full regeneration needed)"
    else do
      logDebug debug "    V2 granular analysis:"
      when (not $ T.null cachedSelfHash) $
        logDebug debug $ "      self_hash:     " <> T.take 8 cachedSelfHash <> "... (methods)"
      when (not $ T.null cachedChildrenHash) $
        logDebug debug $ "      children_hash: " <> T.take 8 cachedChildrenHash <> "... (dependencies)"
      logDebug debug "    Note: Fresh schema comparison needed for granular invalidation"
      logDebug debug "          (requires fetching current schema from backend)"

-- ============================================================================
-- Cache Writing
-- ============================================================================

-- | Write IR cache manifest
writeIRCacheManifest :: Options -> Backend -> Map Text IRPluginCache -> Text -> IO ()
writeIRCacheManifest opts backend plugins synapseVer = do
  cacheDir <- getIRCacheDir opts backend
  createDirectoryIfMissing True cacheDir

  currentTime <- getCurrentTime
  let timestamp = T.pack $ formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" currentTime

  let manifest = IRCacheManifest
        { ircmVersion = "1.0"
        , ircmIRVersion = "2.0"
        , ircmToolchain = ToolchainVersions
            { tvSynapseCC = synapseCCVersion
            , tvSynapse = synapseVer
            , tvHubCodegen = Nothing
            }
        , ircmUpdatedAt = timestamp
        , ircmPlugins = plugins
        }

  let manifestPath = cacheDir </> "manifest.json"
  BL.writeFile manifestPath (encode manifest)

-- | Write code cache manifest
writeCodeCacheManifest :: Options -> Backend -> Target -> Map Text CodePluginCache -> Text -> Text -> IO ()
writeCodeCacheManifest opts backend target plugins synapseVer hubCodegenVer = do
  cacheDir <- getCodeCacheDir opts backend target
  createDirectoryIfMissing True cacheDir

  currentTime <- getCurrentTime
  let timestamp = T.pack $ formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" currentTime

  let manifest = CodeCacheManifest
        { ccmVersion = "1.0"
        , ccmTarget = targetToText target
        , ccmToolchain = ToolchainVersions
            { tvSynapseCC = synapseCCVersion
            , tvSynapse = synapseVer
            , tvHubCodegen = Just hubCodegenVer
            }
        , ccmUpdatedAt = timestamp
        , ccmPlugins = plugins
        }

  let manifestPath = cacheDir </> "manifest.json"
  BL.writeFile manifestPath (encode manifest)

-- ============================================================================
-- Cache Management
-- ============================================================================

-- | Clear the entire cache directory
clearCache :: Options -> IO ()
clearCache opts = do
  cacheDir <- getCacheDir opts
  exists <- doesFileExist cacheDir
  when exists $ do
    catch
      (removeDirectoryRecursive cacheDir)
      (\(e :: SomeException) -> logDebug True $ "Warning: Failed to clear cache: " <> T.pack (show e))