packages feed

baikai-kit-0.2.0.0: src/Baikai/Kit/Status.hs

module Baikai.Kit.Status
  ( KitState (..),
    StatusReport (..),
    StatusRow (..),
    UpstreamAvailability (..),
    classify,
    collectStatus,
    kitStatus,
    renderState,
    renderStatusTable,
  )
where

import Baikai.AgentAssets (AgentAssetProvider, agentTargetPath, skillTargetPath)
import Baikai.Interactive (InteractiveScope (InteractiveProjectScope))
import Baikai.Kit.Config (KitConfig, KitScope (..), providerAgentsBase, providerLabel, sidecarFileName)
import Baikai.Kit.Error (KitError (..))
import Baikai.Kit.Install (loadManifestMaybe, lookupItem)
import Baikai.Kit.Manifest (KitItem, KitItemKind (..), itemKind, itemSources, itemVersion, kindLabel)
import Baikai.Kit.Repo (RepoRefresh (..), ensureKitRepo)
import Baikai.Kit.Sidecar (SidecarMeta, computeKitHash, readSidecar, sidecarPath)
import Baikai.Prelude
import Control.Monad (forM)
import Data.List (groupBy, isPrefixOf, isSuffixOf, nub, sort, sortOn)
import Data.Maybe (fromMaybe)
import Data.Text qualified as Text
import System.Directory (doesDirectoryExist, listDirectory)
import System.FilePath (takeDirectory, (</>))

data KitState
  = KitUpToDate
  | KitOutdated
  | KitDirty
  | KitDirtyOutdated
  | KitDelisted
  | KitUpstreamRefused
  | KitUnknown
  deriving stock (Eq, Ord, Show)

-- | Whether the cached upstream could be consulted for this report.
data UpstreamAvailability
  = -- | The cache was refreshed and its manifest read.
    UpstreamReady
  | -- | The cache could not be refreshed; the rows compare against the
    --   copy already on disk. The 'Text' is git's output.
    UpstreamStale !Text
  | -- | There is no usable cache: the rows say what is installed and
    --   nothing about how it compares.
    UpstreamUnavailable !KitError
  deriving stock (Eq, Show)

-- | What @kit status@ found, for the caller to render.
data StatusReport = StatusReport
  { upstream :: !UpstreamAvailability,
    rows :: ![StatusRow]
  }
  deriving stock (Eq, Generic, Show)

data StatusRow = StatusRow
  { name :: !Text,
    kind :: !Text,
    scope :: !Text,
    providers :: !Text,
    installedVersion :: !(Maybe Text),
    latestVersion :: !(Maybe Text),
    state :: !KitState
  }
  deriving stock (Eq, Generic, Show)

renderState :: KitState -> Text
renderState = \case
  KitUpToDate -> "up-to-date"
  KitOutdated -> "outdated"
  KitDirty -> "dirty"
  KitDirtyOutdated -> "dirty+outdated"
  KitDelisted -> "delisted"
  KitUpstreamRefused -> "refused"
  KitUnknown -> "unknown"

classify :: Maybe SidecarMeta -> Maybe KitItem -> Maybe Text -> KitState
classify Nothing _ _ = KitUnknown
classify (Just _) Nothing _ = KitDelisted
classify (Just sm) (Just it) mUpstreamHash =
  let outdated = case itemVersion it of
        Just latest -> sm ^. #version /= Just latest
        Nothing -> False
      dirty = case mUpstreamHash of
        Just up -> up /= sm ^. #hash
        Nothing -> False
   in case (outdated, dirty) of
        (True, True) -> KitDirtyOutdated
        (True, False) -> KitOutdated
        (False, True) -> KitDirty
        (False, False) -> KitUpToDate

