packages feed

seihou-cli-0.4.0.0: src-exe/Seihou/CLI/Outdated.hs

module Seihou.CLI.Outdated
  ( handleOutdated,
    checkInstalledModulesForUpdates,
    readOriginWithModule,
    moduleNameFromDm,
    checkSource,
  )
where

import Control.Exception (SomeException, try)
import Data.Aeson qualified as Aeson
import Data.Aeson.Encode.Pretty (encodePretty)
import Data.ByteString.Lazy qualified as LBS
import Data.Map.Strict qualified as Map
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Seihou.CLI.Commands (OutdatedOpts (..))
import Seihou.CLI.InstallShared (OriginInfo (..))
import Seihou.CLI.RemoteVersion (fetchTrueModuleVersion)
import Seihou.CLI.Style (dim, green, red, useColor, yellow)
import Seihou.CLI.VersionCompare
  ( CheckStats (..),
    OutdatedEntry (..),
    OutdatedStatus (..),
    compareVersions,
  )
import Seihou.Core.Install (parseModuleName)
import Seihou.Core.Module (DiscoveredModule (..), ModuleSource (..), defaultSearchPaths, discoverAllModules)
import Seihou.Core.Types (Module (..), ModuleName (..))
import Seihou.Prelude
import System.Directory (doesFileExist)
import System.Exit (ExitCode (..))
import System.IO.Temp (withSystemTempDirectory)
import System.Process (readProcessWithExitCode)

handleOutdated :: OutdatedOpts -> IO ()
handleOutdated oopts = do
  searchPaths <- defaultSearchPaths
  modules <- discoverAllModules searchPaths
  let installed = filter (\dm -> dm.discoveredSource == SourceInstalled) modules

  if null installed
    then TIO.putStrLn "No installed modules found."
    else do
      (entries, _stats) <- checkInstalledModulesForUpdates installed
      if null entries
        then TIO.putStrLn "No installed modules with origin metadata found."
        else
          if oopts.outdatedJson
            then LBS.putStr (encodePretty entries)
            else renderTable entries

-- | Check a list of already-discovered modules for updates.
--
-- Filters to @SourceInstalled@ modules internally, reads @.seihou-origin.json@
-- for each, groups by source URL, clones each source shallowly, and compares
-- installed versions against what the remote registry advertises.
--
-- Prints progress lines to stdout via 'checkSource'. Returns the flat list
-- of outdated entries plus stats describing how many modules were actually
-- checked versus skipped for lack of origin metadata.
checkInstalledModulesForUpdates ::
  [DiscoveredModule] ->
  IO ([OutdatedEntry], CheckStats)
checkInstalledModulesForUpdates modules = do
  let installed = filter (\dm -> dm.discoveredSource == SourceInstalled) modules
  originsWithModules <- mapM readOriginWithModule installed
  let withOrigins = [(dm, origin) | (dm, Just origin) <- originsWithModules]
      skipped = length installed - length withOrigins
  if null withOrigins
    then
      pure
        ( [],
          CheckStats {checkedCount = 0, skippedNoOrigin = skipped}
        )
    else do
      let grouped =
            Map.toList $
              Map.fromListWith
                (++)
                [(origin.sourceUrl, [(dm, origin)]) | (dm, origin) <- withOrigins]
      TIO.putStrLn "Checking installed modules for updates..."
      entries <- concat <$> mapM checkSource grouped
      pure
        ( entries,
          CheckStats {checkedCount = length entries, skippedNoOrigin = skipped}
        )

-- | Read origin info from a discovered module's directory.
readOriginWithModule :: DiscoveredModule -> IO (DiscoveredModule, Maybe OriginInfo)
readOriginWithModule dm = do
  let originFile = dm.discoveredDir </> ".seihou-origin.json"
  exists <- doesFileExist originFile
  if exists
    then do
      bs <- LBS.readFile originFile
      case Aeson.decode bs of
        Just info -> pure (dm, Just info)
        Nothing -> pure (dm, Nothing)
    else pure (dm, Nothing)

