packages feed

seihou-cli-0.4.0.0: src/Seihou/CLI/List.hs

module Seihou.CLI.List
  ( handleList,
    formatListOutput,
    formatListOutputEntries,
    applyFilters,
    ListFilter (..),
    Entry (..),
    runnableToEntryWithOrigin,
  )
where

import Data.Aeson (FromJSON (..), withObject, (.:?))
import Data.Aeson qualified as Aeson
import Data.ByteString.Lazy qualified as LBS
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Seihou.CLI.Shared (shortenHome)
import Seihou.CLI.Style (dim, red, useColor)
import Seihou.Core.Module (DiscoveredModule (..), DiscoveredRunnable (..), ModuleSource (..), RunnableKind (..), defaultSearchPaths, discoverAllModules, discoverAllRunnables)
import Seihou.Core.Types
import Seihou.Prelude
import System.Directory (doesFileExist)

-- | Origin metadata read from @.seihou-origin.json@.
data OriginInfo = OriginInfo
  { originRepoName :: Maybe Text,
    originVersion :: Maybe Text,
    originTags :: [Text]
  }

instance FromJSON OriginInfo where
  parseJSON = withObject "OriginInfo" $ \v ->
    OriginInfo <$> v .:? "repoName" <*> v .:? "version" <*> (fromMaybe [] <$> v .:? "tags")

-- | Filter criteria for the list command.  Defined here (rather than
-- imported from Commands) so the internal library does not depend on
-- optparse-applicative.
data ListFilter = ListFilter
  { filterRepo :: Maybe Text,
    filterTag :: Maybe Text,
    filterKinds :: [RunnableKind]
  }
  deriving stock (Eq, Show)

noFilter :: ListFilter
noFilter = ListFilter Nothing Nothing []

handleList :: ListFilter -> IO ()
handleList listOpts = do
  searchPaths <- defaultSearchPaths
  runnables <- discoverAllRunnables searchPaths
  colorEnabled <- useColor
  shortenedPaths <- mapM shortenHome searchPaths
  -- Read origin metadata for installed items
  origins <- Map.fromList <$> mapM readRunnableOrigin runnables
  let entries = map (runnableToEntryWithOrigin origins) runnables
      filtered = applyFilters listOpts entries
  TIO.putStr (formatListOutputEntries colorEnabled filtered shortenedPaths listOpts)

readOrigin :: DiscoveredModule -> IO (FilePath, Maybe OriginInfo)
readOrigin 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.discoveredDir, Just info)
        Nothing -> pure (dm.discoveredDir, Nothing)
    else pure (dm.discoveredDir, Nothing)

readRunnableOrigin :: DiscoveredRunnable -> IO (FilePath, Maybe OriginInfo)
readRunnableOrigin dr = do
  let originFile = dr.drDir </> ".seihou-origin.json"
  exists <- doesFileExist originFile
  if exists
    then do
      bs <- LBS.readFile originFile
      case Aeson.decode bs of
        Just info -> pure (dr.drDir, Just info)
        Nothing -> pure (dr.drDir, Nothing)
    else pure (dr.drDir, Nothing)

runnableToEntryWithOrigin :: Map FilePath (Maybe OriginInfo) -> DiscoveredRunnable -> Entry
runnableToEntryWithOrigin origins dr =
  let (originName, originVer, originTags) = case Map.lookup dr.drDir origins of
        Just (Just info) -> (info.originRepoName, info.originVersion, info.originTags)
        _ -> (Nothing, Nothing, [])
      srcLabel = sourceLabelWithOrigin dr.drSource originName originVer
      kindSuffix = case dr.drKind of
        KindModule -> ""
        KindRecipe -> " [recipe]"
        KindBlueprint -> " [blueprint]"
        KindPrompt -> " [prompt]"
   in if dr.drIsError
        then
          Entry
            { entryName = dr.drName,
              entryDesc = "[error: " <> fromMaybe "unknown" dr.drError <> "]",
              entrySource = srcLabel <> kindSuffix,
              entryIsError = True,
              entryRepoName = originName,
              entryTags = originTags,
              entryKind = dr.drKind
            }
        else
          Entry
            { entryName = dr.drName,
              entryDesc = fromMaybe "(no description)" dr.drDescription,
              entrySource = srcLabel <> kindSuffix,
              entryIsError = False,
              entryRepoName = originName,
              entryTags = originTags,
              entryKind = dr.drKind
            }

