packages feed

mcp-hoogle 0.1.0 → 0.2.0

raw patch · 5 files changed

+276/−92 lines, 5 filesdep +stmdep +timePVP ok

version bump matches the API change (PVP)

Dependencies added: stm, time

API changes (from Hackage documentation)

- McpHoogle.Tools: [databasePath] :: RegenerateDatabaseParams -> Text
+ McpHoogle.Tools: RegenDone :: UTCTime -> Text -> RegenState
+ McpHoogle.Tools: RegenFailed :: UTCTime -> Text -> RegenState
+ McpHoogle.Tools: RegenIdle :: RegenState
+ McpHoogle.Tools: RegenRunning :: UTCTime -> RegenState
+ McpHoogle.Tools: RegenerationStatus :: HoogleTool
+ McpHoogle.Tools: ReloadDatabase :: ReloadDatabaseParams -> HoogleTool
+ McpHoogle.Tools: ReloadDatabaseParams :: Text -> ReloadDatabaseParams
+ McpHoogle.Tools: [ghcBinPath] :: RegenerateDatabaseParams -> Text
+ McpHoogle.Tools: [reloadPath] :: ReloadDatabaseParams -> Text
+ McpHoogle.Tools: data RegenState
+ McpHoogle.Tools: data ReloadDatabaseParams
- McpHoogle.Tools: handleTool :: IORef Database -> HoogleTool -> IO Text
+ McpHoogle.Tools: handleTool :: IORef (Maybe Database) -> TVar RegenState -> HoogleTool -> IO Text

Files

