packages feed

hydra-0.15.0: src/main/haskell/Hydra/Digest.hs

-- | Per-module source digests for skipping inference when nothing has changed.
--
-- The cache is intentionally all-or-nothing: if every module's DSL source
-- hash matches the stored digest and every expected JSON output exists,
-- the caller short-circuits. Otherwise it falls through to full inference
-- and overwrites the digest on success.

module Hydra.Digest (
    -- v1 API (backwards-compatible namespace → hash map)
    DigestMap,
    discoverNamespaceFiles,
    hashFile,
    hashUniverse,
    readDigest,
    writeDigest,
    digestPath,
    -- Encoder identity: a hash of the JSON encode/decode/model/writer
    -- DSL sources. When the universe-wide digest's encoderId doesn't
    -- match the one computed from current sources, every module's
    -- on-disk JSON is potentially stale (the format changed even
    -- though the per-namespace DSL hashes didn't). The cache check
    -- treats this as a universal miss.
    computeEncoderId,
    readEncoderId,
    writeUniverseDigest,
    -- v2 API (richer digest with inputs, outputs, generator stamp)
    Digest(..),
    DigestEntry(..),
    DigestKind(..),
    emptyDigest,
    readDigestV2,
    writeDigestV2,
    hashFileV2,
    digestsMatch,
    verifyOutputsExist,
    generatorStamp,
) where

import Hydra.Packaging (Module(..), Namespace(..))

import qualified Data.ByteString.Lazy as BL
import qualified Data.Digest.Pure.SHA as SHA
import qualified Data.Map as M
import qualified Data.List as L
import qualified Data.Maybe as Y
import qualified System.Directory as SD
import qualified System.Environment as SE
import qualified System.FilePath as FP
import qualified Text.Regex.TDFA as RE
import qualified Control.Exception as E
import qualified Control.Monad as CM


type DigestMap = M.Map Namespace String


-- | Root directory where DSL source files live. Relative paths are resolved
-- from the Haskell head's working directory ("heads/haskell"), which matches
-- the existing hs-source-dirs in package.yaml.
packagesRoot :: FilePath
packagesRoot = ".." FP.</> ".." FP.</> "packages"


-- | Walk packages/*/src/main/haskell/Hydra/Sources/ to build a namespace →
-- file map. Each source file must declare its namespace with a top-level
-- line of the form: ns = Namespace "hydra.foo.bar"
--
-- Files without a recognizable ns declaration are silently skipped.
discoverNamespaceFiles :: IO (M.Map Namespace FilePath)
discoverNamespaceFiles = do
    exists <- SD.doesDirectoryExist packagesRoot
    if not exists then return M.empty else do
      pkgs <- SD.listDirectory packagesRoot
      pairs <- L.concat <$> mapM scanPackage pkgs
      return $ M.fromList pairs
  where
    scanPackage pkg = do
      let srcDir = packagesRoot FP.</> pkg FP.</> "src" FP.</> "main"
                               FP.</> "haskell" FP.</> "Hydra" FP.</> "Sources"
      isDir <- SD.doesDirectoryExist srcDir
      if not isDir then return [] else do
        files <- listHaskellFiles srcDir
        Y.catMaybes <$> mapM extractNs files

    listHaskellFiles dir = do
      entries <- SD.listDirectory dir
      subResults <- CM.forM entries $ \e -> do
        let p = dir FP.</> e
        isDir <- SD.doesDirectoryExist p
        if isDir
          then listHaskellFiles p
          else if ".hs" `L.isSuffixOf` e then return [p] else return []
      return $ concat subResults

    extractNs :: FilePath -> IO (Maybe (Namespace, FilePath))
    extractNs fp = do
      content <- E.try (readFile fp) :: IO (Either E.SomeException String)
      case content of
        Left _ -> return Nothing
        Right s ->
          let pattern = "^ns = Namespace \"([^\"]+)\"" :: String
              matches = s RE.=~ pattern :: [[String]]
          in case matches of
               ([_, nsName]:_) -> return $ Just (Namespace nsName, fp)
               _               -> return Nothing


-- | SHA-256 hex digest of the raw bytes of a file. Fails loudly if the file
-- cannot be read — callers treat failures as "cache miss" upstream.
hashFile :: FilePath -> IO String
hashFile fp = do
    bytes <- BL.readFile fp
    return $ SHA.showDigest (SHA.sha256 bytes)


-- | Hash every module in the given list against the namespace → file map.
-- Modules whose source file isn't found (because discovery missed them, or
-- they have no DSL source — e.g. generated coder modules loaded from JSON)
-- are absent from the returned map. The caller treats that as "cannot verify
-- freshness," which always falls through to full inference.
hashUniverse :: M.Map Namespace FilePath -> [Module] -> IO DigestMap
hashUniverse nsFiles mods = do
    let namespaces = map moduleNamespace mods
    pairs <- CM.forM namespaces $ \ns ->
      case M.lookup ns nsFiles of
        Nothing -> return Nothing
        Just fp -> do
          result <- E.try (hashFile fp) :: IO (Either E.SomeException String)
          case result of
            Left _  -> return Nothing
            Right h -> return $ Just (ns, h)
    return $ M.fromList (Y.catMaybes pairs)


-- | Digest path for a single-tree writer: sits next to the json/ subdir.
-- Input `<pkg>/src/main/json` produces `<pkg>/src/main/digest.json`, and
-- `<pkg>/src/test/json` produces `<pkg>/src/test/digest.json`. This keeps
-- main-tree and test-tree caches distinct.
digestPath :: FilePath -> FilePath
digestPath basePath = FP.takeDirectory basePath FP.</> "digest.json"


-- | Read a digest file. Absent or malformed → empty map.
-- The "encoderId" key, when present, is filtered out of the result.
readDigest :: FilePath -> IO DigestMap
readDigest path = do
    exists <- SD.doesFileExist path
    if not exists then return M.empty else do
      result <- E.try (readFile path) :: IO (Either E.SomeException String)
      case result of
        Left _  -> return M.empty
        Right s -> return $ parseDigest s


-- | Read the encoderId field from a digest file, if present. Absent file
-- or no encoderId field → empty string. Callers compare this against
-- 'computeEncoderId'; a mismatch means the encoder/format changed since
-- the digest was last written.
readEncoderId :: FilePath -> IO String
readEncoderId path = do
    exists <- SD.doesFileExist path
    if not exists then return "" else do
      result <- E.try (readFile path) :: IO (Either E.SomeException String)
      case result of
        Left _  -> return ""
        Right s -> return $ parseEncoderId s


-- | Write a digest file. Format: a minimal JSON object
-- { "formatVersion": 1, "version": 1, "hashes": { "<namespace>": "<hex>", ... } }
-- Keys are written in sorted order for deterministic output.
--
-- The two version fields are deliberately distinct:
--
--   * "formatVersion" describes the JSON encoding of sibling module files
--     (dist/json/<package>/.../*.json). See docs/json-format.md.
--     Bumped only when a parser for version N would mis-parse version N+1.
--   * "version" describes this digest file's own internal schema (v1 = simple
--     hash map, v2 = inputs/outputs/generator). It is not meant for consumers
--     gating on the module-JSON encoding.
--
-- Use 'writeUniverseDigest' to also record an encoderId.
writeDigest :: FilePath -> DigestMap -> IO ()
writeDigest path digest = do
    SD.createDirectoryIfMissing True (FP.takeDirectory path)
    writeFile path (serializeDigest "" digest)


-- | Write a digest file together with an encoderId stamp. Format:
-- { "formatVersion": 1, "version": 1, "encoderId": "<hex>", "hashes": { ... } }
-- The encoderId is computed by 'computeEncoderId'. When empty, the field
-- is omitted (matching the legacy schema).
writeUniverseDigest :: FilePath -> String -> DigestMap -> IO ()
writeUniverseDigest path encoderId digest = do
    SD.createDirectoryIfMissing True (FP.takeDirectory path)
    writeFile path (serializeDigest encoderId digest)


-- | Compute a hash identifying the on-disk JSON encoder/decoder layer.
-- Concretely: SHA-256 of the concatenated bytes of the four DSL source
-- files that govern the wire format. Any change to these files
-- invalidates the universe-wide cache, even when per-namespace DSL
-- hashes are unchanged — guards against the failure mode where editing
-- only Encode.hs or Decode.hs leaves stale JSON on disk for every other
-- namespace (issue surfaced by feature_343_json on 2026-04-25).
--
-- Files hashed (in fixed order):
--   * Hydra/Sources/Json/Encode.hs   — emits Term → Value
--   * Hydra/Sources/Json/Decode.hs   — parses Value → Term
--   * Hydra/Sources/Json/Model.hs    — Value type definition
--   * Hydra/Sources/Json/Writer.hs   — serializes Value to bytes on disk
--
-- A missing file is treated as the zero-byte string for hashing
-- (fail-soft); the encoderId still reflects the present files'
-- contents, and a missing encoder file should fail downstream anyway.
computeEncoderId :: IO String
computeEncoderId = do
    let files =
          [ packagesRoot FP.</> "hydra-kernel/src/main/haskell/Hydra/Sources/Json/Encode.hs"
          , packagesRoot FP.</> "hydra-kernel/src/main/haskell/Hydra/Sources/Json/Decode.hs"
          , packagesRoot FP.</> "hydra-kernel/src/main/haskell/Hydra/Sources/Json/Model.hs"
          , packagesRoot FP.</> "hydra-kernel/src/main/haskell/Hydra/Sources/Json/Writer.hs"
          ]
    chunks <- mapM safeRead files
    let combined = BL.concat chunks
    return $ SHA.showDigest (SHA.sha256 combined)
  where
    safeRead fp = do
      exists <- SD.doesFileExist fp
      if not exists then return BL.empty else do
        result <- E.try (BL.readFile fp) :: IO (Either E.SomeException BL.ByteString)
        case result of
          Left _  -> return BL.empty
          Right b -> return b


-- | Minimal JSON parser for the digest file. We deliberately avoid pulling
-- in aeson's full machinery here because the format is trivial and we want
-- tolerant parsing (a malformed digest silently becomes an empty map).
-- The regex only matches `"key": "quoted_value"` pairs, so it naturally
-- skips the `"formatVersion"`, `"version"`, and `"hashes": { ... }` scaffolding.
--
-- The "encoderId" key is filtered from the namespace map: it is a
-- top-level field, not a namespace hash. Callers wanting the encoderId
-- use 'parseEncoderId' / 'readEncoderId' instead.
parseDigest :: String -> DigestMap
parseDigest s =
    let kvPattern = "\"([^\"]+)\"[[:space:]]*:[[:space:]]*\"([^\"]+)\"" :: String
        matches   = s RE.=~ kvPattern :: [[String]]
    in M.fromList [ (Namespace k, v)
                  | (_:k:v:_) <- matches
                  , k /= "encoderId"
                  ]


-- | Extract the encoderId field from a digest file's text, or "" if absent.
parseEncoderId :: String -> String
parseEncoderId s =
    let pat = "\"encoderId\"[[:space:]]*:[[:space:]]*\"([^\"]*)\"" :: String
    in case s RE.=~ pat :: [[String]] of
         ((_:enc:_):_) -> enc
         _             -> ""


-- | Serialize a digest, optionally with an encoderId stamp. Pass "" to
-- omit the field (legacy schema).
serializeDigest :: String -> DigestMap -> String
serializeDigest encoderId digest = unlines $
    ["{"
    ,"  \"formatVersion\": 1,"
    ,"  \"version\": 1,"]
    ++ encoderLines ++
    ["  \"hashes\": {"]
    ++ hashLines ++
    ["  }"
    ,"}"]
  where
    encoderLines =
      if null encoderId
        then []
        else ["  \"encoderId\": \"" ++ encoderId ++ "\","]
    entries = L.sortBy (\a b -> compare (fst a) (fst b)) (M.toList digest)
    hashLines = zipWith renderEntry [0..] entries
    renderEntry i (Namespace ns, h) =
      let sep = if i == length entries - 1 then "" else ","
      in "    \"" ++ ns ++ "\": \"" ++ h ++ "\"" ++ sep


----------------------------------------------------------------------
-- v2 API: per-package, per-target digest with inputs + outputs +
-- generator stamp.
----------------------------------------------------------------------
-- A v2 digest records:
--   * inputs:  every file whose content determines the output of a sync
--              step (DSL sources, JSON files consumed by code generators,
--              hand-written runtime files copied in by post-processing).
--   * outputs: every file the sync step is responsible for producing.
--   * generator: a stamp identifying the version of the generator that
--                produced these outputs, so generator changes invalidate
--                downstream caches.
--
-- A freshness check is "all input hashes match recorded inputs AND all
-- output files exist with matching hashes AND generator stamp matches."
-- Any mismatch falls through to a regen of the affected step.

-- | What kind of artifact is being recorded. Lets a single Digest mix
-- typed entries (DSL source, JSON file, target source, runtime support
-- file) without losing the discriminator.
data DigestKind
    = KindDslSource     -- A .hs source file under packages/*/src/main/haskell/Hydra/Sources/
    | KindJsonFile      -- A .json file under dist/json/
    | KindTargetFile    -- A generated source file under dist/<lang>/
    | KindRuntimeFile   -- A hand-written file under heads/*/src/ that gets copied in
    | KindOther         -- Anything else (escape hatch)
    deriving (Eq, Ord, Show, Read)

-- | One file's hash + classification. The path is the canonical key
-- (relative to the worktree root for portability).
data DigestEntry = DigestEntry
    { entryKind :: DigestKind
    , entryHash :: String  -- SHA-256 hex
    } deriving (Eq, Show)

-- | A versioned digest for one sync step. Indexed by file path so that
-- callers can mix file types freely.
data Digest = Digest
    { digestInputs    :: M.Map FilePath DigestEntry
    , digestOutputs   :: M.Map FilePath DigestEntry
    , digestGenerator :: String  -- generator stamp; see 'generatorStamp'
    } deriving (Eq, Show)

emptyDigest :: Digest
emptyDigest = Digest M.empty M.empty ""

-- | Hash any file by content. Returns a DigestEntry with the given kind
-- attached. Fails loudly if the file is missing — callers handle by
-- treating absent inputs as cache miss upstream.
hashFileV2 :: DigestKind -> FilePath -> IO DigestEntry
hashFileV2 kind fp = do
    h <- hashFile fp
    return (DigestEntry kind h)

-- | A stamp identifying the generator version. Right now just the
-- HYDRA_GENERATOR_STAMP env var if set, falling back to a fixed
-- placeholder. Future work: hash the bootstrap-from-json executable
-- itself, or read a commit SHA at build time.
--
-- The stamp is treated as opaque by the freshness check: any change
-- invalidates downstream digests across the board. This is a coarse
-- but safe trigger for "the code that produced these files changed."
generatorStamp :: IO String
generatorStamp = do
    mEnv <- E.try (SE.getEnv "HYDRA_GENERATOR_STAMP") :: IO (Either E.SomeException String)
    case mEnv of
      Right s | not (null s) -> return s
      _                      -> return "v0-unstamped"

-- | Read a v2 digest from disk. Absent or malformed → emptyDigest.
readDigestV2 :: FilePath -> IO Digest
readDigestV2 path = do
    exists <- SD.doesFileExist path
    if not exists then return emptyDigest else do
      result <- E.try (readFile path) :: IO (Either E.SomeException String)
      case result of
        Left _  -> return emptyDigest
        Right s -> return $ parseDigestV2 s

-- | Write a v2 digest to disk. Format is JSON-ish, sorted for
-- determinism, parseable by parseDigestV2 (regex-based, tolerant).
writeDigestV2 :: FilePath -> Digest -> IO ()
writeDigestV2 path d = do
    SD.createDirectoryIfMissing True (FP.takeDirectory path)
    writeFile path (serializeDigestV2 d)

-- | Two digests are equivalent for freshness purposes if their input,
-- output, and generator fields all match. Output hashes are NOT
-- compared against the filesystem here — see 'verifyOutputsExist'
-- for that.
digestsMatch :: Digest -> Digest -> Bool
digestsMatch a b =
    digestInputs a == digestInputs b
      && digestOutputs a == digestOutputs b
      && digestGenerator a == digestGenerator b

-- | For each output file recorded in the digest, verify the file
-- exists on disk and hashes to the recorded value. Returns True iff
-- every output is present and content-matched.
--
-- This catches "user deleted some files" or "files were partially
-- regenerated and corrupted." A digest match alone is not sufficient
-- proof of freshness because the dist/ tree could have been mutated
-- after the last write.
verifyOutputsExist :: Digest -> IO Bool
verifyOutputsExist d = do
    let outputs = M.toList (digestOutputs d)
    fmap and $ CM.forM outputs $ \(fp, entry) -> do
      exists <- SD.doesFileExist fp
      if not exists then return False else do
        result <- E.try (hashFile fp) :: IO (Either E.SomeException String)
        case result of
          Left _  -> return False
          Right h -> return (h == entryHash entry)


----------------------------------------------------------------------
-- v2 serialization.
--
-- File layout (deliberately readable + tolerant):
--
--   {
--     "version": 2,
--     "generator": "<stamp>",
--     "inputs": {
--       "<path>": { "kind": "DslSource", "hash": "<hex>" },
--       ...
--     },
--     "outputs": {
--       "<path>": { "kind": "JsonFile", "hash": "<hex>" },
--       ...
--     }
--   }
--
-- Parser is regex-based and recovers from formatting variations.
-- Unknown kinds round-trip as KindOther.
----------------------------------------------------------------------

serializeDigestV2 :: Digest -> String
serializeDigestV2 d = unlines $
    [ "{"
    , "  \"version\": 2,"
    , "  \"generator\": " ++ jsonString (digestGenerator d) ++ ","
    , "  \"inputs\": {"
    ] ++ entries (digestInputs d) ++
    [ "  },"
    , "  \"outputs\": {"
    ] ++ entries (digestOutputs d) ++
    [ "  }"
    , "}"
    ]
  where
    entries m =
      let kvs = L.sortBy (\a b -> compare (fst a) (fst b)) (M.toList m)
      in zipWith (renderEntry (length kvs)) [0..] kvs

    renderEntry total i (path, DigestEntry k h) =
      let sep = if i == total - 1 then "" else ","
      in "    " ++ jsonString path ++ ": { \"kind\": "
           ++ jsonString (kindToString k) ++ ", \"hash\": "
           ++ jsonString h ++ " }" ++ sep

    jsonString s = "\"" ++ concatMap escape s ++ "\""
    escape '\\' = "\\\\"
    escape '"'  = "\\\""
    escape c    = [c]

kindToString :: DigestKind -> String
kindToString KindDslSource   = "DslSource"
kindToString KindJsonFile    = "JsonFile"
kindToString KindTargetFile  = "TargetFile"
kindToString KindRuntimeFile = "RuntimeFile"
kindToString KindOther       = "Other"

stringToKind :: String -> DigestKind
stringToKind "DslSource"   = KindDslSource
stringToKind "JsonFile"    = KindJsonFile
stringToKind "TargetFile"  = KindTargetFile
stringToKind "RuntimeFile" = KindRuntimeFile
stringToKind _             = KindOther

-- Tolerant regex-based parser; ignores anything outside the recognized
-- shapes. The shapes we look for:
--   "generator": "<stamp>"           → captures generator stamp
--   "<path>": { "kind": "<k>", "hash": "<h>" } in inputs/outputs sections
parseDigestV2 :: String -> Digest
parseDigestV2 s =
    let -- Generator stamp is a top-level string.
        genPat = "\"generator\"[[:space:]]*:[[:space:]]*\"([^\"]*)\"" :: String
        gen    = case s RE.=~ genPat :: [[String]] of
                   ((_:g:_):_) -> g
                   _           -> ""
        -- Split on "outputs": to give us two halves; the inputs half is
        -- everything before, outputs half is everything after. This is
        -- coarse but lets us assign entries to the right map without
        -- proper JSON parsing.
        -- We anchor on the literal text "\"outputs\"" preceded by a
        -- closing brace + comma + whitespace to disambiguate from the
        -- (theoretical) word "outputs" appearing in a path.
        (inHalf, outHalf) = splitOnOutputs s
        entryPat = "\"([^\"]+)\"[[:space:]]*:[[:space:]]*\\{[[:space:]]*\"kind\"[[:space:]]*:[[:space:]]*\"([^\"]*)\"[[:space:]]*,[[:space:]]*\"hash\"[[:space:]]*:[[:space:]]*\"([^\"]*)\"" :: String
        parseEntries :: String -> M.Map FilePath DigestEntry
        parseEntries half =
          let ms = (half RE.=~ entryPat) :: [[String]]
          in M.fromList [ (path, DigestEntry (stringToKind k) h)
                        | (_:path:k:h:_) <- ms
                        ]
    in Digest
        { digestInputs    = parseEntries inHalf
        , digestOutputs   = parseEntries outHalf
        , digestGenerator = gen
        }

-- Split the digest text into the inputs region (everything up to and
-- including the first `"outputs"` key) and the outputs region (after).
-- If we can't find the boundary, treat everything as inputs.
splitOnOutputs :: String -> (String, String)
splitOnOutputs s =
    case findIndex "\"outputs\"" s of
      Just i  -> (take i s, drop i s)
      Nothing -> (s, "")
  where
    findIndex needle hay =
      let n = length needle
          go ix rest
            | length rest < n = Nothing
            | take n rest == needle = Just ix
            | otherwise = go (ix + 1) (drop 1 rest)
      in go 0 hay