cutesetup-0.1.0.0: src/NixSearch.hs
{-# LANGUAGE OverloadedStrings #-}
module NixSearch
( nixSearch
, NixResult (..)
, Channel (..)
, getCachePath
, cacheExists
, loadCache
, updateCache
, searchLocal
) where
import Control.Exception (try, SomeException)
import Data.Aeson (FromJSON(..), decodeFileStrict, withArray)
import Data.Char (toLower)
import Data.List (isInfixOf)
import qualified Data.Vector as V
import System.Directory (createDirectoryIfMissing, doesFileExist, getXdgDirectory, XdgDirectory(XdgCache))
import System.FilePath ((</>))
import System.Process (readCreateProcessWithExitCode, shell)
import System.Exit (ExitCode(..))
-- Channel selection for stable vs unstable package databases
data Channel = Unstable | Stable deriving (Eq, Show, Ord, Enum, Bounded)
data NixResult = NixResult
{ nrAttr :: String
, nrName :: String
, nrDesc :: String
} deriving (Eq, Show)
instance FromJSON NixResult where
parseJSON = withArray "NixResult" $ \arr -> do
if V.length arr >= 3
then do
attr <- parseJSON (arr V.! 0)
name <- parseJSON (arr V.! 1)
desc <- parseJSON (arr V.! 2)
return $ NixResult attr name desc
else fail "Expected a list of at least 3 elements"
-- Default fallback version for stable NixOS pinning
defaultStableVersion :: String
defaultStableVersion = "nixos-25.11"
-- Legacy function to keep the interface compileable if needed, or we can deprecate it.
nixSearch :: String -> IO (Either String [NixResult])
nixSearch query = do
-- Legacy nix search, fallback to local search if cache exists
exists <- cacheExists defaultStableVersion Unstable
if exists
then do
res <- loadCache defaultStableVersion Unstable
return $ case res of
Left err -> Left err
Right pkgs -> Right (searchLocal query pkgs)
else return $ Left "Cache not found. Please update/download the packages cache first."
-- Get the URL for the package index of a channel
channelUrl :: String -> Channel -> String
channelUrl _ Unstable = "https://channels.nixos.org/nixpkgs-unstable/packages.json.br"
channelUrl version Stable = "https://channels.nixos.org/" ++ version ++ "/packages.json.br"
-- Get the cache directory path
getCacheDir :: IO FilePath
getCacheDir = do
dir <- getXdgDirectory XdgCache "cutesetup"
createDirectoryIfMissing True dir
return dir
-- Get the cache file path for a channel
getCachePath :: String -> Channel -> IO FilePath
getCachePath version chan = do
dir <- getCacheDir
return $ case chan of
Unstable -> dir </> "packages-unstable.cache.json"
Stable -> dir </> "packages-stable-" ++ version ++ ".cache.json"
-- Check if the cache file exists for a channel
cacheExists :: String -> Channel -> IO Bool
cacheExists version chan = do
path <- getCachePath version chan
doesFileExist path
-- Load the cache database for a channel from disk
loadCache :: String -> Channel -> IO (Either String [NixResult])
loadCache version chan = do
path <- getCachePath version chan
exists <- doesFileExist path
if not exists
then return $ Left "Cache file does not exist"
else do
mVal <- decodeFileStrict path
case mVal of
Nothing -> return $ Left "Failed to parse cache JSON"
Just pkgs -> return $ Right pkgs
-- Update/download the packages list for a channel
-- Downloads the .br file and extracts/compresses it on the fly via a python shell command
updateCache :: String -> Channel -> IO (Either String String)
updateCache version chan = do
path <- getCachePath version chan
let url = channelUrl version chan
{- The pyScript is an optimization tool designed to run in a pipeline.
It processes the large Nix packages JSON index on the fly:
1. `json.load(sys.stdin)` parses the raw decompressed channel packages database object.
2. For each package entry under the "packages" map:
- `k` represents the Nixpkgs attribute path (e.g. "python3Packages.pip").
- `v.get('pname', '')` extracts the package name without the attribute path.
- `v.get('meta', {}).get('description', '')` fetches the package's description text.
3. It constructs a compact array of 3-element arrays `[attr, name, desc]` instead of full objects.
4. `json.dump` writes the compact list to stdout.
This reduces the cached file size from ~60MB to ~5MB, yielding substantial memory and parsing speedups in Haskell. -}
pyScript = "import json, sys; d=json.load(sys.stdin); out=[[k, v.get('pname',''), v.get('meta',{}).get('description','')] for k,v in d.get('packages',{}).items()]; json.dump(out, sys.stdout)"
cmd = "curl -L " ++ url ++ " | nix-shell -p brotli python3 --run \"brotli -d | python3 -c \\\"" ++ pyScript ++ "\\\"\" > " ++ show path
res <- try @SomeException $ readCreateProcessWithExitCode (shell cmd) ""
case res of
Left err -> return $ Left ("Process execution failed: " ++ show err)
Right (exitCode, _, stderr) -> case exitCode of
ExitSuccess -> return $ Right ("Successfully updated " ++ show chan ++ " cache!")
ExitFailure _ -> return $ Left ("Failed to update cache. Stderr:\n" ++ stderr)
-- Maximum number of search results to return
maxSearchResults :: Int
maxSearchResults = 40
-- Filter packages database locally based on query
searchLocal :: String -> [NixResult] -> [NixResult]
searchLocal query pkgs
| null query = []
| otherwise =
let q = map toLower query
matches p = q `isInfixOf` map toLower (nrAttr p)
|| q `isInfixOf` map toLower (nrName p)
|| q `isInfixOf` map toLower (nrDesc p)
in take maxSearchResults (filter matches pkgs)