packages feed

keiro-dsl-0.5.0.0: src/Keiro/Dsl/WorkspaceRecord.hs

{- | Versioned persistence for one successful __whole-workspace__ scaffold run.

A workspace record answers three questions a context-keyed
"Keiro.Dsl.ScaffoldRecord" cannot: which service produced this output tree,
which member files it was composed from, and __which member produced each
emitted module__. The last one is what makes moving an aggregate from one member
file to another an ownership move rather than a stale/new pair.

__Coexistence.__ Workspace history is keyed by the service name in a distinct
file-name slot, @keiro-dsl-scaffold-record.workspace.\<service\>.txt@, and never
by context. A context name is lexed as letters, digits, @_@ and @-@ and can
never contain a dot, so this slot provably cannot collide with a legacy
context-keyed name even when a service is named after its context. Legacy
records and a workspace record may therefore share one output directory: the
workspace path never writes a context-keyed name, and an older keiro-dsl binary
is structurally incapable of parsing — and therefore of clobbering — workspace
history. The one exception is the explicit adoption step, which /appends/ a
@superseded-by:@ line to a legacy record; the v1 parser ignores unknown lines,
so old binaries still read it.

The format is line-oriented like the v1 record, with a distinct header so no
reader can confuse the schemas:

@
keiro-dsl workspace scaffold record v1
service: demo-project
manifest: service.keiro-workspace
context: demo-project
module-root: Demo.Modules.Project
layout: collocated
member domain/project.keiro
module {"kind":"generated","path":"Demo/Project/Generated/StructuralProjections.hs"}
module {"kind":"generated","path":"Demo/Project/Project/Generated/Domain.hs","owner":"domain/project.keiro"}
mapping {…}
binding {…}
adopted {"path":"…","evidence":"record","source":"keiro-dsl-scaffold-record.demo-project.txt"}
@

@module@ rows are canonical single-line JSON, following the precedent set for
@mapping@ rows. An /absent/ @owner@ means the module is context-level: emitted
once for the whole merged graph (the structural projection facade, the
replay-audit assembly, or a binding skeleton shared by declarations from several
members). Unknown row kinds and unknown JSON keys are ignored so a later tool
version can extend the schema; paths that are absolute or contain @..@ are
rejected rather than joined to an output root.
-}
module Keiro.Dsl.WorkspaceRecord (
    WorkspaceRecord (..),
    WorkspaceModuleRow (..),
    AdoptedRow (..),
    renderWorkspaceRecord,
    parseWorkspaceRecord,
    workspaceRecordFileName,
    workspaceManifestFileName,
    workspaceMigrationReportFileName,
    supersededByLine,
) where

import Data.Aeson (FromJSON (..), ToJSON (..), object, withObject, (.:), (.:?), (.=))
import Data.Aeson qualified as Aeson
import Data.ByteString.Lazy qualified as BL
import Data.List (nub)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as Text
import Keiro.Dsl.ExplainBindings (BindingHole (..))
import Keiro.Dsl.MappedConsumer (MappingIdentity (..))
import Keiro.Dsl.Scaffold (ModuleKind (..))
import System.FilePath (isAbsolute, splitDirectories)

{- | One emitted module: what kind it is, where it landed relative to the output
directory, and which member file produced it ('Nothing' for context-level
modules emitted once from the merged graph).
-}
data WorkspaceModuleRow = WorkspaceModuleRow
    { wrmKind :: !ModuleKind
    , wrmPath :: !FilePath
    , wrmOwner :: !(Maybe FilePath)
    }
    deriving stock (Eq, Show)

instance ToJSON WorkspaceModuleRow where
    toJSON row =
        object $
            [ "kind" .= (case wrmKind row of Generated -> "generated" :: Text; HoleStub -> "hole")
            , "path" .= T.pack (wrmPath row)
            ]
                <> ["owner" .= T.pack owner | Just owner <- [wrmOwner row]]