-- | Format list output — backward-compatible version without origin info.
-- Used by tests that construct DiscoveredModule values directly.
formatListOutput :: Bool -> [DiscoveredModule] -> [Text] -> Text
formatListOutput color modules searchPaths =
  let entries = map toEntry modules
   in formatListOutputEntries color entries searchPaths noFilter

formatListOutputEntries :: Bool -> [Entry] -> [Text] -> ListFilter -> Text
formatListOutputEntries color entries searchPaths listOpts
  | null entries =
      -- With nothing to show, the kind comes from the active filter (if any).
      "No "
        <> pluralize 0 (summaryNoun listOpts.filterKinds)
        <> " found."
        <> filterSuffix
        <> "\n\nSearched:\n"
        <> T.unlines (map ("  " <>) searchPaths)
  | otherwise =
      let maxNameLen = maximum (map (T.length . (.entryName)) entries)
          maxDescLen = maximum (map (T.length . (.entryDesc)) entries)
          header = "Available modules, recipes, blueprints, and prompts:\n"
          fileLines = map (formatEntry color maxNameLen maxDescLen) entries
          n = length entries
          nSources = length searchPaths
          -- The count noun reflects the kinds actually shown: a single shared
          -- kind names that kind; a mix falls back to the neutral "item".
          noun = pluralize n (summaryNoun (map (.entryKind) entries))
          summary =
            T.pack (show n)
              <> " "
              <> noun
              <> " found ("
              <> T.pack (show nSources)
              <> " sources searched)"
              <> filterSuffix
              <> "\n"
       in header <> "\n" <> T.unlines fileLines <> "\n" <> summary
  where
    filterSuffix = formatFilterSuffix listOpts

-- | Choose the singular base noun for a set of kinds.  When every kind is the
-- same, name it ("module", "recipe", "blueprint"); when the set is empty or
-- mixed, fall back to the neutral "item".
summaryNoun :: [RunnableKind] -> Text
summaryNoun kinds = case kinds of
  [] -> "item"
  (k : ks)
    | all (== k) ks -> kindBase k
    | otherwise -> "item"
  where
    kindBase KindModule = "module"
    kindBase KindRecipe = "recipe"
    kindBase KindBlueprint = "blueprint"
    kindBase KindPrompt = "prompt"

-- | Append @s@ to a singular noun unless the count is exactly one.
pluralize :: Int -> Text -> Text
pluralize n base = if n == 1 then base else base <> "s"

-- | Build a display suffix describing the active filters, e.g.
-- @" [filtered: repo=foo, tag=bar]"@.  Returns empty text when no filters
-- are active.
formatFilterSuffix :: ListFilter -> Text
formatFilterSuffix opts =
  let parts =
        maybe [] (\r -> ["repo=" <> r]) opts.filterRepo
          <> maybe [] (\t -> ["tag=" <> t]) opts.filterTag
          <> kindPart
      kindPart = case opts.filterKinds of
        [] -> []
        ks -> ["kind=" <> T.intercalate "+" (map kindNoun ks)]
   in if null parts
        then ""
        else " [filtered: " <> T.intercalate ", " parts <> "]"
  where
    kindNoun KindModule = "module"
    kindNoun KindRecipe = "recipe"
    kindNoun KindBlueprint = "blueprint"
    kindNoun KindPrompt = "prompt"