app/Main.hs view
@@ -3,6 +3,8 @@ import McpHoogle (runServer, runServerWithDb) import Hoogle (defaultDatabaseLocation, hoogle) import Options.Applicative+import Data.Version (showVersion)+import Paths_mcp_hoogle (version)  data Command   = Generate GenerateOpts@@ -41,7 +43,7 @@     ))  opts :: ParserInfo Command-opts = info (commandParser <**> helper)+opts = info (commandParser <**> helper <**> simpleVersioner ("mcp-hoogle " <> showVersion version))   ( fullDesc   <> progDesc "MCP server exposing Hoogle search over local Haskell dependencies"   <> header "mcp-hoogle - Hoogle search via Model Context Protocol"
mcp-hoogle.cabal view
@@ -1,7 +1,7 @@ cabal-version:      3.0  name:           mcp-hoogle-version:        0.1.0+version:        0.2.0 synopsis:       MCP server exposing Hoogle search over local project dependencies description:    An MCP (Model Context Protocol) server that exposes Hoogle search                 over your project's local Haskell dependencies. Run from within a@@ -77,19 +77,31 @@       McpHoogle       McpHoogle.Tools       McpHoogle.Format+  autogen-modules:+      Paths_mcp_hoogle+  other-modules:+      Paths_mcp_hoogle   hs-source-dirs:       src   build-depends:       hoogle >=5.0 && <5.1+    -- 0.1.0.17 added protocol version negotiation; older versions reject+    -- Claude Code's 2024-11-05 handshake and tools silently don't appear.     , mcp-server >=0.1.0.17 && <0.2     , text >=1.2 && <2.2     , directory >=1.3 && <1.4+    , stm >=2.5 && <2.6+    , time >=1.9 && <1.15  executable mcp-hoogle   import: common-options   main-is: Main.hs   hs-source-dirs:       app+  autogen-modules:+      Paths_mcp_hoogle+  other-modules:+      Paths_mcp_hoogle   build-depends:       mcp-hoogle     , hoogle >=5.0 && <5.1@@ -110,3 +122,5 @@     , hoogle >=5.0 && <5.1     , text >=1.2 && <2.2     , directory >=1.3 && <1.4+    , stm >=2.5 && <2.6+    , time >=1.9 && <1.15
src/McpHoogle.hs view
@@ -4,16 +4,23 @@ -- This module wires together the Hoogle database, the tool definitions from -- "McpHoogle.Tools", and the @mcp-server@ library's stdio transport. -- It loads the Hoogle database into an 'IORef' so it can be swapped at--- runtime (via the @regenerate_database@ tool), then enters the MCP--- request\/response loop reading JSON-RPC from stdin and writing to stdout.+-- runtime (via the @regenerate_database@ or @reload_database@ tools),+-- then enters the MCP request\/response loop reading JSON-RPC from stdin+-- and writing to stdout.+--+-- The server starts even without an existing database — searches return+-- "No database loaded" until one is generated or reloaded. module McpHoogle   ( runServer   , runServerWithDb   ) where -import Data.IORef (newIORef)+import Control.Concurrent.STM (TVar, newTVarIO)+import Data.IORef (IORef, newIORef) import Data.Text qualified as Text+import Data.Version (showVersion)+import Hoogle qualified (Database) import Hoogle (withDatabase, defaultDatabaseLocation) import MCP.Server (runMcpServerStdio) import MCP.Server.Types@@ -22,22 +29,23 @@   , Content(..)   ) import MCP.Server.Derive (deriveToolHandlerWithDescription)-import McpHoogle.Tools (HoogleTool(..), handleTool, toolDescriptions)+import McpHoogle.Tools (HoogleTool(..), RegenState(..), handleTool, toolDescriptions)+import Paths_mcp_hoogle (version) import System.Directory (doesFileExist)  -- | Run the MCP server using the default Hoogle database location -- (@~\/.hoogle\/default-haskell-*.hoo@). ----- Errors immediately if no database is found — the user should run--- @mcp-hoogle generate@ first.+-- If no database exists, the server starts anyway with an empty database+-- ref — the agent can call @regenerate_database@ or @reload_database@ to+-- populate it without restarting. runServer :: IO () runServer = do   defaultPath <- defaultDatabaseLocation   exists <- doesFileExist defaultPath   if exists     then runServerWithDb defaultPath-    else error $ "Hoogle database not found at: " <> defaultPath-      <> "\nRun 'mcp-hoogle generate' to create it, or pass --database PATH."+    else runServerEmpty  -- | Run the MCP server with an explicit database path. --@@ -47,46 +55,57 @@ runServerWithDb :: FilePath -> IO () runServerWithDb databasePath =   withDatabase databasePath $ \database -> do-    databaseRef <- newIORef database-    let serverInfo :: McpServerInfo-        serverInfo = McpServerInfo-          { serverName = "mcp-hoogle"-          , serverVersion = "0.1.0"-          , serverInstructions = Text.unlines-              [ "Hoogle search for Haskell types, functions, and modules."-              , ""-              , "USE THESE TOOLS INSTEAD OF:"-              , "- Web searching for Haskell documentation"-              , "- Running `hoogle` or `mcp-hoogle` CLI commands"-              , "- Fetching Hackage pages with curl/w3m"-              , ""-              , "AVAILABLE TOOLS:"-              , "- search: Find functions by name, keyword, or type signature (e.g. \"map\", \"[a] -> Int\")"-              , "- search_type: Search specifically by type signature"-              , "- lookup_module: Browse all exports of a module (e.g. \"Data.Map\")"-              , "- regenerate_database: Re-index after entering a different project's nix-shell"-              , ""-              , "DATABASE SETUP:"-              , "If no database exists, run `mcp-hoogle generate` from an"-              , "environment where `ghc-pkg` is on PATH (so it can discover"-              , "installed packages). For nix-based projects this means running"-              , "from inside the project's nix-shell:"-              , "  nix-shell --run 'mcp-hoogle generate'"-              , "For cabal/stack projects, just run `mcp-hoogle generate` directly"-              , "(GHC tools are already on PATH)."-              , "This only needs to be done once per project. The database persists"-              , "at ~/.hoogle/ and is reused across sessions."-              ]-          }+    databaseRef <- newIORef (Just database)+    regenStateVar <- newTVarIO RegenIdle+    runMcpServerStdio serverInfo (handlers databaseRef regenStateVar) -        toolHandler :: HoogleTool -> IO Content-        toolHandler tool = ContentText <$> handleTool databaseRef tool+-- | Run the MCP server without a database.+--+-- The server starts and exposes tools, but searches return "No database+-- loaded" until the agent calls @regenerate_database@ or @reload_database@.+runServerEmpty :: IO ()+runServerEmpty = do+  databaseRef <- newIORef Nothing+  regenStateVar <- newTVarIO RegenIdle+  runMcpServerStdio serverInfo (handlers databaseRef regenStateVar) -        handlers :: McpServerHandlers IO-        handlers = McpServerHandlers-          { prompts = Nothing-          , resources = Nothing-          , tools = Just $(deriveToolHandlerWithDescription ''HoogleTool 'toolHandler toolDescriptions)-          }+-- | Server metadata sent during MCP initialization.+serverInfo :: McpServerInfo+serverInfo = McpServerInfo+  { serverName = "mcp-hoogle"+  , serverVersion = Text.pack (showVersion version)+  , serverInstructions = Text.unlines+      [ "Hoogle search for Haskell types, functions, and modules."+      , ""+      , "USE THESE TOOLS INSTEAD OF:"+      , "- Web searching for Haskell documentation"+      , "- Running `hoogle` or `mcp-hoogle` CLI commands"+      , "- Fetching Hackage pages with curl/w3m"+      , ""+      , "AVAILABLE TOOLS:"+      , "- search: Find functions by name, keyword, or type signature (e.g. \"map\", \"[a] -> Int\")"+      , "- search_type: Search specifically by type signature"+      , "- lookup_module: Browse all exports of a module (e.g. \"Data.Map\")"+      , "- regenerate_database: Re-index packages. Pass ghcBinPath (find it with: nix-shell --run 'dirname $(which ghc-pkg)')"+      , "- reload_database: Reload database from disk after generating externally"+      , ""+      , "DATABASE SETUP:"+      , "If searches return 'No database loaded', call regenerate_database with"+      , "the ghcBinPath from the project's nix-shell. Find it by running:"+      , "  nix-shell --run 'dirname $(which ghc-pkg)'"+      , "Then pass that path to regenerate_database."+      , "Alternatively, run `nix-shell --run 'mcp-hoogle generate'` as a bash"+      , "command, then call reload_database to pick up the new file."+      ]+  } -    runMcpServerStdio serverInfo handlers+-- | Build MCP handlers from a database ref and regeneration state.+handlers :: IORef (Maybe Hoogle.Database) -> TVar RegenState -> McpServerHandlers IO+handlers databaseRef regenStateVar = McpServerHandlers+  { prompts = Nothing+  , resources = Nothing+  , tools = Just $(deriveToolHandlerWithDescription ''HoogleTool 'toolHandler toolDescriptions)+  }+  where+    toolHandler :: HoogleTool -> IO Content+    toolHandler tool = ContentText <$> handleTool databaseRef regenStateVar tool
src/McpHoogle/Tools.hs view
@@ -4,14 +4,20 @@ -- MCP client) can invoke. The @mcp-server@ library's Template Haskell -- derivation turns the ADT into a tool list + dispatcher automatically. ----- The handler reads from an 'IORef' 'Database' so the database can be--- hot-swapped via 'RegenerateDatabase' without restarting the server.+-- The handler reads from an 'IORef' holding a 'Maybe' 'Database'. The ref+-- starts as 'Nothing' when no database exists on disk and is populated+-- by 'RegenerateDatabase' or 'ReloadDatabase'.+--+-- 'RegenerateDatabase' runs asynchronously via 'forkIO' so the MCP response+-- returns immediately. Use 'RegenerationStatus' to poll progress. module McpHoogle.Tools   ( HoogleTool(..)   , SearchParams(..)   , SearchTypeParams(..)   , LookupModuleParams(..)   , RegenerateDatabaseParams(..)+  , ReloadDatabaseParams(..)+  , RegenState(..)   , handleTool   , toolDescriptions   )@@ -20,8 +26,15 @@ import Data.Text (Text) import Data.Text qualified as Text import Data.IORef (IORef, readIORef, writeIORef)-import Hoogle (Database, searchDatabase, withDatabase, hoogle)+import Data.Time.Clock (UTCTime, getCurrentTime, diffUTCTime)+import Control.Concurrent (forkIO)+import Control.Concurrent.STM (TVar, readTVarIO, atomically, writeTVar)+import Control.Exception (bracket, SomeException, try)+import GHC.IO.Handle (hDuplicate, hDuplicateTo)+import Hoogle (Database, searchDatabase, withDatabase, hoogle, defaultDatabaseLocation) import McpHoogle.Format (formatTargets)+import System.Environment (setEnv, lookupEnv)+import System.IO (stdout, stderr, hFlush)  -- | Parameters for a general search (name, keyword, or type signature). data SearchParams = SearchParams@@ -39,10 +52,28 @@   }  -- | Parameters for database regeneration.+--+-- Requires @ghcBinPath@ so that @ghc-pkg@ can be found even when the+-- MCP server was started outside a nix-shell. data RegenerateDatabaseParams = RegenerateDatabaseParams-  { databasePath :: Text+  { ghcBinPath :: Text   } +-- | Parameters for reloading the database from disk without regenerating.+data ReloadDatabaseParams = ReloadDatabaseParams+  { reloadPath :: Text+  }++-- | Tracks the state of an asynchronous database regeneration.+--+-- Stored in a 'TVar' for thread-safe access between the MCP handler+-- thread and the background regeneration thread.+data RegenState+  = RegenIdle+  | RegenRunning UTCTime     -- ^ Started at this time+  | RegenDone UTCTime Text   -- ^ Finished at, database path+  | RegenFailed UTCTime Text -- ^ Finished at, error message+ -- | The set of MCP tools this server exposes. -- -- Each constructor maps to one callable tool. The nested parameter records@@ -53,6 +84,8 @@   | SearchType SearchTypeParams   | LookupModule LookupModuleParams   | RegenerateDatabase RegenerateDatabaseParams+  | ReloadDatabase ReloadDatabaseParams+  | RegenerationStatus  -- | Human-readable descriptions for each tool and its arguments. -- Fed to 'deriveToolHandlerWithDescription' so MCP clients know what@@ -62,39 +95,116 @@   [ ("Search", "Search Hoogle by function name, type signature, or keyword. Returns matching functions with their types, packages, and documentation.")   , ("SearchType", "Search Hoogle specifically by type signature. Example: 'a -> [a]' or '[a] -> Int'")   , ("LookupModule", "Search for all exports of a given module name. Example: 'Data.Map'")-  , ("RegenerateDatabase", "Regenerate the Hoogle database from the current GHC package database. Call this after switching projects or nix-shells to re-index available packages. The databasePath should be the path to write the .hoo file.")+  , ("RegenerateDatabase", "Regenerate the Hoogle database asynchronously. Returns immediately — use regeneration_status to poll progress. Requires ghcBinPath so ghc-pkg can be found. Find it with: nix-shell --run 'dirname $(which ghc-pkg)'")+  , ("ReloadDatabase", "Reload the Hoogle database from disk without regenerating. Use after running 'mcp-hoogle generate' externally via a bash command.")+  , ("RegenerationStatus", "Check the status of an asynchronous database regeneration. Reports idle, in-progress (with elapsed time), completed, or failed.")   , ("query", "The search query: a function name, keyword, or type signature")   , ("typeSignature", "A Haskell type signature to search for, e.g. '[a] -> Int' or 'Map k v -> [(k,v)]'")   , ("moduleName", "A Haskell module name to look up, e.g. 'Data.Map' or 'Control.Monad'")-  , ("databasePath", "Path to the Hoogle database file to regenerate and reload")+  , ("ghcBinPath", "Path to GHC's bin directory containing ghc-pkg. Find with: nix-shell --run 'dirname $(which ghc-pkg)'")+  , ("reloadPath", "Path to the .hoo database file to reload. Use empty string for default (~/.hoogle/default-haskell-5.0.18.hoo).")   ]  -- | Dispatch a tool call to the appropriate Hoogle operation. -- -- Reads the current database from the 'IORef'. For 'RegenerateDatabase',--- shells out to @hoogle generate --local@, reloads the resulting file,--- and swaps the 'IORef' contents so subsequent searches use the new data.-handleTool :: IORef Database -> HoogleTool -> IO Text-handleTool databaseRef (Search (SearchParams searchQuery)) = do-  database <- readIORef databaseRef-  let results = take 20 (searchDatabase database (Text.unpack searchQuery))-  pure (formatTargets results)-handleTool databaseRef (SearchType (SearchTypeParams sig)) = do-  database <- readIORef databaseRef-  let results = take 20 (searchDatabase database (Text.unpack sig))-  pure (formatTargets results)-handleTool databaseRef (LookupModule (LookupModuleParams modName)) = do-  database <- readIORef databaseRef-  let queryString = "module:" <> Text.unpack modName-      results = take 30 (searchDatabase database queryString)-  -- If module: prefix doesn't work well, fall back to plain search-  let finalResults = if null results-        then take 30 (searchDatabase database (Text.unpack modName))-        else results-  pure (formatTargets finalResults)-handleTool databaseRef (RegenerateDatabase (RegenerateDatabaseParams path)) = do-  let pathStr = Text.unpack path-  hoogle ["generate", "--local", "--database=" <> pathStr]-  withDatabase pathStr $ \newDb -> do-    writeIORef databaseRef newDb-    pure "Database regenerated and reloaded successfully."+-- spawns a background thread and returns immediately — poll with+-- 'RegenerationStatus'.+handleTool :: IORef (Maybe Database) -> TVar RegenState -> HoogleTool -> IO Text+handleTool databaseRef _regenStateVar (Search (SearchParams searchQuery)) = do+  mDatabase <- readIORef databaseRef+  case mDatabase of+    Nothing -> pure "No database loaded. Call regenerate_database with your project's ghcBinPath first."+    Just database -> do+      let results = take 20 (searchDatabase database (Text.unpack searchQuery))+      pure (formatTargets results)+handleTool databaseRef _regenStateVar (SearchType (SearchTypeParams sig)) = do+  mDatabase <- readIORef databaseRef+  case mDatabase of+    Nothing -> pure "No database loaded. Call regenerate_database with your project's ghcBinPath first."+    Just database -> do+      let results = take 20 (searchDatabase database (Text.unpack sig))+      pure (formatTargets results)+handleTool databaseRef _regenStateVar (LookupModule (LookupModuleParams modName)) = do+  mDatabase <- readIORef databaseRef+  case mDatabase of+    Nothing -> pure "No database loaded. Call regenerate_database with your project's ghcBinPath first."+    Just database -> do+      let queryString = "module:" <> Text.unpack modName+          results = take 30 (searchDatabase database queryString)+      -- If module: prefix doesn't work well, fall back to plain search+      let finalResults = if null results+            then take 30 (searchDatabase database (Text.unpack modName))+            else results+      pure (formatTargets finalResults)+handleTool databaseRef regenStateVar (RegenerateDatabase (RegenerateDatabaseParams ghcBin)) = do+  currentState <- readTVarIO regenStateVar+  case currentState of+    RegenRunning _ -> pure "Database regeneration already in progress. Use regeneration_status to check progress."+    _ -> do+      startTime <- getCurrentTime+      atomically (writeTVar regenStateVar (RegenRunning startTime))+      dbPath <- defaultDatabaseLocation+      let ghcBinStr = Text.unpack ghcBin+      _ <- forkIO $ do+        result <- try $ do+          withPrependedPath ghcBinStr $+            withSilencedStdout $+              hoogle ["generate", "--local", "--database=" <> dbPath]+          withDatabase dbPath $ \newDb ->+            writeIORef databaseRef (Just newDb)+        finishTime <- getCurrentTime+        case result of+          Right () ->+            atomically (writeTVar regenStateVar (RegenDone finishTime (Text.pack dbPath)))+          Left someException ->+            atomically (writeTVar regenStateVar (RegenFailed finishTime (Text.pack (show (someException :: SomeException)))))+      pure "Database regeneration started. Use regeneration_status to check progress."+handleTool databaseRef _regenStateVar (ReloadDatabase (ReloadDatabaseParams path)) = do+  dbPath <- if Text.null path+    then defaultDatabaseLocation+    else pure (Text.unpack path)+  withDatabase dbPath $ \newDb -> do+    writeIORef databaseRef (Just newDb)+    pure (Text.pack ("Database reloaded from: " <> dbPath))+handleTool _databaseRef regenStateVar RegenerationStatus = do+  currentState <- readTVarIO regenStateVar+  now <- getCurrentTime+  case currentState of+    RegenIdle -> pure "No regeneration in progress or completed."+    RegenRunning startTime ->+      let elapsed = floor (diffUTCTime now startTime) :: Int+      in pure (Text.pack ("Regeneration in progress (started " <> show elapsed <> "s ago)."))+    RegenDone finishTime dbPathText ->+      let ago = floor (diffUTCTime now finishTime) :: Int+      in pure (Text.pack ("Regeneration completed " <> show ago <> "s ago. Database loaded from: " <> Text.unpack dbPathText))+    RegenFailed finishTime errorText ->+      let ago = floor (diffUTCTime now finishTime) :: Int+      in pure (Text.pack ("Regeneration failed " <> show ago <> "s ago: " <> Text.unpack errorText))++-- | Temporarily prepend a directory to PATH, run an action, then restore.+withPrependedPath :: FilePath -> IO a -> IO a+withPrependedPath dir action = do+  oldPath <- lookupEnv "PATH"+  let newPath = case oldPath of+        Nothing -> dir+        Just p  -> dir <> ":" <> p+  bracket+    (setEnv "PATH" newPath)+    (\_ -> maybe (setEnv "PATH" "") (setEnv "PATH") oldPath)+    (\_ -> action)++-- | Redirect stdout to \/dev\/null during an action.+--+-- Hoogle's library API writes progress text to stdout which would corrupt+-- the MCP JSON-RPC stream. We redirect stdout to stderr (so it's still+-- visible for debugging) and restore it after.+withSilencedStdout :: IO a -> IO a+withSilencedStdout action = do+  hFlush stdout+  savedStdout <- hDuplicate stdout+  hDuplicateTo stderr stdout+  result <- action+  hFlush stdout+  hDuplicateTo savedStdout stdout+  pure result
test/Test.hs view
@@ -3,12 +3,14 @@ import Test.Tasty import Test.Tasty.HUnit +import Control.Concurrent.STM (TVar, newTVarIO, atomically, writeTVar)+import Data.Time.Clock (getCurrentTime) import Control.Exception (try, SomeException) import Data.IORef (IORef, newIORef) import Data.Text qualified as Text import Hoogle (Database, Target(..), defaultDatabaseLocation, withDatabase, hoogle) import McpHoogle.Format (formatTarget, formatTargets, stripHtmlTags)-import McpHoogle.Tools (HoogleTool(..), SearchParams(..), SearchTypeParams(..), LookupModuleParams(..), handleTool)+import McpHoogle.Tools (HoogleTool(..), SearchParams(..), SearchTypeParams(..), LookupModuleParams(..), RegenState(..), handleTool) import System.Directory (doesFileExist, createDirectoryIfMissing, getTemporaryDirectory)  main :: IO ()@@ -18,6 +20,7 @@ tests = testGroup "McpHoogle"   [ formatTests   , stripHtmlTests+  , regenStateTests   , withResource acquireDb releaseDb hoogleSearchTests   ] @@ -77,6 +80,41 @@       stripHtmlTags "<a><b>deep</b></a>" @?= "deep"   ] +-- | Tests for the async regeneration state machine.+regenStateTests :: TestTree+regenStateTests = testGroup "Regeneration state"+  [ testCase "regeneration_status reports idle initially" $ do+      databaseRef <- newIORef Nothing+      regenStateVar <- newTVarIO RegenIdle+      result <- handleTool databaseRef regenStateVar RegenerationStatus+      assertBool "should report idle"+        (Text.isInfixOf "No regeneration" result)+  , testCase "regeneration_status reports running when state is RegenRunning" $ do+      databaseRef <- newIORef Nothing+      regenStateVar <- newTVarIO RegenIdle+      now <- getCurrentTime+      atomically (writeTVar regenStateVar (RegenRunning now))+      result <- handleTool databaseRef regenStateVar RegenerationStatus+      assertBool "should report in progress"+        (Text.isInfixOf "in progress" result)+  , testCase "regeneration_status reports failure" $ do+      databaseRef <- newIORef Nothing+      regenStateVar <- newTVarIO RegenIdle+      now <- getCurrentTime+      atomically (writeTVar regenStateVar (RegenFailed now "ghc-pkg not found"))+      result <- handleTool databaseRef regenStateVar RegenerationStatus+      assertBool "should report failure with error message"+        (Text.isInfixOf "ghc-pkg not found" result)+  , testCase "regeneration_status reports completion" $ do+      databaseRef <- newIORef Nothing+      regenStateVar <- newTVarIO RegenIdle+      now <- getCurrentTime+      atomically (writeTVar regenStateVar (RegenDone now "/tmp/test.hoo"))+      result <- handleTool databaseRef regenStateVar RegenerationStatus+      assertBool "should report completed with path"+        (Text.isInfixOf "/tmp/test.hoo" result)+  ]+ -- | Integration tests that exercise hoogle search through our handler. -- The database path is shared across all tests via withResource. -- Tests pass trivially when no database is available (e.g. cabal CI without haddocks).@@ -86,8 +124,8 @@       mDbPath <- getDbPath       case mDbPath of         Nothing -> pure () -- no DB available, skip-        Just dbPath -> withHoogleDb dbPath $ \databaseRef -> do-          result <- handleTool databaseRef (Search (SearchParams "map"))+        Just dbPath -> withHoogleDb dbPath $ \databaseRef regenStateVar -> do+          result <- handleTool databaseRef regenStateVar (Search (SearchParams "map"))           assertBool "search should return results, not 'No results found'"             (result /= "No results found.")           assertBool "search for map should mention 'map' in results"@@ -96,26 +134,27 @@       mDbPath <- getDbPath       case mDbPath of         Nothing -> pure ()-        Just dbPath -> withHoogleDb dbPath $ \databaseRef -> do-          result <- handleTool databaseRef (SearchType (SearchTypeParams "[a] -> Int"))+        Just dbPath -> withHoogleDb dbPath $ \databaseRef regenStateVar -> do+          result <- handleTool databaseRef regenStateVar (SearchType (SearchTypeParams "[a] -> Int"))           assertBool "type search should return results"             (result /= "No results found.")   , testCase "module lookup 'Data.Map' returns results" $ do       mDbPath <- getDbPath       case mDbPath of         Nothing -> pure ()-        Just dbPath -> withHoogleDb dbPath $ \databaseRef -> do-          result <- handleTool databaseRef (LookupModule (LookupModuleParams "Data.Map"))+        Just dbPath -> withHoogleDb dbPath $ \databaseRef regenStateVar -> do+          result <- handleTool databaseRef regenStateVar (LookupModule (LookupModuleParams "Data.Map"))           assertBool "module lookup should return results"             (result /= "No results found.")   ]  -- | Load a hoogle database and run an action with it-withHoogleDb :: FilePath -> (IORef Database -> IO ()) -> IO ()+withHoogleDb :: FilePath -> (IORef (Maybe Database) -> TVar RegenState -> IO ()) -> IO () withHoogleDb path action =   withDatabase path $ \database -> do-    databaseRef <- newIORef database-    action databaseRef+    databaseRef <- newIORef (Just database)+    regenStateVar <- newTVarIO RegenIdle+    action databaseRef regenStateVar  -- | Helper to create a Target for testing mkTarget :: String -> Maybe (String, String) -> Maybe (String, String) -> Target