-- | Check a single source URL for updates. Clones the repo and compares versions.
--
-- The comparison must happen inside 'withSystemTempDirectory' because
-- 'fetchTrueModuleVersion' reads @module.dhall@ off disk. If we returned
-- the clone path to the caller, the temp directory would already be gone
-- by the time the Dhall read ran, and every module would resolve to
-- @Nothing@ (appearing as \"unversioned\").
checkSource :: (Text, [(DiscoveredModule, OriginInfo)]) -> IO [OutdatedEntry]
checkSource (sourceUrl, modulesWithOrigins) = do
  let repoName = parseModuleName sourceUrl
  TIO.putStrLn $ "  Cloning " <> T.pack repoName <> "..."

  result <- try $ withSystemTempDirectory "seihou-outdated" $ \tmpDir -> do
    let cloneDir = tmpDir </> repoName
    (exitCode, _stdout, _stderr) <- readProcessWithExitCode "git" ["clone", "--depth", "1", T.unpack sourceUrl, cloneDir] ""
    case exitCode of
      ExitFailure _ -> pure Nothing
      ExitSuccess ->
        Just <$> mapM (compareModule cloneDir) modulesWithOrigins

  case result of
    Left (_ :: SomeException) ->
      pure [mkUnreachable dm origin | (dm, origin) <- modulesWithOrigins]
    Right Nothing ->
      pure [mkUnreachable dm origin | (dm, origin) <- modulesWithOrigins]
    Right (Just entries) -> pure entries

-- | Compare a single installed module against the remote contents.
compareModule :: FilePath -> (DiscoveredModule, OriginInfo) -> IO OutdatedEntry
compareModule cloneDir (dm, origin) = do
  let name = moduleNameFromDm dm
      installedVer = origin.version
  availableVer <- fetchAvailable cloneDir (ModuleName name)
  let status = compareVersions installedVer availableVer
  pure
    OutdatedEntry
      { moduleName = name,
        installedVersion = installedVer,
        availableVersion = availableVer,
        status = status
      }

-- | Look up the available version of a module in a cloned repo. Wraps
-- 'fetchTrueModuleVersion' so that fetch errors collapse to @Nothing@ for
-- the consumer-facing comparison; downstream renderers display this as
-- "unversioned".
fetchAvailable :: FilePath -> ModuleName -> IO (Maybe Text)
fetchAvailable cloneDir name = do
  result <- fetchTrueModuleVersion cloneDir name
  case result of
    Right v -> pure v
    Left _ -> pure Nothing

-- | Compare installed and available version strings.
-- | Extract the module name text from a DiscoveredModule.
moduleNameFromDm :: DiscoveredModule -> Text
moduleNameFromDm dm = case dm.discoveredResult of
  Right m -> m.name.unModuleName
  Left _ -> dirName dm.discoveredDir

-- | Extract the last path component as a name.
dirName :: FilePath -> Text
dirName path = case reverse (T.splitOn "/" (T.pack path)) of
  (name : _) -> name
  [] -> T.pack path

-- | Create an unreachable entry for a module.
mkUnreachable :: DiscoveredModule -> OriginInfo -> OutdatedEntry
mkUnreachable dm origin =
  OutdatedEntry
    { moduleName = moduleNameFromDm dm,
      installedVersion = origin.version,
      availableVersion = Nothing,
      status = Unreachable
    }

-- | Render the outdated report as a table.
renderTable :: [OutdatedEntry] -> IO ()
renderTable entries = do
  colorEnabled <- useColor
  let maxNameLen = max 6 (maximum (map (T.length . (.moduleName)) entries))
      maxInstLen = max 9 (maximum (map (T.length . maybe "(none)" id . (.installedVersion)) entries))
      maxAvailLen = max 9 (maximum (map (T.length . maybe "(none)" id . (.availableVersion)) entries))

      padR n t = t <> T.replicate (n - T.length t + 2) " "

      header =
        padR maxNameLen "Module"
          <> padR maxInstLen "Installed"
          <> padR maxAvailLen "Available"
          <> "Status"

      formatRow e =
        let instText = maybe "(none)" id e.installedVersion
            availText = maybe "(none)" id e.availableVersion
            statusTxt = case e.status of
              UpToDate -> if colorEnabled then green "up to date" else "up to date"
              OutdatedSt -> if colorEnabled then red "outdated" else "outdated"
              Unversioned -> if colorEnabled then dim "unversioned" else "unversioned"
              Unreachable -> if colorEnabled then yellow "unreachable" else "unreachable"
         in padR maxNameLen e.moduleName
              <> padR maxInstLen instText
              <> padR maxAvailLen availText
              <> statusTxt

  TIO.putStrLn ""
  TIO.putStrLn header
  mapM_ (TIO.putStrLn . formatRow) entries

  let total = length entries
      outdated = length (filter (\e -> e.status == OutdatedSt) entries)
  TIO.putStrLn ""
  TIO.putStrLn $
    T.pack (show total)
      <> " module(s) checked, "
      <> T.pack (show outdated)
      <> " outdated."