instance FromJSON WorkspaceModuleRow where
    parseJSON = withObject "WorkspaceModuleRow" $ \fields -> do
        kindLabel <- fields .: "kind"
        moduleKind <- case (kindLabel :: Text) of
            "generated" -> pure Generated
            "hole" -> pure HoleStub
            other -> fail ("unknown module kind: " <> T.unpack other)
        path <- fields .: "path"
        owner <- fields .:? "owner"
        pure
            WorkspaceModuleRow
                { wrmKind = moduleKind
                , wrmPath = T.unpack (path :: Text)
                , wrmOwner = T.unpack <$> (owner :: Maybe Text)
                }

{- | One file imported into workspace history from pre-workspace scaffold
output. @adEvidence@ is @record@ when a legacy per-context scaffold record
listed the file, or @banner@ when the file sits at a planned Generated path and
carries the @-- \@generated@ banner but no surviving record lists it (the orphan
case created when one legacy record overwrote another).
-}
data AdoptedRow = AdoptedRow
    { adPath :: !FilePath
    , adEvidence :: !Text
    , adSource :: !(Maybe Text)
    -- ^ The legacy record's file name, when the evidence is @record@.
    , adSpec :: !(Maybe Text)
    -- ^ The legacy record's @spec:@ field, when available.
    }
    deriving stock (Eq, Show)

instance ToJSON AdoptedRow where
    toJSON row =
        object $
            ["path" .= T.pack (adPath row), "evidence" .= adEvidence row]
                <> ["source" .= source | Just source <- [adSource row]]
                <> ["spec" .= specPath | Just specPath <- [adSpec row]]

instance FromJSON AdoptedRow where
    parseJSON = withObject "AdoptedRow" $ \fields -> do
        path <- fields .: "path"
        evidence <- fields .: "evidence"
        source <- fields .:? "source"
        specPath <- fields .:? "spec"
        pure
            AdoptedRow
                { adPath = T.unpack (path :: Text)
                , adEvidence = evidence
                , adSource = source
                , adSpec = specPath
                }

-- | Everything one successful whole-workspace scaffold produced.
data WorkspaceRecord = WorkspaceRecord
    { wrService :: !Text
    -- ^ The manifest's @service@ name: the workspace's durable identity.
    , wrManifest :: !Text
    {- ^ The manifest's __file name__, not a path. Members are relative to its
    directory, so the directory is wherever the manifest currently sits;
    recording only the name keeps the record independent of the invoking
    working directory, which is what makes byte-identical output provable.
    -}
    , wrContext :: !Text
    , wrModuleRoot :: !Text
    , wrLayout :: !Text
    , wrMembers :: ![FilePath]
    -- ^ Canonically ordered manifest-relative member paths.
    , wrModules :: ![WorkspaceModuleRow]
    , wrMappings :: ![MappingIdentity]
    , wrBindingObligations :: ![BindingHole]
    , wrAdopted :: ![AdoptedRow]
    }
    deriving stock (Eq, Show)

workspaceRecordHeader :: Text
workspaceRecordHeader = "keiro-dsl workspace scaffold record v1"

renderWorkspaceRecord :: WorkspaceRecord -> Text
renderWorkspaceRecord record =
    T.unlines $
        [ workspaceRecordHeader
        , "service: " <> wrService record
        , "manifest: " <> wrManifest record
        , "context: " <> wrContext record
        , "module-root: " <> rootLabel
        , "layout: " <> wrLayout record
        ]
            <> ["member " <> T.pack path | path <- wrMembers record]
            <> ["module " <> encodeRow row | row <- wrModules record]
            <> ["mapping " <> encodeRow mapping | mapping <- wrMappings record]
            <> ["binding " <> encodeRow obligation | obligation <- wrBindingObligations record]
            <> ["adopted " <> encodeRow adopted | adopted <- wrAdopted record]
  where
    rootLabel = if T.null (wrModuleRoot record) then "(none)" else wrModuleRoot record

encodeRow :: (ToJSON a) => a -> Text
encodeRow = Text.decodeUtf8 . BL.toStrict . Aeson.encode

