clod-0.1.0: src/Clod/FileSystem/Processing.hs
{-# LANGUAGE OverloadedStrings #-}
-- |
-- Module : Clod.FileSystem.Processing
-- Description : File processing operations for Clod
-- Copyright : (c) Fuzz Leonard, 2025
-- License : MIT
-- Maintainer : cyborg@bionicfuzz.com
-- Stability : experimental
--
-- This module provides functions for processing files, including
-- adding files to the manifest and copying them to the staging directory.
--
-- The implementation uses Kleisli arrows for elegant function composition.
module Clod.FileSystem.Processing
( -- * File processing
processFiles
, ManifestEntry(..)
-- * Manifest operations
, createOptimizedName
, writeManifestFile
) where
import qualified Data.List as L
import Data.List (nubBy)
import System.Directory (doesFileExist)
import System.FilePath (takeDirectory, takeFileName, (</>))
import qualified System.IO
import System.IO (stderr, hPutStrLn)
import Control.Monad (when)
import Control.Monad.Except (throwError)
import qualified System.Directory as D (copyFile)
import Clod.Types (OptimizedName(..), OriginalPath(..), ClodM, ClodConfig(..),
liftIO, FileWriteCap(..), fileWriteCap, isPathAllowed, ClodError(..))
import Clod.IgnorePatterns (matchesIgnorePattern)
import Clod.FileSystem.Detection (isTextFile)
import Clod.FileSystem.Transformations (transformFilename)
-- | A manifest entry consisting of an optimized name and original path
--
-- Each entry in the path manifest maps an optimized file name (as displayed in Claude)
-- to its original file path in the repository.
--
-- @
-- ManifestEntry
-- { entryOptimizedName = OptimizedName "src-config-settings.js"
-- , entryOriginalPath = OriginalPath "src/config/settings.js"
-- }
-- @
data ManifestEntry = ManifestEntry
{ entryOptimizedName :: OptimizedName -- ^ The optimized filename shown in Claude's UI
, entryOriginalPath :: OriginalPath -- ^ The original path in the repository
} deriving (Show, Eq)
-- | Read any existing entries from a manifest file
readManifestEntries :: FilePath -> ClodM [ManifestEntry]
readManifestEntries manifestPath = do
-- Check if manifest exists
exists <- liftIO $ doesFileExist manifestPath
if not exists
then return []
else do
-- Read the content (using strict IO to ensure file handle is closed)
content <- liftIO $ do
fileContent <- readFile manifestPath
length fileContent `seq` return fileContent
-- Parse Dhall record format
-- This parsing handles the record format with keys and string values
let stripQuotes s = case s of
'"':rest -> case reverse rest of
'"':revRest -> reverse revRest
_ -> s
_ -> s
-- Strip backticks from keys if present
stripBackticks s = case s of
'`':rest -> case reverse rest of
'`':revRest -> reverse revRest
_ -> s
_ -> s
-- Parse Dhall key-value pairs
parseEntry line = do
-- Look for the = sign that separates key and value
let parts = break (== '=') line
case parts of
(keyPart, '=':valuePart) -> do
let key = strip (stripBackticks (strip keyPart))
value = strip (stripQuotes (strip valuePart))
-- Filter out any entry where key or value is empty
if null key || null value
then Nothing
else Just $ ManifestEntry
(OptimizedName key)
(OriginalPath value)
_ -> Nothing
-- Get lines that look like Dhall record entries
let possibleEntries = filter (\l -> "=" `L.isInfixOf` l) (lines content)
entries = mapMaybe parseEntry possibleEntries
return entries
where
mapMaybe f = map fromJust . filter isJust . map f
isJust (Just _) = True
isJust Nothing = False
fromJust (Just x) = x
fromJust Nothing = error "Impossible: fromJust Nothing"
-- Remove leading and trailing whitespace
strip :: String -> String
strip = reverse . dropWhile isSpace . reverse . dropWhile isSpace
-- Check if a character is whitespace
isSpace :: Char -> Bool
isSpace c = c == ' ' || c == '\t' || c == '\n' || c == '\r'
-- | Process a list of files for Claude integration
--
-- This is the core function that processes files for Claude integration.
-- It filters files based on ignore patterns, skips binary files, and
-- either copies the files to the staging directory or just adds them to the manifest.
--
-- The function returns a tuple with:
--
-- * The number of files successfully processed
-- * The number of files skipped
--
-- @
-- -- Process all files in a list
-- (processed, skipped) <- processFiles config manifestPath allFiles False
--
-- -- Process files but only include in manifest (no copying)
-- (processed, skipped) <- processFiles config manifestPath allFiles True
-- @
processFiles :: ClodConfig -- ^ Configuration for the Clod program
-> FilePath -- ^ Path to the manifest file
-> [FilePath] -- ^ List of files to process
-> Bool -- ^ Whether to only include in manifest (no file copying)
-> ClodM (Int, Int) -- ^ (Processed count, Skipped count)
processFiles config manifestPath files includeInManifestOnly = do
-- First collect all valid entries
fileResults <- mapM processOneFile files
-- Extract successful entries and count results
let newEntries = concatMap (maybe [] id . fst) fileResults
processed = length newEntries
skipped = sum $ map snd fileResults
-- Read any existing entries from the manifest, if it exists
existingEntries <- readManifestEntries manifestPath
-- Combine existing and new entries, then deduplicate
let allEntries = existingEntries ++ newEntries
-- Use original path as deduplication key
uniqueEntries = nubBy (\a b -> entryOriginalPath a == entryOriginalPath b) allEntries
-- Create a FileWriteCap for the staging directory
let writeCap = fileWriteCap [takeDirectory manifestPath]
-- Write all entries to the manifest file at once
let manifestPairs = map (\e -> (entryOptimizedName e, entryOriginalPath e)) uniqueEntries
writeManifestFile writeCap manifestPath manifestPairs
-- Return file counts
return (processed, skipped)
where
-- Process a single file and return Maybe (entries, skipped count)
processOneFile :: FilePath -> ClodM (Maybe [ManifestEntry], Int)
processOneFile file = do
-- Get full path
let fullPath = projectPath config </> file
-- Skip if not a regular file
isFile <- liftIO $ doesFileExist fullPath
if not isFile
then return (Nothing, 0)
else do
-- Skip any files in the staging directory
if stagingDir config `L.isInfixOf` fullPath
then do
when (verbose config) $ do
liftIO $ hPutStrLn stderr $ "Skipping: " ++ fullPath ++ " (in staging directory)"
return (Nothing, 0)
else do
-- Process the file based on manifest-only flag
if includeInManifestOnly
then processForManifestOnly config fullPath file
else processWithCopy config fullPath file
-- Process for manifest only (no copying)
processForManifestOnly :: ClodConfig -> FilePath -> FilePath -> ClodM (Maybe [ManifestEntry], Int)
processForManifestOnly cfg fullPath relPath = do
-- Skip if matches ignore patterns
let patterns = ignorePatterns cfg
if not (null patterns) && matchesIgnorePattern patterns relPath
then return (Nothing, 1)
else do
-- Skip binary files
isText <- isTextFile fullPath
if not isText
then return (Nothing, 1)
else do
-- Create the manifest entry
let optimizedName = createOptimizedName relPath
originalPath = OriginalPath relPath
entry = ManifestEntry optimizedName originalPath
return (Just [entry], 0)
-- Process with file copying
processWithCopy :: ClodConfig -> FilePath -> FilePath -> ClodM (Maybe [ManifestEntry], Int)
processWithCopy cfg fullPath relPath
-- Skip specifically excluded files and directories
| relPath `elem` [".gitignore", "package-lock.json", "yarn.lock", ".clodignore"] =
return (Nothing, 1)
-- Skip any paths containing node_modules or .git directories
| "node_modules" `L.isInfixOf` relPath || ".git/" `L.isPrefixOf` relPath || ".git" == relPath =
return (Nothing, 1)
| otherwise = do
-- Skip if matches ignore patterns
let patterns = ignorePatterns cfg
if not (null patterns) && matchesIgnorePattern patterns relPath
then return (Nothing, 1)
else do
-- Skip binary files
isText <- isTextFile fullPath
if not isText
then return (Nothing, 1)
else do
-- Create the manifest entry
let optimizedName = createOptimizedName relPath
originalPath = OriginalPath relPath
entry = ManifestEntry optimizedName originalPath
-- Helper function to extract the name from OptimizedName
getOptimizedName (OptimizedName name) = name
-- Copy file with optimized name
liftIO $ D.copyFile fullPath (currentStaging cfg </> getOptimizedName optimizedName)
-- Report the copy operation only when verbose flag is set (to stderr)
when (verbose cfg) $ do
liftIO $ hPutStrLn stderr $ "Copied: " ++ relPath ++ " → " ++ getOptimizedName optimizedName
return (Just [entry], 0)
-- | Write all entries to the manifest file at once
--
-- This creates the _path_manifest.dhall file that maps optimized filenames back to original paths.
-- The manifest is a Dhall record with optimized names as keys and original paths as values.
--
-- >>> writeManifestFile writeCap "_path_manifest.dhall" [(OptimizedName "app-config.js", OriginalPath "app/config.js")]
-- -- Creates a file with content: { `app-config.js` = "app/config.js" }
--
-- The manifest file is crucial for Claude to know where to write files back to the filesystem.
writeManifestFile :: FileWriteCap -- ^ Capability token with permissions to write to the staging directory
-> FilePath -- ^ Path to the manifest file (typically "_path_manifest.dhall")
-> [(OptimizedName, OriginalPath)] -- ^ List of optimized name to original path mappings
-> ClodM () -- ^ Action that creates the manifest file
writeManifestFile writeCap manifestPath entries = do
-- Create the manifest content with all entries
let manifestLines = "{\n" : entryLines ++ ["\n}"]
entryLines = zipWith formatEntry [0..] entries
-- Format a single entry (with comma for all but the last)
formatEntry idx (optimizedName, originalPath) =
let comma = if idx == length entries - 1 then "" else ","
-- Properly escape keys for Dhall format (backticks for non-standard identifiers)
dhallOptimizedName = if needsBackticks (unOptimizedName optimizedName)
then "`" ++ unOptimizedName optimizedName ++ "`"
else unOptimizedName optimizedName
-- Properly escape string values for Dhall
dhallOriginalPath = "\"" ++ escapeString (unOriginalPath originalPath) ++ "\""
in " " ++ dhallOptimizedName ++ " = " ++ dhallOriginalPath ++ comma
-- Check if path is allowed by capability
allowed <- liftIO $ isPathAllowed (allowedWriteDirs writeCap) manifestPath
if not allowed
then throwError $ CapabilityError $ "Access denied: Cannot write manifest file outside allowed directories: " ++ manifestPath
else do
-- Write the complete manifest file at once, ensuring handles are closed promptly
let content = unlines manifestLines
liftIO $ System.IO.withFile manifestPath System.IO.WriteMode $ \h -> do
System.IO.hPutStr h content
System.IO.hFlush h
-- | Check if a key needs to be wrapped in backticks for Dhall
needsBackticks :: String -> Bool
needsBackticks s = case s of
[] -> True
(c:cs) -> not (isAlpha c) || any notAllowedInIdentifier cs
where
isAlpha c = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
notAllowedInIdentifier c = not (isAlpha c || isDigit c || c == '_' || c == '-')
isDigit c = c >= '0' && c <= '9'
-- | Escape string literals for Dhall
escapeString :: String -> String
escapeString = concatMap escapeChar
where
escapeChar '"' = "\\\""
escapeChar '\\' = "\\\\"
escapeChar c = [c]
-- | Create an optimized filename for Claude UI
createOptimizedName :: FilePath -> OptimizedName
createOptimizedName relPath = OptimizedName finalOptimizedName
where
dirPart = takeDirectory relPath
fileName = takeFileName relPath
-- Handle paths with no directory part (files in root)
finalOptimizedName = case dirPart of
"." -> transformFilename fileName fileName
_ ->
-- Process the directory part to handle hidden directories
let dirParts = splitPath dirPart
-- Transform each directory segment, handling hidden directories
transformedDirParts = map transformDirPart dirParts
-- Join them with dashes
transformedDirPath = concat $ L.intersperse "-" transformedDirParts
-- Transform the filename
transformedFileName = transformFilename fileName fileName
in transformedDirPath ++ "-" ++ transformedFileName
-- Transform a directory segment, handling hidden directories
transformDirPart :: String -> String
transformDirPart dir =
-- Remove trailing slash if present
let cleanDir = if L.isSuffixOf "/" dir then init dir else dir
-- Apply hidden file transformation if needed
in if not (null cleanDir) && head cleanDir == '.'
then "dot--" ++ tail cleanDir
else cleanDir
-- Split a path into its directory components
splitPath :: FilePath -> [String]
splitPath path = filter (not . null) $ map getSegment $ L.groupBy sameGroup path
where
sameGroup c1 c2 = c1 /= '/' && c2 /= '/'
getSegment seg = filter (/= '/') seg