synapse-cc-0.3.2: src/SynapseCC/Merge.hs
{-# LANGUAGE OverloadedStrings #-}
-- | Three-way merge for generated files.
-- Preserves user modifications while applying generator updates safely.
module SynapseCC.Merge
( computeFileHash
, applyMerge
, cleanRemovedFiles
, additiveMerge
, MergeResult(..)
) where
import qualified Crypto.Hash.SHA256 as SHA256
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base16 as Base16
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import qualified Data.Set as Set
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.Encoding.Error as TEE
import Control.Exception (try, SomeException)
import Data.Maybe (catMaybes)
import System.Directory ( createDirectoryIfMissing, doesFileExist
, removeFile, listDirectory, removeDirectory )
import System.FilePath ((</>), takeDirectory)
-- | Compute SHA-256 hash of text content, returning first 16 hex characters.
-- Algorithm matches hub-codegen hash.rs: SHA-256(utf8_bytes)[..16] as lowercase hex.
computeFileHash :: Text -> Text
computeFileHash content =
T.take 16 $ TE.decodeUtf8 $ Base16.encode $ SHA256.hash $ TE.encodeUtf8 content
data FileStatus
= NewFile
| SafeToUpdate
| Unchanged
| UserModified
| SmartMerged
deriving (Show, Eq)
-- | Three-way merge decision table:
--
-- cached | current | new | status
-- --------+---------+------+---------------
-- ∅ | ∅ | any | NewFile
-- any | ∅ | any | SafeToUpdate (restore deleted file)
-- ∅ | H | H | Unchanged
-- ∅ | H | H2 | NewFile
-- H1==H1 | same | same | Unchanged
-- H1==H1 | same | H2 | SafeToUpdate
-- H1 | H2≠H1 | any | UserModified (skip)
determineStatus :: Maybe Text -> Maybe Text -> Text -> FileStatus
determineStatus Nothing Nothing _new = NewFile
determineStatus (Just _) Nothing _new = SafeToUpdate
determineStatus Nothing (Just curr) new
| curr == new = Unchanged
| otherwise = NewFile
determineStatus (Just cached) (Just curr) new
| cached == curr = if curr == new then Unchanged else SafeToUpdate
| otherwise = UserModified
-- | Result of applying a merge
data MergeResult = MergeResult
{ mrNew :: [Text] -- ^ New files written
, mrUpdated :: [Text] -- ^ Existing files safely updated
, mrUnchanged :: [Text] -- ^ Files unchanged (write skipped)
, mrSkipped :: [Text] -- ^ Files skipped due to user modifications
, mrDeleted :: [Text] -- ^ Stale generated files removed
, mrMerged :: [Text] -- ^ Files smart-merged (new content added, user mods preserved)
, mrMergedHashes :: Map Text Text -- ^ relPath -> merged hash for smart-merged files
} deriving (Show, Eq)
-- | Apply three-way merge: write generated files to disk, skipping user-modified ones.
applyMerge
:: Map Text Text -- ^ Generated file contents (relPath -> content)
-> Map Text Text -- ^ Generated file hashes (relPath -> hash, unused but kept for API symmetry)
-> Map Text Text -- ^ Cached file hashes (relPath -> hash)
-> FilePath -- ^ Output directory
-> IO MergeResult
applyMerge generatedFiles _generatedHashes cachedHashes outputDir = do
results <- mapM processFile (Map.toList generatedFiles)
pure $ foldr addResult emptyResult results
where
emptyResult = MergeResult [] [] [] [] [] [] Map.empty
processFile (relPath, content) = do
let fullPath = outputDir </> T.unpack relPath
newHash = computeFileHash content
cachedHash = Map.lookup relPath cachedHashes
currentHash <- readCurrentHash fullPath
let status = determineStatus cachedHash currentHash newHash
case status of
NewFile -> writeFile' fullPath content >> pure (relPath, NewFile, Nothing)
SafeToUpdate -> writeFile' fullPath content >> pure (relPath, SafeToUpdate, Nothing)
Unchanged -> pure (relPath, Unchanged, Nothing)
SmartMerged -> pure (relPath, SmartMerged, Nothing) -- not reached from determineStatus
UserModified -> do
currentBytes <- BS.readFile fullPath
let currentContent = TE.decodeUtf8With TEE.lenientDecode currentBytes
case additiveMerge content currentContent of
Just merged -> do
writeFile' fullPath merged
let mergedHash = computeFileHash merged
pure (relPath, SmartMerged, Just mergedHash)
Nothing -> pure (relPath, UserModified, Nothing)
readCurrentHash path = do
exists <- doesFileExist path
if exists
then do
bytes <- BS.readFile path
pure $ Just $ computeFileHash $ TE.decodeUtf8With TEE.lenientDecode bytes
else pure Nothing
writeFile' path content = do
createDirectoryIfMissing True (takeDirectory path)
BS.writeFile path (TE.encodeUtf8 content)
addResult (p, NewFile, _) mr = mr { mrNew = p : mrNew mr }
addResult (p, SafeToUpdate, _) mr = mr { mrUpdated = p : mrUpdated mr }
addResult (p, Unchanged, _) mr = mr { mrUnchanged = p : mrUnchanged mr }
addResult (p, UserModified, _) mr = mr { mrSkipped = p : mrSkipped mr }
addResult (p, SmartMerged, mh) mr = mr
{ mrMerged = p : mrMerged mr
, mrMergedHashes = case mh of
Just h -> Map.insert p h (mrMergedHashes mr)
Nothing -> mrMergedHashes mr
}
-- | Additive merge: insert novel lines from new content into user-modified current content.
-- Returns Nothing if files are too divergent (<50% shared lines) or an anchor can't be found.
additiveMerge :: Text -> Text -> Maybe Text
additiveMerge newContent currentContent
| newLines == currentLines = Just currentContent -- identical, no-op
| sharedRatio < 0.5 = Nothing -- too divergent
| null insertionBlocks = Just currentContent -- no novel lines
| otherwise = applyInsertions currentLines insertionBlocks
where
newLines = T.lines newContent
currentLines = T.lines currentContent
currentSet = Set.fromList currentLines
-- Count how many new lines exist in current
sharedCount = length $ filter (`Set.member` currentSet) newLines
sharedRatio = fromIntegral sharedCount / max 1 (fromIntegral (length newLines)) :: Double
-- Walk newLines, grouping consecutive novel lines with their anchor
-- An anchor is the last shared line before a block of novel lines
insertionBlocks = extractBlocks Nothing [] [] newLines
-- Extract insertion blocks: (Maybe anchor, [novel lines])
extractBlocks :: Maybe Text -> [(Maybe Text, [Text])] -> [Text] -> [Text] -> [(Maybe Text, [Text])]
extractBlocks _anchor acc novelAcc [] =
if null novelAcc then reverse acc
else reverse ((Nothing, reverse novelAcc) : acc) -- trailing novel lines get no anchor
extractBlocks anchor acc novelAcc (l:ls)
| l `Set.member` currentSet =
let acc' = if null novelAcc then acc
else (anchor, reverse novelAcc) : acc
in extractBlocks (Just l) acc' [] ls
| otherwise =
extractBlocks anchor acc (l : novelAcc) ls
-- Apply insertion blocks into currentLines
applyInsertions :: [Text] -> [(Maybe Text, [Text])] -> Maybe Text
applyInsertions curr blocks = go curr blocks 0
where
go :: [Text] -> [(Maybe Text, [Text])] -> Int -> Maybe Text
go remaining [] _ = Just $ T.unlines (remaining)
go remaining ((Nothing, novel):rest) pos =
-- No anchor: prepend to result (novel lines before any shared line)
go (novel ++ remaining) rest pos
go remaining ((Just anchor, novel):rest) pos =
-- Find anchor in remaining lines (forward scan)
case findAnchor anchor remaining of
Nothing -> Nothing -- can't find anchor, bail
Just idx ->
let (before, afterAnchor) = splitAt (idx + 1) remaining
remaining' = before ++ novel ++ afterAnchor
newPos = pos + idx + 1 + length novel
in go remaining' rest newPos
-- Find first occurrence of anchor line in a list
findAnchor :: Text -> [Text] -> Maybe Int
findAnchor _anchor [] = Nothing
findAnchor anchor ls = go' 0 ls
where
go' _ [] = Nothing
go' i (x:xs) = if x == anchor then Just i else go' (i + 1) xs
-- | Delete stale generated files: those present in the previous generation's
-- cached hashes but absent from the new generation.
--
-- A file is only deleted if it hasn't been modified since the last generation
-- (current hash == cached hash). User-modified files are left untouched.
--
-- After file deletion, empty parent directories are pruned up to (but not
-- including) the output root.
cleanRemovedFiles
:: Map Text Text -- ^ Old cached file hashes (previous generation)
-> Map Text Text -- ^ New generated file hashes (current generation)
-> FilePath -- ^ Output directory
-> IO [Text] -- ^ Paths of deleted files (relative)
cleanRemovedFiles oldHashes newHashes outputDir = do
let stale = Map.keys $ Map.difference oldHashes newHashes
deleted <- mapM tryDelete stale
pure (catMaybes deleted)
where
tryDelete relPath = do
let fullPath = outputDir </> T.unpack relPath
cachedHash = Map.findWithDefault "" relPath oldHashes
exists <- doesFileExist fullPath
if not exists
then pure Nothing -- already gone
else do
bytes <- BS.readFile fullPath
let current = computeFileHash $ TE.decodeUtf8With TEE.lenientDecode bytes
if current /= cachedHash
then pure Nothing -- user-modified: leave it
else do
removeFile fullPath
pruneEmptyDirs (takeDirectory fullPath) outputDir
pure (Just relPath)
-- | Remove empty directories walking upward, stopping at the output root.
pruneEmptyDirs :: FilePath -> FilePath -> IO ()
pruneEmptyDirs dir root
| dir == root || length dir <= length root = pure ()
| otherwise = do
contentsResult <- try (listDirectory dir) :: IO (Either SomeException [FilePath])
case contentsResult of
Left _ -> pure ()
Right [] -> do
_ <- try (removeDirectory dir) :: IO (Either SomeException ())
pruneEmptyDirs (takeDirectory dir) root
Right _nonempty -> pure ()