packages feed

stacker-0.0.1.0: src/CSV.hs

{-# LANGUAGE OverloadedStrings #-}

module CSV
  ( generateCSVs
  , loadSnapshotDB
  , ensureCSVFiles
  ) where

import Prelude hiding (lines, min)

import Control.Monad (unless)
import Data.ByteString.Char8 qualified as BS
import Data.List (find, sortBy, sortOn)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Ord (comparing)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import System.Directory (listDirectory, doesFileExist, createDirectoryIfMissing)
import System.FilePath (takeExtension)
import PathUtil ((</>))
import Data.Yaml qualified as Yaml
import Data.Yaml ((.:))
import Data.Aeson qualified as Aeson
import Text.Printf (printf)
import Types (LTSVersion(..), NightlyVersion(..), GHCVersion(..), Snapshot(..), SnapshotDB(..))
import XDG (getStateDir)
import System.IO (IOMode(ReadMode), SeekMode(AbsoluteSeek), hFileSize, hSeek, stderr, withBinaryFile)
import Paths_stacker (getDataDir)
import System.Directory qualified as Dir

-- Helper functions for parsing
parseGHCVersionText :: Text -> Maybe GHCVersion
parseGHCVersionText txt =
  case T.splitOn "." txt of
    [maj1Str, maj2Str, minStr] ->
      case (reads $ T.unpack maj1Str, reads $ T.unpack maj2Str, reads $ T.unpack minStr) of
        ([(maj1, "")], [(maj2, "")], [(minV, "")]) -> Just $ GHCVersion maj1 maj2 minV
        _ -> Nothing
    _ -> Nothing

parseNightlyVersionText :: Text -> Maybe NightlyVersion
parseNightlyVersionText txt =
  case T.splitOn "-" txt of
    [yearStr, monthStr, dayStr] ->
      case (reads $ T.unpack yearStr, reads $ T.unpack monthStr, reads $ T.unpack dayStr) of
        ([(year, "")], [(month, "")], [(day, "")]) -> Just $ NightlyVersion year month day
        _ -> Nothing
    _ -> Nothing

-- Helper functions for formatting
formatGHCVersion :: GHCVersion -> Text
formatGHCVersion (GHCVersion maj1 maj2 minV) =
  T.pack $ show maj1 ++ "." ++ show maj2 ++ "." ++ show minV

formatNightlyVersion :: NightlyVersion -> Text
formatNightlyVersion (NightlyVersion year month day) =
  T.pack $ printf "%d-%02d-%02d" year month day

-- | Ensure CSV files exist in state directory, copying from data directory if needed
ensureCSVFiles :: IO ()
ensureCSVFiles = do
  stateDir <- getStateDir
  createDirectoryIfMissing True stateDir

  dataDir <- getDataDir

  -- Check if any CSV file is missing
  ghcExists <- doesFileExist (stateDir </> "ghc.csv")
  ltsExists <- doesFileExist (stateDir </> "lts.csv")
  nightlyExists <- doesFileExist (stateDir </> "nightly.csv")

  unless (ghcExists && ltsExists && nightlyExists) $ do
    TIO.hPutStrLn stderr "Copying CSV files from data directory..."
    -- Copy all three CSV files from data directory
    Dir.copyFile (dataDir </> "ghc.csv") (stateDir </> "ghc.csv")
    Dir.copyFile (dataDir </> "lts.csv") (stateDir </> "lts.csv")
    Dir.copyFile (dataDir </> "nightly.csv") (stateDir </> "nightly.csv")

-- | Generate CSV files from the stackage-snapshots repository
generateCSVs :: FilePath -> IO ()
generateCSVs repoPath = do
  stateDir <- getStateDir
  createDirectoryIfMissing True stateDir

  TIO.hPutStrLn stderr $ "Processing snapshot database..."

  -- Process LTS snapshots
  TIO.hPutStrLn stderr $ "Processing LTSs..."
  ltsMap <- processLTSSnapshots (repoPath </> "lts")
  writeLTSCSV (stateDir </> "lts.csv") ltsMap

  -- Process nightly snapshots
  TIO.hPutStrLn stderr $ "Processing nightlys..."
  nightlyMap <- processNightlySnapshots (repoPath </> "nightly")
  writeNightlyCSV (stateDir </> "nightly.csv") nightlyMap

  -- Generate GHC version map
  TIO.hPutStrLn stderr $ "Computing latest snapshot per GHC version..."
  let ghcMap = generateGHCMap ltsMap nightlyMap
  writeGHCCSV (stateDir </> "ghc.csv") ghcMap

  TIO.hPutStrLn stderr $ "Done processing snapshot database."

-- | Process LTS snapshots (structure: lts/major/minor.yaml)
processLTSSnapshots :: FilePath -> IO [(LTSVersion, GHCVersion)]
processLTSSnapshots ltsDir = do
  majorDirs <- listDirectory ltsDir
  let sortedMajors = sortOn (\s -> read s :: Int) $ filter (all (`elem` ("0123456789" :: String))) majorDirs
  concat <$> mapM (processMajor ltsDir) sortedMajors
  where
    processMajor dir majorStr = do
      case reads majorStr of
        [(major, "")] -> do
          let majorPath = dir </> majorStr
          minorFiles <- listDirectory majorPath
          let sortedMinors = sortOn (\s -> read (take (length s - 5) s) :: Int) $
                             filter (\s -> takeExtension s == ".yaml") minorFiles
          mapM (processMinor majorPath major) sortedMinors
        _ -> return []
    processMinor dir major fname
      | takeExtension fname == ".yaml" = do
          let minorStr = take (length fname - 5) fname
          case reads minorStr of
            [(minor, "")] -> do
              ghc <- extractGHCVersion (dir </> fname)
              return (LTSVersion major minor, ghc)
            _ -> error $ "Invalid minor version format in " ++ fname ++
                        ": expected integer, got '" ++ minorStr ++ "'"
      | otherwise = error $ "Unexpected file in LTS directory: " ++ fname ++
                           " (expected .yaml extension)"

-- | Process nightly snapshots (structure: nightly/year/month/day.yaml)
processNightlySnapshots :: FilePath -> IO [(NightlyVersion, GHCVersion)]
processNightlySnapshots nightlyDir = do
  yearDirs <- listDirectory nightlyDir
  let sortedYears = sortOn (\s -> read s :: Int) $ filter (all (`elem` ("0123456789" :: String))) yearDirs
  concat <$> mapM (processYear nightlyDir) sortedYears
  where
    processYear dir yearStr = do
      case reads yearStr of
        [(year, "")] -> do
          let yearPath = dir </> yearStr
          monthDirs <- listDirectory yearPath
          let sortedMonths = sortOn (\s -> read s :: Int) $ filter (all (`elem` ("0123456789" :: String))) monthDirs
          months <- mapM (processMonth yearPath year) sortedMonths
          return $ concat months
        _ -> return []
    processMonth dir year monthStr = do
      case reads monthStr of
        [(month, "")] -> do
          let monthPath = dir </> monthStr
          dayFiles <- listDirectory monthPath
          let sortedDays = sortOn (\s -> read (take (length s - 5) s) :: Int) $
                           filter (\s -> takeExtension s == ".yaml") dayFiles
          mapM (processDay monthPath year month) sortedDays
        _ -> return []
    processDay dir year month fname
      | takeExtension fname == ".yaml" = do
          case reads $ take (length fname - 5) fname of
            [(day, "")] -> do
              ghc <- extractGHCVersion (dir </> fname)
              return (NightlyVersion year month day, ghc)
            _ -> error $ "Invalid day in " ++ fname
      | otherwise = error $ "Unexpected file in nightly directory: " ++ fname ++
                           " (expected .yaml extension)"

-- | Extract GHC version from a snapshot YAML file.
-- Tries the fast method first, falling back to (slow) full YAML parsing with a warning.
extractGHCVersion :: FilePath -> IO GHCVersion
extractGHCVersion file = do
  fast <- extractGHCVersionFast file
  case fast of
    Just ghc -> return ghc
    Nothing -> do
      TIO.hPutStrLn stderr $ "Warning: could not find compiler field at the end of " <> T.pack file
        <> ", falling back to slow YAML parsing"
      extractGHCVersionYaml file

-- | Number of bytes at the end of a snapshot file to search for the compiler field
tailSize :: Integer
tailSize = 4096

-- | Extract GHC version by searching the end of a snapshot YAML file for the compiler field.
-- This avoids parsing the whole (large) file.  The compiler field is the last entry,
-- either at top level (old format) or under @resolver:@ (new format):
--
-- > compiler: ghc-7.8.3
--
-- > resolver:
-- >   compiler: ghc-9.12.4
extractGHCVersionFast :: FilePath -> IO (Maybe GHCVersion)
extractGHCVersionFast file = do
  chunk <- withBinaryFile file ReadMode $ \h -> do
    size <- hFileSize h
    hSeek h AbsoluteSeek (max 0 (size - tailSize))
    BS.hGetContents h
  return $ do
    line <- find isCompilerLine $ reverse $ BS.lines chunk
    parseGHCVersionText $ T.pack $ BS.unpack $ BS.strip $ BS.drop (BS.length prefix) $ BS.dropWhile (== ' ') line
  where
    prefix = "compiler: ghc-"
    isCompilerLine l = any (`BS.isPrefixOf` l) [prefix, "  " <> prefix]

-- | Extract GHC version from a snapshot YAML file by full YAML parsing
extractGHCVersionYaml :: FilePath -> IO GHCVersion
extractGHCVersionYaml file = do
  result <- Yaml.decodeFileEither file
  case result of
    Left err -> error $ "Failed to parse " ++ file ++ ": " ++ show err
    Right (Aeson.Object obj) ->
      -- Try new format first (nested under resolver)
      case Yaml.parseMaybe (.: "resolver") obj of
        Just (Aeson.Object resolverObj) ->
          case Yaml.parseMaybe (.: "compiler") resolverObj of
            Just (Aeson.String compiler) ->
              parseCompiler compiler
            _ -> error $ "No compiler field in resolver in " ++ file
        _ ->
          -- Try old format (compiler at top level)
          case Yaml.parseMaybe (.: "compiler") obj of
            Just (Aeson.String compiler) ->
              parseCompiler compiler
            _ -> error $ "No compiler field in " ++ file
    _ -> error $ "Invalid YAML in " ++ file
  where
    parseCompiler compiler =
      case parseGHCVersionText (T.drop 4 compiler) of
        Just ghc -> return ghc
        Nothing -> error $ "Invalid GHC version format in " ++ file ++ ": " ++ T.unpack compiler

-- | Write LTS CSV file
writeLTSCSV :: FilePath -> [(LTSVersion, GHCVersion)] -> IO ()
writeLTSCSV path entries = do
  let sorted = sortBy (comparing fst) entries
  let lines = map formatLTSEntry sorted
  TIO.writeFile path $ T.unlines lines
  where
    formatLTSEntry (LTSVersion maj min, ghc) =
      T.pack (show maj) <> "." <> T.pack (show min) <> "," <> formatGHCVersion ghc

-- | Write nightly CSV file
writeNightlyCSV :: FilePath -> [(NightlyVersion, GHCVersion)] -> IO ()
writeNightlyCSV path entries = do
  let sorted = sortBy (comparing fst) entries
  let lines = map formatNightlyEntry sorted
  TIO.writeFile path $ T.unlines lines
  where
    formatNightlyEntry (nightly, ghc) =
      formatNightlyVersion nightly <> "," <> formatGHCVersion ghc

-- | Generate GHC version map from LTS and nightly maps
generateGHCMap :: [(LTSVersion, GHCVersion)] -> [(NightlyVersion, GHCVersion)] -> Map GHCVersion Snapshot
generateGHCMap ltsEntries nightlyEntries =
  let ltsMap = foldr insertLTS Map.empty ltsEntries
      nightlyMap = foldr insertNightly Map.empty nightlyEntries
  in Map.union ltsMap nightlyMap
  where
    insertLTS (lts, ghc) m =
      Map.insertWith (\_ old -> old) ghc (LTS lts) m
    insertNightly (nightly, ghc) m =
      Map.insertWith (\_ old -> old) ghc (Nightly nightly) m

-- | Write GHC CSV file
writeGHCCSV :: FilePath -> Map GHCVersion Snapshot -> IO ()
writeGHCCSV path ghcMap = do
  let sorted = sortBy (comparing fst) $ Map.toList ghcMap
  let lines = map formatGHCEntry sorted
  TIO.writeFile path $ T.unlines lines
  where
    formatGHCEntry (ghc, LTS (LTSVersion maj min)) =
      formatGHCVersion ghc <> ",lts-" <> T.pack (show maj) <> "." <> T.pack (show min)
    formatGHCEntry (ghc, Nightly nightly) =
      formatGHCVersion ghc <> ",nightly-" <> formatNightlyVersion nightly

-- | Load snapshot database from CSV files
loadSnapshotDB :: IO SnapshotDB
loadSnapshotDB = do
  stateDir <- getStateDir

  ltsMap <- readLTSCSV (stateDir </> "lts.csv")
  nightlyMap <- readNightlyCSV (stateDir </> "nightly.csv")
  ghcMap <- readGHCCSV (stateDir </> "ghc.csv")

  return $ SnapshotDB ltsMap nightlyMap ghcMap

-- | Read LTS CSV file
readLTSCSV :: FilePath -> IO (Map LTSVersion GHCVersion)
readLTSCSV path = do
  exists <- doesFileExist path
  if not exists
    then return Map.empty
    else do
      content <- TIO.readFile path
      let entries = map parseLTSLine $ T.lines content
      return $ Map.fromList $ concat entries
  where
    parseLTSLine line =
      case T.splitOn "," line of
        [ver, ghcText] ->
          case T.splitOn "." ver of
            [majStr, minStr] ->
              case (reads $ T.unpack majStr, reads $ T.unpack minStr, parseGHCVersionText ghcText) of
                ([(maj, "")], [(min, "")], Just ghc) ->
                  [(LTSVersion maj min, ghc)]
                _ -> []
            _ -> []
        _ -> []

-- | Read nightly CSV file
readNightlyCSV :: FilePath -> IO (Map NightlyVersion GHCVersion)
readNightlyCSV path = do
  exists <- doesFileExist path
  if not exists
    then return Map.empty
    else do
      content <- TIO.readFile path
      let entries = map parseNightlyLine $ T.lines content
      return $ Map.fromList $ concat entries
  where
    parseNightlyLine line =
      case T.splitOn "," line of
        [dateText, ghcText] ->
          case (parseNightlyVersionText dateText, parseGHCVersionText ghcText) of
            (Just nightly, Just ghc) -> [(nightly, ghc)]
            _ -> []
        _ -> []

-- | Read GHC CSV file
readGHCCSV :: FilePath -> IO (Map GHCVersion Snapshot)
readGHCCSV path = do
  exists <- doesFileExist path
  if not exists
    then return Map.empty
    else do
      content <- TIO.readFile path
      let entries = map parseGHCLine $ T.lines content
      return $ Map.fromList $ concat entries
  where
    parseGHCLine line =
      case T.splitOn "," line of
        [ghcText, snap] ->
          case (parseGHCVersionText ghcText, parseSnapshot snap) of
            (Just ghc, Just s) -> [(ghc, s)]
            _ -> []
        _ -> []
    parseSnapshot snap
      | T.isPrefixOf "lts-" snap =
          case T.splitOn "." (T.drop 4 snap) of
            [majStr, minStr] ->
              case (reads $ T.unpack majStr, reads $ T.unpack minStr) of
                ([(maj, "")], [(min, "")]) -> Just $ LTS $ LTSVersion maj min
                _ -> Nothing
            _ -> Nothing
      | T.isPrefixOf "nightly-" snap =
          case parseNightlyVersionText (T.drop 8 snap) of
            Just nightly -> Just $ Nightly nightly
            Nothing -> Nothing
      | otherwise = Nothing