packages feed

stacker-0.0.1.0: src/Analysis.hs

{-# LANGUAGE OverloadedStrings #-}

module Analysis
  ( analyzeStackYaml
  , analyzeAllStackYamls
  , analyzeStackYamls
  , determineNewSnapshot
  ) where

import Prelude hiding (min, span)

import Control.Applicative ((<|>))
import Control.Monad (filterM)
import Data.Functor ((<&>))
import Data.List (maximumBy)
import Data.Map.Strict qualified as Map
import Data.Maybe (catMaybes, fromMaybe)
import Data.Ord (comparing)
import Data.Text (Text)
import Data.Text qualified as T
import System.Directory (doesFileExist)
import Text.Printf (printf)

import Types (Action(..), Bump(..), SnapshotDB(..), LTSVersion(..), NightlyVersion(..), GHCVersion(..), Snapshot(..))
import StackYaml (parseStackYaml, findStackYamlFiles, findStackYamlFilesRecursive, getSymlinkMap)

-- | Analyze a single stack.yaml file
analyzeStackYaml :: SnapshotDB -> Map.Map FilePath FilePath -> FilePath -> IO (Maybe Action)
analyzeStackYaml db symlinkMap file = do
  -- Check if this file is a symlink to another stack*.yaml file in our list
  let symlinkTarget = Map.lookup file symlinkMap

  parseStackYaml file <&> \case
    Nothing -> Nothing
    Just (oldSnap, isResolver, span) -> do
      let newSnap = determineNewSnapshot db oldSnap
      Just $ Action file oldSnap newSnap isResolver span symlinkTarget

-- | Analyze specific stack*.yaml files, or all if empty list
-- If recursive is True, search recursively in all subdirectories
analyzeStackYamls :: SnapshotDB -> [FilePath] -> Bool -> IO [Action]
analyzeStackYamls db files recursive = do
  -- Get files to analyze: either auto-discover or use provided list
  -- Non-existent files are silently filtered out
  filesToAnalyze <-
    if null files
      then if recursive
           then findStackYamlFilesRecursive
           else findStackYamlFiles
      else filterM doesFileExist files
  symlinkMap <- getSymlinkMap filesToAnalyze
  results <- mapM (analyzeStackYaml db symlinkMap) filesToAnalyze
  return $ catMaybes results

-- | Analyze all stack*.yaml files in the current directory
analyzeAllStackYamls :: SnapshotDB -> IO [Action]
analyzeAllStackYamls db = analyzeStackYamls db [] False

-- | Determine the new snapshot for a given old snapshot
determineNewSnapshot :: SnapshotDB -> Text -> Bump
determineNewSnapshot db oldSnap
  | T.isPrefixOf "lts-" oldSnap = fromMaybe Unknown $ determineLTSBump db oldSnap
  | T.isPrefixOf "nightly-" oldSnap = fromMaybe Unknown $ determineNightlyBump db oldSnap
  | otherwise = Unknown

-- | Compare the latest known snapshot with the old one.
-- If the old snapshot is newer than anything we know of,
-- our database is outdated and we cannot give advice.
compareWithLatest :: Ord a => a -> a -> Snapshot -> Bump
compareWithLatest old latest snap = case compare latest old of
  GT -> BumpTo (formatSnapshot snap)
  EQ -> UpToDate
  LT -> Unknown

-- | Determine bump for LTS snapshot
determineLTSBump :: SnapshotDB -> Text -> Maybe Bump
determineLTSBump db oldSnap = do
  oldVersion <- parseLTSSnapshot oldSnap
  -- Find the latest LTS with the same major version
  let sameMajor = filter (\(LTSVersion maj _, _) -> maj == ltsMajor oldVersion) $ Map.toList (dbLTS db)
  if null sameMajor
    then Nothing
    else do
      let (latestVersion, _) = maximumBy (comparing fst) sameMajor
      return $ compareWithLatest oldVersion latestVersion (LTS latestVersion)

-- | Determine bump for nightly snapshot
determineNightlyBump :: SnapshotDB -> Text -> Maybe Bump
determineNightlyBump db oldSnap = do
  oldVersion <- parseNightlySnapshot oldSnap
  -- The exact nightly may be missing from the database
  -- (e.g. a date for which no nightly was published);
  -- then take the GHC version of the closest known neighbour.
  (_, oldGHC) <- Map.lookupLE oldVersion (dbNightly db) <|> Map.lookupGE oldVersion (dbNightly db)
  -- Get GHC major version (e.g., (9,6) from GHCVersion 9 6 1)
  let ghcMajor = getGHCMajor oldGHC
  -- Find if there's an LTS for this GHC major version
  let ltsForGHC = filter (\(_, ghc) -> getGHCMajor ghc == ghcMajor) $ Map.toList (dbLTS db)
  if not (null ltsForGHC)
    then do
      -- Bump to latest LTS for this GHC major
      let (latestLTS, _) = maximumBy (comparing fst) ltsForGHC
      return $ BumpTo $ formatSnapshot (LTS latestLTS)
    else do
      -- Bump to latest nightly for this GHC major
      let nightliesForGHC = filter (\(_, ghc) -> getGHCMajor ghc == ghcMajor) $ Map.toList (dbNightly db)
      if null nightliesForGHC
        then Nothing
        else do
          let (latestNightly, _) = maximumBy (comparing fst) nightliesForGHC
          return $ compareWithLatest oldVersion latestNightly (Nightly latestNightly)

-- | Get GHC major version from full version (e.g., (9,6) from GHCVersion 9 6 1)
getGHCMajor :: GHCVersion -> (Int, Int)
getGHCMajor (GHCVersion maj1 maj2 _) = (maj1, maj2)

-- | Parse LTS snapshot string
parseLTSSnapshot :: Text -> Maybe LTSVersion
parseLTSSnapshot snap =
  case T.splitOn "." (T.drop 4 snap) of  -- Remove "lts-"
    [majStr, minStr] ->
      case (reads $ T.unpack majStr, reads $ T.unpack minStr) of
        ([(maj, "")], [(min, "")]) -> Just $ LTSVersion maj min
        _ -> Nothing
    _ -> Nothing

-- | Parse nightly snapshot string
parseNightlySnapshot :: Text -> Maybe NightlyVersion
parseNightlySnapshot snap =
  case T.splitOn "-" (T.drop 8 snap) of  -- Remove "nightly-"
    [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

-- | Format a snapshot as a string
formatSnapshot :: Snapshot -> Text
formatSnapshot (LTS (LTSVersion maj min)) =
  "lts-" <> T.pack (show maj) <> "." <> T.pack (show min)
formatSnapshot (Nightly (NightlyVersion year month day)) =
  T.pack $ "nightly-" ++ printf "%d-%02d-%02d" year month day