clod-0.1.13: src/Clod/FileSystem/Detection.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell #-}
-- |
-- Module : Clod.FileSystem.Detection
-- Description : File detection operations for Clod
-- Copyright : (c) Fuzz Leonard, 2025
-- License : MIT
-- Maintainer : ink@fuzz.ink
-- Stability : experimental
--
-- This module provides functions for detecting file types and states
-- using libmagic for robust file type detection. It determines whether
-- files are text or binary based on their content rather than just extensions.
module Clod.FileSystem.Detection
( -- * File type detection
isTextFile
, isModifiedSince
, safeFileExists
, safeIsTextFile
, isTextDescription
, needsTransformation
) where
import Control.Exception (try, SomeException)
import Control.Monad.Except (throwError)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Reader (asks)
import Data.List (isPrefixOf)
import Data.Time.Clock (UTCTime)
import System.Directory (doesFileExist, getModificationTime, canonicalizePath)
import System.FilePath ((</>), takeFileName, takeExtension)
import qualified Data.ByteString.Char8 as BS
import qualified Data.Text as T
import qualified Dhall
import Data.FileEmbed (embedStringFile)
import Clod.Types (ClodM, FileReadCap(..), ClodError(..), isPathAllowed, ClodConfig(..))
import qualified Magic.Init as Magic
import qualified Magic.Operations as Magic
-- | Type to represent text patterns from Dhall
newtype TextPatterns = TextPatterns
{ textPatterns :: [T.Text]
} deriving (Show, Eq)
instance Dhall.FromDhall TextPatterns where
autoWith _ = Dhall.record $
TextPatterns <$> Dhall.field "textPatterns" Dhall.auto
-- | Default text patterns content embedded at compile time
defaultTextPatternsContent :: String
defaultTextPatternsContent = BS.unpack $(embedStringFile "resources/text_patterns.dhall")
-- | Parse the embedded text patterns content
parseDefaultTextPatterns :: ClodM TextPatterns
parseDefaultTextPatterns = do
result <- liftIO $ try $ Dhall.input Dhall.auto (T.pack defaultTextPatternsContent)
case result of
Right patterns -> return patterns
Left (e :: SomeException) ->
throwError $ ConfigError $ "Failed to parse default text patterns: " ++ show e
-- | Load text patterns for determining text files
-- This first checks for a custom pattern file in the clod directory,
-- and falls back to the embedded default patterns if not found.
loadTextPatterns :: ClodM TextPatterns
loadTextPatterns = do
clodDir <- asks configDir
-- First check if there's a custom pattern file in the clod directory
let customPath = clodDir </> "resources" </> "text_patterns.dhall"
customExists <- liftIO $ doesFileExist customPath
if customExists
then do
result <- liftIO $ try $ Dhall.inputFile Dhall.auto customPath
case result of
Right patterns -> return patterns
Left (e :: SomeException) ->
throwError $ ConfigError $ "Failed to load custom text patterns: " ++ show e
else parseDefaultTextPatterns
-- | Check if a file is a text file using libmagic with enhanced detection
isTextFile :: FilePath -> ClodM Bool
isTextFile file = do
exists <- liftIO $ doesFileExist file
if not exists
then return False
else do
-- Detect the file type using libmagic
result <- liftIO $ try $ do
magic <- Magic.magicOpen []
Magic.magicLoadDefault magic
Magic.magicFile magic file
case result of
Left (_ :: SomeException) -> return False
Right description -> isTextDescription description
-- | Helper to determine if a file description indicates text content
isTextDescription :: String -> ClodM Bool
isTextDescription desc = do
patterns <- loadTextPatterns
let lowerDesc = T.toLower $ T.pack desc
return $ any (\pattern -> pattern `T.isInfixOf` lowerDesc) (textPatterns patterns)
-- | Special handling for files that need transformation
--
-- Detects files that need special handling and transformation based on their
-- name or extension. Currently identifies:
--
-- * Hidden files (dotfiles) - need to be transformed to be visible
-- * SVG files - need to be transformed to XML for Claude compatibility
--
-- >>> needsTransformation ".gitignore"
-- True
--
-- >>> needsTransformation "logo.svg"
-- True
--
-- >>> needsTransformation "regular-file.txt"
-- False
needsTransformation :: FilePath -> Bool
needsTransformation path =
-- Handle dotfiles and SVG files with special transformation
("." `isPrefixOf` takeFileName path) ||
(takeExtension path == ".svg")
-- | Check if a file has been modified since the given time
isModifiedSince :: FilePath -> UTCTime -> FilePath -> ClodM Bool
isModifiedSince basePath lastRunTime relPath = do
let fullPath = basePath </> relPath
fileExists <- liftIO $ doesFileExist fullPath
if not fileExists
then return False
else do
modTime <- liftIO $ getModificationTime fullPath
return (modTime > lastRunTime)
-- | Safe file existence check that checks capabilities
safeFileExists :: FileReadCap -> FilePath -> ClodM Bool
safeFileExists cap path = do
allowed <- liftIO $ isPathAllowed (allowedReadDirs cap) path
if allowed
then liftIO $ doesFileExist path
else do
canonicalPath <- liftIO $ canonicalizePath path
throwError $ CapabilityError $ "Access denied: Cannot check existence of file outside allowed directories: " ++ canonicalPath
-- | Safe file type check that checks capabilities
safeIsTextFile :: FileReadCap -> FilePath -> ClodM Bool
safeIsTextFile cap path = do
allowed <- liftIO $ isPathAllowed (allowedReadDirs cap) path
if allowed
then isTextFile path
else do
canonicalPath <- liftIO $ canonicalizePath path
throwError $ CapabilityError $ "Access denied: Cannot check file type outside allowed directories: " ++ canonicalPath