-- | Apply repo and tag filters to a list of entries.  Both filters combine
-- with AND: an entry must match all active filters to be included.
applyFilters :: ListFilter -> [Entry] -> [Entry]
applyFilters opts = filter match
  where
    match entry = repoMatch entry && tagMatch entry && kindMatch entry
    repoMatch entry = case opts.filterRepo of
      Nothing -> True
      Just r -> entry.entryRepoName == Just r
    tagMatch entry = case opts.filterTag of
      Nothing -> True
      Just t -> t `elem` entry.entryTags
    kindMatch entry = case opts.filterKinds of
      [] -> True
      ks -> entry.entryKind `elem` ks

data Entry = Entry
  { entryName :: Text,
    entryDesc :: Text,
    entrySource :: Text,
    entryIsError :: Bool,
    entryRepoName :: Maybe Text,
    entryTags :: [Text],
    entryKind :: RunnableKind
  }
  deriving stock (Eq, Show)

toEntry :: DiscoveredModule -> Entry
toEntry = toEntryWithOrigin Map.empty

toEntryWithOrigin :: Map FilePath (Maybe OriginInfo) -> DiscoveredModule -> Entry
toEntryWithOrigin origins dm =
  let (originName, originVer, originTags) = case Map.lookup dm.discoveredDir origins of
        Just (Just info) -> (info.originRepoName, info.originVersion, info.originTags)
        _ -> (Nothing, Nothing, [])
      srcLabel = sourceLabelWithOrigin dm.discoveredSource originName originVer
   in case dm.discoveredResult of
        Right m ->
          Entry
            { entryName = m.name.unModuleName,
              entryDesc = maybe "(no description)" id m.description,
              entrySource = srcLabel,
              entryIsError = False,
              entryRepoName = originName,
              entryTags = originTags,
              entryKind = KindModule
            }
        Left err ->
          Entry
            { entryName = dirName dm.discoveredDir,
              entryDesc = "[error: " <> briefError err <> "]",
              entrySource = srcLabel,
              entryIsError = True,
              entryRepoName = originName,
              entryTags = originTags,
              entryKind = KindModule
            }

sourceLabelWithOrigin :: ModuleSource -> Maybe Text -> Maybe Text -> Text
sourceLabelWithOrigin SourceProject _ _ = "project"
sourceLabelWithOrigin SourceUser _ _ = "user"
sourceLabelWithOrigin SourceInstalled Nothing Nothing = "installed"
sourceLabelWithOrigin SourceInstalled (Just rn) Nothing = "installed: " <> rn
sourceLabelWithOrigin SourceInstalled (Just rn) (Just v) = "installed: " <> rn <> " v" <> v
sourceLabelWithOrigin SourceInstalled Nothing (Just v) = "installed v" <> v

dirName :: FilePath -> Text
dirName path = case reverse (T.splitOn "/" (T.pack path)) of
  (name : _) -> name
  [] -> T.pack path

briefError :: ModuleLoadError -> Text
briefError (DhallEvalError _ _) = "Dhall evaluation failed"
briefError (DhallDecodeError _ _) = "Dhall decode failed"
briefError (ValidationError _ _) = "validation failed"
briefError (ModuleNotFound _ _) = "not found"
briefError (MissingSourceFile _ _) = "missing source file"
briefError (CircularDependency _) = "circular dependency"
briefError (RegistryEvalError _ _) = "registry eval failed"

formatEntry :: Bool -> Int -> Int -> Entry -> Text
formatEntry color maxNameLen maxDescLen entry =
  let name = entry.entryName
      desc = entry.entryDesc
      src = entry.entrySource
      paddedName = name <> T.replicate (maxNameLen - T.length name + 3) " "
      paddedDesc = desc <> T.replicate (maxDescLen - T.length desc + 3) " "
      srcTag = "(" <> src <> ")"
      colorDesc = if color && entry.entryIsError then red desc else desc
      colorSrc = if color then dim srcTag else srcTag
      colorPaddedDesc = colorDesc <> T.replicate (maxDescLen - T.length desc + 3) " "
   in "  " <> paddedName <> (if color && entry.entryIsError then colorPaddedDesc else paddedDesc) <> colorSrc