packages feed

tui-launcher-0.0.1: src/TuiLauncher/Config.hs

{-# LANGUAGE MultilineStrings #-}

-- | Config file loading, validation, and normalization.
module TuiLauncher.Config (
    defaultConfigText,
    loadResolvedConfig,
) where

import Control.Applicative ((<|>))
import Control.Monad (unless, when)
import Data.List (isPrefixOf)
import Data.List.NonEmpty (NonEmpty (..))
import Data.Maybe (fromMaybe)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import System.Directory (
    createDirectoryIfMissing,
    doesFileExist,
    getHomeDirectory,
 )
import System.Environment (lookupEnv)
import System.FilePath (isAbsolute, takeDirectory, (</>))
import Toml qualified
import TuiLauncher.Types

{- | Starter config written on first launch when no default config exists.

The generated file keeps only the shell entry enabled and leaves the other
starter entries in place as commented examples.
-}
defaultConfigText :: T.Text
defaultConfigText =
    """
    [layout]
    tile-width = 20
    tile-height = 5
    tile-spacing = 1

    # [shell]
    # program = "/bin/bash"
    # login = false

    [[entries]]
    name = "Shell"
    command = "exec \\\"${SHELL:-/bin/sh}\\\""
    # color = "bright-blue"

    # [[entries]]
    # name = "nvim"
    # command = "nvim"
    # color = "blue"
    # working-dir = "~/Code"

    # [[entries]]
    # name = "Tmux"
    # command = "tmux"
    # color = "cyan"
    # working-dir = "~/Code/project"
    # shell-program = "/bin/zsh"
    # shell-login = true

    # [[entries]]
    # name = "Codex"
    # command = "codex"
    # color = "bright-magenta"

    # [[entries]]
    # name = "Claude"
    # command = "claude"
    # color = "orange"
    """

-- | Load, parse, validate, and normalize the active config file.
loadResolvedConfig :: Maybe FilePath -> IO ResolvedConfig
loadResolvedConfig overridePath = do
    home <- getHomeDirectory
    (configLocation, configPath, autoCreated) <- resolveConfigPath home overridePath
    decoded <- Toml.decodeFileEither rawConfigCodec configPath
    rawConfig <- either (fail . T.unpack . Toml.prettyTomlDecodeErrors) pure decoded
    validated <- validateConfig home configLocation rawConfig
    pure
        ResolvedConfig
            { resolvedPath = configPath
            , resolvedLocation = configLocation
            , resolvedAutoCreated = autoCreated
            , resolvedConfig = validated
            }

-- | Resolve which config file to use and create the default one if needed.
resolveConfigPath :: FilePath -> Maybe FilePath -> IO (ConfigLocation, FilePath, Bool)
resolveConfigPath home = \case
    Nothing -> do
        let defaultPath = home </> ".config" </> "tui-launcher" </> "config.toml"
        exists <- doesFileExist defaultPath
        if exists
            then pure (DefaultConfig defaultPath, defaultPath, False)
            else do
                createDirectoryIfMissing True (takeDirectory defaultPath)
                TIO.writeFile defaultPath defaultConfigText
                pure (DefaultConfig defaultPath, defaultPath, True)
    Just path -> do
        expanded <- expandPath home path
        exists <- doesFileExist expanded
        unless exists $
            fail ("Config file does not exist: " <> expanded)
        pure (OverrideConfig expanded, expanded, False)

-- | Expand a leading @~@ against the supplied home directory.
expandPath :: FilePath -> FilePath -> IO FilePath
expandPath home path
    | "~/" `isPrefixOf` path = pure (home </> drop 2 path)
    | path == "~" = pure home
    | otherwise = pure path

-- | Validate raw TOML data into the application config used by the UI.
validateConfig :: FilePath -> ConfigLocation -> RawConfig -> IO AppConfig
validateConfig home location RawConfig{..} = do
    layout <- validateLayout rawLayout
    entries <- validateEntries home location rawShell rawEntries
    pure
        AppConfig
            { configLayout = layout
            , configEntries = entries
            }

-- | Validate layout values and apply defaults for missing settings.
validateLayout :: Maybe RawLayout -> IO LayoutConfig
validateLayout maybeLayout = do
    let raw = fromMaybe (RawLayout Nothing Nothing Nothing) maybeLayout
        width = fromMaybe (layoutTileWidth defaultLayout) (rawTileWidth raw)
        height = fromMaybe (layoutTileHeight defaultLayout) (rawTileHeight raw)
        spacing = fromMaybe (layoutTileSpacing defaultLayout) (rawTileSpacing raw)
    when (width <= 0) $ fail "layout.tile-width must be positive"
    when (height <= 0) $ fail "layout.tile-height must be positive"
    when (spacing < 0) $ fail "layout.tile-spacing must be non-negative"
    pure
        LayoutConfig
            { layoutTileWidth = width
            , layoutTileHeight = height
            , layoutTileSpacing = spacing
            }

-- | Validate and resolve all configured entries.
validateEntries :: FilePath -> ConfigLocation -> Maybe RawShell -> [EntryConfig] -> IO (NonEmpty ResolvedEntry)
validateEntries home location globalShell entries = do
    resolvedEntries <- traverse (resolveEntry home location globalShell) entries
    case resolvedEntries of
        [] -> fail "At least one [[entries]] table is required"
        entry : rest -> pure (entry :| rest)

-- | Resolve a single entry into the runtime format used by the launcher.
resolveEntry :: FilePath -> ConfigLocation -> Maybe RawShell -> EntryConfig -> IO ResolvedEntry
resolveEntry home location globalShell EntryConfig{..} = do
    let strippedName = T.strip entryName
        strippedCommand = T.strip entryCommand
    when (T.null strippedName) $ fail "entries.name must not be empty"
    when (T.null strippedCommand) $ fail "entries.command must not be empty"
    color <- traverse resolveEntryColor entryColor
    workingDir <- traverse (resolveWorkingDir home location) entryWorkingDir
    shellProgramText <- resolveShellProgram home globalShell entryShellProgram
    let login = fromMaybe (maybe False (fromMaybe False . rawShellLogin) globalShell) entryShellLogin
    pure
        ResolvedEntry
            { resolvedName = strippedName
            , resolvedCommand = strippedCommand
            , resolvedColor = color
            , resolvedWorkingDirDisplay = fmap (displayWorkingDir home) workingDir
            , resolvedWorkingDir = workingDir
            , resolvedShell =
                ShellConfig
                    { shellProgram = T.unpack shellProgramText
                    , shellLogin = login
                    }
            }

-- | Resolve the shell program for an entry using the configured precedence.
resolveShellProgram :: FilePath -> Maybe RawShell -> Maybe T.Text -> IO T.Text
resolveShellProgram _home globalOverride entryOverride =
    case entryOverride <|> (globalOverride >>= rawShellProgram) of
        Just program -> validateShellProgram program
        Nothing -> do
            envShell <- lookupShell
            pure $
                case fmap (T.strip . T.pack) envShell of
                    Just shellPath | not (T.null shellPath) -> shellPath
                    _ -> "/bin/sh"

-- | Read the current shell from the environment.
lookupShell :: IO (Maybe FilePath)
lookupShell = lookupEnv "SHELL"

-- | Reject blank shell program values.
validateShellProgram :: T.Text -> IO T.Text
validateShellProgram rawProgram = do
    let stripped = T.strip rawProgram
    when (T.null stripped) $
        fail "shell.program and entries.shell-program must not be empty"
    pure stripped

-- | Resolve an entry working directory relative to the proper base path.
resolveWorkingDir :: FilePath -> ConfigLocation -> T.Text -> IO FilePath
resolveWorkingDir home location rawPath = do
    let strippedPath = T.strip rawPath
    when (T.null strippedPath) $
        fail "entries.working-dir must not be empty"
    let pathString = T.unpack strippedPath
    expanded <- expandPath home pathString
    if isAbsolute expanded
        then pure expanded
        else pure (baseDirectory location home </> expanded)

-- | Determine the base directory for relative path resolution.
baseDirectory :: ConfigLocation -> FilePath -> FilePath
baseDirectory location home = case location of
    DefaultConfig _ -> home
    OverrideConfig path -> takeDirectory path

-- | Render a resolved working directory with @~@ for the user's home.
displayWorkingDir :: FilePath -> FilePath -> T.Text
displayWorkingDir home path
    | path == home = "~"
    | (home <> "/") `isPrefixOf` path =
        T.pack ("~/" <> drop (length home + 1) path)
    | otherwise = T.pack path

-- | Validate and normalize an optional entry color.
resolveEntryColor :: T.Text -> IO EntryColor
resolveEntryColor rawColor = do
    let normalized = T.toLower (T.strip rawColor)
    when (T.null normalized) $
        fail "entries.color must not be empty"
    case normalized of
        "black" -> pure EntryBlack
        "red" -> pure EntryRed
        "orange" -> pure EntryOrange
        "green" -> pure EntryGreen
        "yellow" -> pure EntryYellow
        "blue" -> pure EntryBlue
        "magenta" -> pure EntryMagenta
        "cyan" -> pure EntryCyan
        "white" -> pure EntryWhite
        "bright-black" -> pure EntryBrightBlack
        "bright-red" -> pure EntryBrightRed
        "bright-green" -> pure EntryBrightGreen
        "bright-yellow" -> pure EntryBrightYellow
        "bright-blue" -> pure EntryBrightBlue
        "bright-magenta" -> pure EntryBrightMagenta
        "bright-cyan" -> pure EntryBrightCyan
        "bright-white" -> pure EntryBrightWhite
        "gray" -> pure EntryBrightBlack
        "grey" -> pure EntryBrightBlack
        other ->
            fail
                ( "Unsupported entries.color: "
                    <> T.unpack other
                    <> ". Use an ANSI color name like red, bright-blue, or cyan."
                )

-- | TOML codec for the top-level config structure.
rawConfigCodec :: Toml.TomlCodec RawConfig
rawConfigCodec =
    RawConfig
        <$> (Toml.dioptional (Toml.table rawLayoutCodec "layout") Toml..= rawLayout)
        <*> (Toml.dioptional (Toml.table rawShellCodec "shell") Toml..= rawShell)
        <*> (Toml.list entryCodec "entries" Toml..= rawEntries)

-- | TOML codec for the layout table.
rawLayoutCodec :: Toml.TomlCodec RawLayout
rawLayoutCodec =
    RawLayout
        <$> (Toml.dioptional (Toml.int "tile-width") Toml..= rawTileWidth)
        <*> (Toml.dioptional (Toml.int "tile-height") Toml..= rawTileHeight)
        <*> (Toml.dioptional (Toml.int "tile-spacing") Toml..= rawTileSpacing)

-- | TOML codec for global shell defaults.
rawShellCodec :: Toml.TomlCodec RawShell
rawShellCodec =
    RawShell
        <$> (Toml.dioptional (Toml.text "program") Toml..= rawShellProgram)
        <*> (Toml.dioptional (Toml.bool "login") Toml..= rawShellLogin)

-- | TOML codec for launcher entries.
entryCodec :: Toml.TomlCodec EntryConfig
entryCodec =
    EntryConfig
        <$> (Toml.text "name" Toml..= entryName)
        <*> (Toml.text "command" Toml..= entryCommand)
        <*> (Toml.dioptional (Toml.text "color") Toml..= entryColor)
        <*> (Toml.dioptional (Toml.text "working-dir") Toml..= entryWorkingDir)
        <*> (Toml.dioptional (Toml.text "shell-program") Toml..= entryShellProgram)
        <*> (Toml.dioptional (Toml.bool "shell-login") Toml..= entryShellLogin)