{- | Parse a workspace record. The header and the five @key: value@ fields must
each appear exactly once; unknown lines are ignored for forward compatibility;
unsafe paths are rejected rather than joined to an output root.
-}
parseWorkspaceRecord :: Text -> Maybe WorkspaceRecord
parseWorkspaceRecord contents = case T.lines contents of
    header : rows
        | header == workspaceRecordHeader -> do
            service <- exactlyOne "service: " rows
            manifest <- exactlyOne "manifest: " rows
            context <- exactlyOne "context: " rows
            rootLabel <- exactlyOne "module-root: " rows
            layout <- exactlyOne "layout: " rows
            members <- traverse safePath [path | row <- rows, Just path <- [T.stripPrefix "member " row]]
            modules <- traverse (decodeRow "module ") (rowsWith "module " rows)
            checkedModules <- traverse checkedModule modules
            mappings <- traverse (decodeRow "mapping ") (rowsWith "mapping " rows)
            obligations <- traverse (decodeRow "binding ") (rowsWith "binding " rows)
            adopted <- traverse (decodeRow "adopted ") (rowsWith "adopted " rows)
            checkedAdopted <- traverse checkedAdoption adopted
            if hasDuplicates members
                || hasDuplicates (map wrmPath checkedModules)
                || hasDuplicates (map mappingSpecName mappings)
                || hasDuplicates (map bindingKey obligations)
                then Nothing
                else
                    pure
                        WorkspaceRecord
                            { wrService = service
                            , wrManifest = manifest
                            , wrContext = context
                            , wrModuleRoot = if rootLabel == "(none)" then "" else rootLabel
                            , wrLayout = layout
                            , wrMembers = members
                            , wrModules = checkedModules
                            , wrMappings = mappings
                            , wrBindingObligations = obligations
                            , wrAdopted = checkedAdopted
                            }
    _ -> Nothing
  where
    exactlyOne prefix rows = case [value | row <- rows, Just value <- [T.stripPrefix prefix row]] of
        [value] -> Just value
        _ -> Nothing
    rowsWith prefix rows = [row | row <- rows, prefix `T.isPrefixOf` row]
    decodeRow prefix row = do
        payload <- T.stripPrefix prefix row
        Aeson.decodeStrict' (Text.encodeUtf8 payload)
    checkedModule row = do
        path <- safePath (T.pack (wrmPath row))
        owner <- traverse (safePath . T.pack) (wrmOwner row)
        pure row{wrmPath = path, wrmOwner = owner}
    checkedAdoption row = do
        path <- safePath (T.pack (adPath row))
        pure row{adPath = path}
    safePath raw =
        let path = T.unpack raw
         in if null path || isAbsolute path || ".." `elem` splitDirectories path
                then Nothing
                else Just path
    hasDuplicates :: (Eq a) => [a] -> Bool
    hasDuplicates values = length values /= length (nub values)
    bindingKey hole =
        ( holeMappedName hole
        , holeModule hole
        , holeSymbol hole
        , holeKind hole
        , holePath hole
        )

{- | @keiro-dsl-scaffold-record.workspace.\<service\>.txt@ — the workspace
history file. See the module header for why the @workspace.@ slot cannot
collide with a context-keyed name.
-}
workspaceRecordFileName :: Text -> FilePath
workspaceRecordFileName service = "keiro-dsl-scaffold-record.workspace." <> T.unpack service <> ".txt"

-- | @keiro-dsl-manifest.workspace.\<service\>.txt@ — the Cabal build manifest.
workspaceManifestFileName :: Text -> FilePath
workspaceManifestFileName service = "keiro-dsl-manifest.workspace." <> T.unpack service <> ".txt"

{- | @keiro-dsl-migration-report.workspace.\<service\>.txt@ — the durable review
artifact written once, on the run that adopts pre-workspace scaffold output.
-}
workspaceMigrationReportFileName :: Text -> FilePath
workspaceMigrationReportFileName service = "keiro-dsl-migration-report.workspace." <> T.unpack service <> ".txt"

{- | The single line adoption appends to a superseded legacy record. The v1
parser ignores unknown lines, so the legacy record keeps parsing for old
binaries and stays readable for humans; nothing is renamed or deleted.
-}
supersededByLine :: Text -> Text
supersededByLine service = "superseded-by: " <> T.pack (workspaceRecordFileName service)