-- | Collect the status of everything installed. Needs no network: a kit
--   repository that cannot be reached is reported as
--   'UpstreamUnavailable' and the installed rows are still returned.
kitStatus :: KitConfig -> IO StatusReport
kitStatus config = do
  repo <- ensureKitRepo config
  case repo of
    Left err -> report (UpstreamUnavailable err) ""
    Right resolved -> do
      let availability = case resolved ^. #refresh of
            RepoStale err -> UpstreamStale err
            RepoCloned -> UpstreamReady
            RepoPulled -> UpstreamReady
      manifest <- loadManifestMaybe (resolved ^. #dir)
      case manifest of
        Left err -> report (UpstreamUnavailable err) ""
        Right Nothing -> report availability ""
        Right (Just _) -> report availability (resolved ^. #dir)
  where
    report upstream cacheDir = do
      rows <- collectStatus config cacheDir [(UserScope, "user"), (ProjectScope, "project")]
      pure StatusReport {upstream, rows}

collectStatus :: KitConfig -> FilePath -> [(KitScope, Text)] -> IO [StatusRow]
collectStatus config cacheDir scopes = do
  -- A manifest that cannot be read is treated here as no manifest; the
  -- report as a whole says so through 'UpstreamUnavailable'.
  mManifest <- either (const Nothing) id <$> loadManifestMaybe cacheDir
  fmap concat . forM scopes $ \(scope, scopeText) -> do
    items <- scanInstalled config scope
    forM items $ \(provider, baseDir, itemName', scannedKind) -> do
      let mItem = lookupItem itemName' =<< mManifest
      mSidecar <- readSidecar (sidecarPath provider scannedKind itemName' baseDir (sidecarFileName config))
      upstream <- upstreamHash cacheDir mItem
      let state' = case upstream of
            Left _ -> KitUpstreamRefused
            Right mUpstreamHash -> classify mSidecar mItem mUpstreamHash
      pure
        StatusRow
          { name = itemName',
            kind = maybe (kindLabel scannedKind) itemKind mItem,
            scope = scopeText,
            providers = providerLabel provider,
            installedVersion = mSidecar >>= (^. #version),
            latestVersion = mItem >>= itemVersion,
            state = state'
          }

-- | The hash of an item's sources as they are in the cached checkout.
--
--   @Right Nothing@ means there is nothing to compare against: no cache,
--   no manifest entry, or an upstream source that is simply gone. A
--   'Left' means the upstream listing is one this installer refuses to
--   read — a symbolic link, an escaping path, an unsafe manifest string —
--   which 'collectStatus' shows as 'KitUpstreamRefused'.
upstreamHash :: FilePath -> Maybe KitItem -> IO (Either KitError (Maybe Text))
upstreamHash "" _ = pure (Right Nothing)
upstreamHash _ Nothing = pure (Right Nothing)
upstreamHash cacheDir (Just item) =
  case itemSources item of
    Left err -> pure (Left err)
    Right sources -> do
      result <- computeKitHash cacheDir (sources ^. #base) (sources ^. #files)
      pure $ case result of
        Left (KitSourceMissing _) -> Right Nothing
        Left err -> Left err
        Right h -> Right (Just h)

scanInstalled :: KitConfig -> KitScope -> IO [(AgentAssetProvider, FilePath, Text, KitItemKind)]
scanInstalled config scope = fmap concat $
  forM (config ^. #providers) $ \provider -> do
    baseDir <- providerAgentsBase config provider scope
    skillItems <- scanSkills provider (takeDirectory (baseDir </> skillTargetPath provider InteractiveProjectScope "__scan__"))
    agentItems <- scanAgents provider (takeDirectory (baseDir </> agentTargetPath provider InteractiveProjectScope "__scan__"))
    pure [(provider', baseDir, itemName', kind) | (provider', itemName', kind) <- skillItems ++ agentItems]

scanSkills :: AgentAssetProvider -> FilePath -> IO [(AgentAssetProvider, Text, KitItemKind)]
scanSkills provider dir = do
  exists <- doesDirectoryExist dir
  if exists
    then do
      entries <- listDirectory dir
      pure [(provider, Text.pack e, SkillKind) | e <- entries, visible e]
    else pure []

scanAgents :: AgentAssetProvider -> FilePath -> IO [(AgentAssetProvider, Text, KitItemKind)]
scanAgents provider dir = do
  exists <- doesDirectoryExist dir
  if exists
    then do
      entries <- listDirectory dir
      let files = filter (\f -> agentExtension provider `isSuffixOf` f && visible f) entries
      pure [(provider, Text.pack (dropAgentExtension provider f), AgentKind) | f <- files]
    else pure []

-- | The table @kit status@ prints, without a trailing newline.
renderStatusTable :: [StatusRow] -> Text
renderStatusTable [] = "No kit items installed."
renderStatusTable rows = Text.intercalate "\n" (hdr : map printRow displayRows)
  where
    displayRows = aggregateStatusRows rows
    nameW = colWidth "NAME" (^. #name)
    kindW = colWidth "TYPE" (^. #kind)
    scopeW = colWidth "SCOPE" (^. #scope)
    providersW = colWidth "PROVIDERS" (^. #providers)
    instW = colWidth "INSTALLED" (renderMVer . view #installedVersion)
    latW = colWidth "LATEST" (renderMVer . view #latestVersion)
    hdr =
      Text.justifyLeft (nameW + 2) ' ' "NAME"
        <> Text.justifyLeft (kindW + 2) ' ' "TYPE"
        <> Text.justifyLeft (scopeW + 2) ' ' "SCOPE"
        <> Text.justifyLeft (providersW + 2) ' ' "PROVIDERS"
        <> Text.justifyLeft (instW + 2) ' ' "INSTALLED"
        <> Text.justifyLeft (latW + 2) ' ' "LATEST"
        <> "STATE"

    colWidth colTitle f =
      maximum (Text.length colTitle : map (Text.length . f) displayRows)

    renderMVer = fromMaybe "-"

    printRow row =
      Text.justifyLeft (nameW + 2) ' ' (row ^. #name)
        <> Text.justifyLeft (kindW + 2) ' ' (row ^. #kind)
        <> Text.justifyLeft (scopeW + 2) ' ' (row ^. #scope)
        <> Text.justifyLeft (providersW + 2) ' ' (row ^. #providers)
        <> Text.justifyLeft (instW + 2) ' ' (renderMVer (row ^. #installedVersion))
        <> Text.justifyLeft (latW + 2) ' ' (renderMVer (row ^. #latestVersion))
        <> renderState (row ^. #state)

aggregateStatusRows :: [StatusRow] -> [StatusRow]
aggregateStatusRows rows =
  map summarize grouped
  where
    grouped = groupBy sameKey $ sortOn rowKey rows
    rowKey row =
      ( row ^. #name,
        row ^. #kind,
        row ^. #scope,
        row ^. #installedVersion,
        row ^. #latestVersion,
        row ^. #state
      )
    sameKey a b = rowKey a == rowKey b
    summarize groupRows@(firstRow : _) =
      firstRow & #providers .~ Text.intercalate "," (sort (nub (map (^. #providers) groupRows)))
    summarize [] = error "aggregateStatusRows: empty group"

agentExtension :: AgentAssetProvider -> String
agentExtension provider =
  case agentTargetPath provider InteractiveProjectScope "__scan__" of
    path
      | ".toml" `isSuffixOf` path -> ".toml"
      | ".md" `isSuffixOf` path -> ".md"
      | otherwise -> ""

dropAgentExtension :: AgentAssetProvider -> FilePath -> FilePath
dropAgentExtension provider file =
  let ext = agentExtension provider
   in take (length file - length ext) file

visible :: FilePath -> Bool
visible = not . ("." `isPrefixOf`)