seihou-core-0.4.0.0: src/Seihou/Composition/Plan.hs
module Seihou.Composition.Plan
( compileComposedPlan,
mergeOperations,
mergeStructuredContent,
deepMergeJSON,
)
where
import Data.Aeson qualified as Aeson
import Data.Aeson.Encode.Pretty qualified as AesonPretty
import Data.Aeson.KeyMap qualified as KM
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Text.Lazy qualified as TL
import Data.Text.Lazy.Encoding qualified as TLE
import Data.Yaml qualified as Yaml
import Seihou.Composition.Instance (ModuleInstance, qualifiedName)
import Seihou.Core.Types
import Seihou.Engine.Plan (compilePlan)
import Seihou.Engine.Section (applyTextPatch)
import Seihou.Prelude
import System.FilePath (takeExtension)
-- | Compile plans for all modules in execution order and merge into a
-- single operation list. File conflicts are resolved by last-writer-wins
-- with 'CompositionWarning' entries for overwritten files.
--
-- Each triple is a distinct 'ModuleInstance' invocation. The instance's
-- 'qualifiedName' is passed to 'compilePlan' for any field that must be
-- unique within the composition ('FileRecord.moduleName',
-- 'PatchFileOp.moduleName'), so two instances of the same module do not
-- collide on file ownership.
compileComposedPlan ::
[(ModuleInstance, Module, FilePath, Map VarName VarValue)] ->
IO (Either [Text] ([Operation], [CompositionWarning], Map FilePath ModuleName))
compileComposedPlan modules = do
results <- mapM compileOne modules
let (errs, opsPerModule) = partitionResults results
if null errs
then pure (Right (mergeOperations opsPerModule))
else pure (Left (concat errs))
where
compileOne (inst, m, dir, vars) = do
let qn = qualifiedName inst
instancedModule =
Module
{ name = qn,
version = m.version,
description = m.description,
vars = m.vars,
exports = m.exports,
prompts = m.prompts,
steps = m.steps,
commands = m.commands,
dependencies = m.dependencies,
removal = m.removal,
migrations = m.migrations
}
result <- compilePlan dir instancedModule vars
case result of
Left errs -> pure (Left errs)
Right ops -> pure (Right (qn, ops))
-- | Merge operation lists from multiple modules, handling file conflicts.
--
-- When two modules produce a 'WriteFileOp' or 'CopyFileOp' targeting the
-- same destination path, the later module (later in execution order) wins
-- and a 'FileOverwritten' warning is recorded.
--
-- 'CreateDirOp' operations are silently deduplicated.
-- 'RunCommandOp' operations are always included.
mergeOperations ::
[(ModuleName, [Operation])] ->
([Operation], [CompositionWarning], Map FilePath ModuleName)
mergeOperations moduleOps =
let tagged = [(name, op) | (name, ops) <- moduleOps, op <- ops]
(result, warnings, owners) = go tagged Map.empty Set.empty [] []
in (reverse result, warnings, owners)
where
go [] fileOwner _ opsAcc warningsAcc = (opsAcc, warningsAcc, fileOwner)
go ((name, op) : rest) fileOwner seenDirs opsAcc warningsAcc =
case op of
CreateDirOp p
| Set.member p seenDirs -> go rest fileOwner seenDirs opsAcc warningsAcc
| otherwise -> go rest fileOwner (Set.insert p seenDirs) (op : opsAcc) warningsAcc
WriteFileOp dest _ _ ->
handleFileOp name op dest rest fileOwner seenDirs opsAcc warningsAcc
CopyFileOp _ dest ->
handleFileOp name op dest rest fileOwner seenDirs opsAcc warningsAcc
RunCommandOp {} ->
go rest fileOwner seenDirs (op : opsAcc) warningsAcc
PatchFileOp dest newContent patchOp' _ patchModName ->
handlePatchOp name dest newContent patchOp' patchModName rest fileOwner seenDirs opsAcc warningsAcc
-- \| Handle a PatchFileOp: if the target file exists in the accumulator,
-- apply the patch to the existing content. Otherwise, treat it like a
-- new file operation (the patch creates the file from empty).
handlePatchOp name dest newContent patchOp' patchModName rest fileOwner seenDirs opsAcc warningsAcc =
case findWriteOp dest opsAcc of
Just (WriteFileOp _ existingContent strat, otherOps) ->
-- Apply patch to the existing WriteFileOp's content
case applyTextPatch patchOp' patchModName "#" existingContent newContent of
Left _err ->
-- On patch failure, fall back to last-writer-wins
handleFileOp name (WriteFileOp dest newContent Template) dest rest fileOwner seenDirs opsAcc warningsAcc
Right merged ->
let baseName = case Map.lookup dest fileOwner of
Just n -> n
Nothing -> name
updatedOp = WriteFileOp dest merged strat
in go
rest
(Map.insert dest name fileOwner)
seenDirs
(updatedOp : otherOps)
(ContentMerged dest baseName name : warningsAcc)
_ ->
-- No existing file op for this dest; preserve as PatchFileOp so the
-- execution engine can merge with the on-disk file and the diff engine
-- can avoid false conflict classification.
let patchOp = PatchFileOp dest newContent patchOp' Template patchModName
in go
rest
(Map.insert dest name fileOwner)
seenDirs
(patchOp : opsAcc)
warningsAcc
handleFileOp name op dest rest fileOwner seenDirs opsAcc warningsAcc =
case Map.lookup dest fileOwner of
Just prevName ->
-- Check for structured merge: both ops are WriteFileOp with Structured strategy
case (findWriteOp dest opsAcc, op) of
(Just (WriteFileOp _ existingContent Structured, otherOps), WriteFileOp _ newContent Structured) ->
case mergeStructuredContent dest existingContent newContent of
Right merged ->
let updatedOp = WriteFileOp dest merged Structured
in go
rest
(Map.insert dest name fileOwner)
seenDirs
(updatedOp : otherOps)
(ContentMerged dest prevName name : warningsAcc)
Left _ ->
-- On merge failure, fall back to last-writer-wins
let opsAcc' = op : filter (\old -> destOfOp old /= Just dest) opsAcc
in go
rest
(Map.insert dest name fileOwner)
seenDirs
opsAcc'
(FileOverwritten dest prevName name : warningsAcc)
_ ->
-- Default: last-writer-wins
let opsAcc' = op : filter (\old -> destOfOp old /= Just dest) opsAcc
in go
rest
(Map.insert dest name fileOwner)
seenDirs
opsAcc'
(FileOverwritten dest prevName name : warningsAcc)
Nothing ->
go
rest
(Map.insert dest name fileOwner)
seenDirs
(op : opsAcc)
warningsAcc
-- | Find and extract a WriteFileOp targeting a given destination from the ops accumulator.
-- Returns the matching op and the remaining ops (with it removed).
findWriteOp :: FilePath -> [Operation] -> Maybe (Operation, [Operation])
findWriteOp _ [] = Nothing
findWriteOp dest (op@(WriteFileOp d _ _) : rest)
| d == dest = Just (op, rest)
findWriteOp dest (op : rest) =
case findWriteOp dest rest of
Just (found, remaining) -> Just (found, op : remaining)
Nothing -> Nothing
-- | Extract the destination path from a file-producing operation.
destOfOp :: Operation -> Maybe FilePath
destOfOp (WriteFileOp d _ _) = Just d
destOfOp (CopyFileOp _ d) = Just d
destOfOp (PatchFileOp d _ _ _ _) = Just d
destOfOp _ = Nothing
-- | Merge two serialized structured files (JSON or YAML) by deep-merging
-- their contents. The file extension determines the parse/serialize format.
-- Right-biased: overlapping scalar keys take the overlay's value; nested
-- objects are merged recursively.
mergeStructuredContent :: FilePath -> Text -> Text -> Either Text Text
mergeStructuredContent dest base overlay = do
baseVal <- parseStructured dest base
overlayVal <- parseStructured dest overlay
let merged = deepMergeJSON baseVal overlayVal
serializeStructured dest merged
-- | Parse serialized content based on file extension.
parseStructured :: FilePath -> Text -> Either Text Aeson.Value
parseStructured dest content =
case takeExtension dest of
".json" ->
case Aeson.eitherDecodeStrict' (TE.encodeUtf8 content) of
Left err -> Left ("Failed to parse JSON in " <> T.pack dest <> ": " <> T.pack err)
Right val -> Right val
".yaml" -> parseYaml dest content
".yml" -> parseYaml dest content
ext -> Left ("Structured merge: unsupported format '" <> T.pack ext <> "'")
where
parseYaml d c =
case Yaml.decodeEither' (TE.encodeUtf8 c) of
Left err -> Left ("Failed to parse YAML in " <> T.pack d <> ": " <> T.pack (show err))
Right val -> Right val
-- | Serialize a JSON value based on file extension.
serializeStructured :: FilePath -> Aeson.Value -> Either Text Text
serializeStructured dest value =
case takeExtension dest of
".json" -> Right (TL.toStrict (TLE.decodeUtf8 (AesonPretty.encodePretty value)) <> "\n")
".yaml" -> Right (TE.decodeUtf8 (Yaml.encode value))
".yml" -> Right (TE.decodeUtf8 (Yaml.encode value))
ext -> Left ("Structured merge: unsupported format '" <> T.pack ext <> "'")
-- | Deep-merge two JSON values. For objects, keys are merged recursively.
-- For all other types, the right (overlay) value wins.
deepMergeJSON :: Aeson.Value -> Aeson.Value -> Aeson.Value
deepMergeJSON (Aeson.Object base) (Aeson.Object overlay) =
Aeson.Object (KM.unionWith deepMergeJSON base overlay)
deepMergeJSON _ overlay = overlay
-- | Partition a list of Either into errors and successes.
partitionResults :: [Either e a] -> ([e], [a])
partitionResults = foldr step ([], [])
where
step (Left e) (errs, oks) = (e : errs, oks)
step (Right a) (errs, oks) = (errs, a : oks)