packages feed

gachi-sync-0.1.0.0: src/Gachi/Sync/Engine.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | Module: Gachi.Sync.Engine
-- Description: Harmonic chattering synchronization and differential propagation engine
--
-- This module implements the core propagation engine of @gachi sync@. It evaluates
-- differential changes across ecosystem checkouts against the canonical Standardization
-- Matrix (~/src/PLAN.md §1.A), computes atomic synchronization plans, applies non-destructive
-- updates to missing or drifted files, and guarantees mathematical idempotency.
--
-- = Architectural Context & Invariants
--
-- In accordance with Note [Ecosystem Standardization Invariants]
-- [ref:note-ecosystem-standardization], Note [Cross-Repository Standardization Matrix and Audit Semantics]
-- [ref:note-standardization-matrix], and Note [Harmonic Chattering Synchronization and Differential Propagation]
-- [ref:note-harmonic-sync], this module is the operational engine that transforms
-- observational audit findings into safe, automated filesystem repairs.
--
-- = Note [Harmonic Chattering Synchronization and Differential Propagation]
-- [tag:note-harmonic-sync]
--
-- In the mythos of Gachi, the *gashadokuro* phantom is assembled from the scattered
-- bones of those fallen across many battles. When the phantom awakens, its collected
-- teeth chatter and click (*gachi-gachi*) in eerie unison—a resonant harmonic rhythm
-- that coordinates the entire skeletal body without centralized nervous signaling.
--
-- In modern software engineering, multi-repository architectures suffer from structural
-- desynchronization: improvements made to one repository's formatting profile (Fourmolu),
-- compiler warning flags, Git commit hooks, or AI agent guidance fail to propagate to
-- sibling repositories. Over time, repositories drift into incompatible silos.
--
-- The Harmonic Chattering Synchronization engine resolves this through three core principles:
--
-- 1.  __Observational Planning Before Mutation:__
--     Before modifying a single byte on disk, the engine computes a 'SyncPlan'
--     classifying every standardized asset into an explicit 'SyncAction':
--     * 'ActionCreate': The file is absent; it must be materialized with correct permissions.
--     * 'ActionUpdate': The file exists, but its content diverges from canonical generators.
--     * 'ActionChmod': The content matches, but its POSIX executable permissions differ.
--     * 'ActionNoOp': The file is already compliant; zero disk I/O is performed.
--
-- 2.  __Strict Mathematical Idempotency:__
--     The synchronization function is mathematically idempotent:
--     \[
--       \text{sync}(\text{sync}(W)) = \text{sync}(W)
--     \]
--     Executing @gachi sync@ once repairs all discrepancies. Executing @gachi sync@
--     immediately afterward produces an empty change set (0 creates, 0 updates, 0 chmods).
--
-- 3.  __Non-Destructive Safety & Dry-Run Preview:__
--     The engine supports 'DryRun' mode, enabling developers and CI harnesses to inspect
--     all planned filesystem modifications and unified diffs before applying changes.
--
-- 4.  __Multi-Repository Ecosystem Orchestration:__
--     Through 'discoverEcosystemRepositories' and 'syncEcosystem', a single command
--     can harmonize all checkouts across @~/src/*@ in lockstep.
--
-- = Worked Example
--
-- >>> import Gachi.Schema.Project (defaultProject, Author(..))
-- >>> import Gachi.Sync.Matrix (canonicalMatrix)
-- >>> let proj = defaultProject "gachi" (Author "Nadia" "nadia@example.com")
-- >>> plan <- computeSyncPlan "/home/nyc/src/gachi" proj canonicalMatrix
-- >>> planProjectName plan
-- "gachi"
module Gachi.Sync.Engine
  ( -- * Synchronization Execution Modes
    SyncMode(..)

    -- * Synchronization Action Models
  , SyncAction(..)
  , isNoOpAction
  , isMutatingAction

    -- * Synchronization Planning
  , SyncPlan(..)
  , isPlanClean
  , computeSyncPlan

    -- * Synchronization Execution
  , SyncResult(..)
  , executeSyncPlan
  , syncRepository
  , syncRepositoryQuick

    -- * Multi-Repository Ecosystem Orchestration
  , discoverEcosystemRepositories
  , syncEcosystem

    -- * Diagnostic Plan & Result Formatting
  , formatSyncPlan
  , formatSyncResult
  ) where

import Control.Exception (SomeException, catch, displayException)
import Control.Monad (forM, when)
import Data.Maybe (catMaybes)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import System.Directory
  ( canonicalizePath
  , createDirectoryIfMissing
  , doesDirectoryExist
  , doesFileExist
  , executable
  , getPermissions
  , listDirectory
  , setOwnerExecutable
  , setPermissions
  )
import System.FilePath (takeDirectory, takeFileName, (</>))

import Gachi.Schema.Dhall (decodeProjectFile)
import Gachi.Schema.Project (Author(..), Project, defaultProject, projectName)
import Gachi.Sync.Hash (sha256Text)
import Gachi.Sync.Matrix (canonicalMatrix)
import Gachi.Sync.Types
  ( StandardAsset(..)
  , StandardizationMatrix(..)
  , SyncMode(..)
  , resolveAssetPath
  )

--------------------------------------------------------------------------------
-- 1. Synchronization Modes & Actions
--------------------------------------------------------------------------------

-- | Explicit atomic action planned for an individual standardized asset.
--
-- Represents the fine-grained decision made for a single asset during the evaluation
-- phase of 'computeSyncPlan'. Every standardized asset maps to exactly one action.
data SyncAction
  = ActionCreate !FilePath !Text !Bool
    -- ^ The asset is completely absent on disk.
    -- Parameters:
    -- 1. Relative destination file path (e.g. @"fourmolu.yaml"@ or @"bin/gachi-verify"@).
    -- 2. Complete canonical text content produced by the asset generator.
    -- 3. Boolean flag indicating whether POSIX owner executable bit (+x) must be set.
  | ActionUpdate !FilePath !Text !Text !Bool
    -- ^ The asset exists on disk, but its SHA-256 checksum diverges from the generator.
    -- Parameters:
    -- 1. Relative destination file path.
    -- 2. Pre-existing file content currently residing on disk.
    -- 3. Canonical replacement content produced by the asset generator.
    -- 4. Target POSIX owner executable bit requirement.
  | ActionChmod !FilePath !Bool !Bool
    -- ^ The asset content is bit-identical, but its POSIX executable bit differs.
    -- Parameters:
    -- 1. Relative destination file path.
    -- 2. Current executable permission detected on disk.
    -- 3. Target executable permission required by the asset specification.
  | ActionNoOp !FilePath
    -- ^ The asset is fully compliant: exists, matches SHA-256 hash, and has correct permissions.
    -- Parameters:
    -- 1. Relative destination file path.
  | ActionError !FilePath !Text
    -- ^ An unexpected filesystem I/O exception occurred during inspection.
    -- Parameters:
    -- 1. Relative destination file path where inspection failed.
    -- 2. Human-readable diagnostic description of the caught exception.
  deriving stock (Eq, Show)

-- | Returns 'True' if the action represents an already-compliant asset requiring no changes.
--
-- No-op actions imply that the file on disk is bit-identical and has conforming permissions.
isNoOpAction :: SyncAction -> Bool
isNoOpAction (ActionNoOp _) = True
isNoOpAction _              = False

-- | Returns 'True' if the action would modify the filesystem (create, update, or chmod).
--
-- Mutating actions are executed during 'ApplySync' and simulated during 'DryRun'.
isMutatingAction :: SyncAction -> Bool
isMutatingAction ActionCreate {} = True
isMutatingAction ActionUpdate {} = True
isMutatingAction ActionChmod {}  = True
isMutatingAction _               = False

--------------------------------------------------------------------------------
-- 2. Synchronization Planning
--------------------------------------------------------------------------------

-- | Comprehensive pre-mutation plan detailing all actions required across a repository.
--
-- The 'SyncPlan' represents the pure evaluation outcome of comparing a repository's
-- on-disk state against the canonical 'StandardizationMatrix'. It serves as an immutable,
-- inspectable specification of all proposed mutations prior to disk execution.
--
-- === Structural Invariants
--
-- 1. __Completeness:__ Exactly one 'SyncAction' exists for every asset declared in
--    the input 'StandardizationMatrix'. No assets are skipped or duplicated.
-- 2. __Partitioning:__ The action counts ('planCreatedCount', 'planUpdatedCount',
--    'planChmodCount', 'planNoOpCount') sum to the total length of 'planActions'.
-- 3. __Cleanliness Predicate:__ 'isPlanClean' holds if and only if no mutating
--    actions are scheduled (i.e. created, updated, and chmod counts are all 0).
data SyncPlan = SyncPlan
  { planTargetDir     :: !FilePath
    -- ^ Target repository workspace path.
  , planProjectName   :: !Text
    -- ^ Name of the target project being synchronized.
  , planActions       :: ![SyncAction]
    -- ^ Complete list of planned per-asset actions.
  , planCreatedCount  :: !Int
    -- ^ Number of missing files planned for creation.
  , planUpdatedCount  :: !Int
    -- ^ Number of drifted files planned for content update.
  , planChmodCount    :: !Int
    -- ^ Number of files requiring permission correction.
  , planNoOpCount     :: !Int
    -- ^ Number of files already compliant.
  } deriving stock (Eq, Show)

-- | Checks whether a plan requires zero mutations (all assets compliant).
--
-- Returns 'True' when a repository is in perfect resonance with the standardization
-- matrix, meaning no files need to be created, updated, or chmodded.
isPlanClean :: SyncPlan -> Bool
isPlanClean plan =
  planCreatedCount plan == 0
    && planUpdatedCount plan == 0
    && planChmodCount plan == 0

-- | Computes a 'SyncPlan' for a target repository without modifying any files.
--
-- Evaluates each asset registered in the 'StandardizationMatrix', compares current
-- disk state with expected generator output, and categorizes required actions.
--
-- === Four-Phase Differential Inspection Pipeline
--
-- For each asset in the matrix, the planning algorithm executes the following
-- deterministic decision procedure:
--
-- 1. __Path Resolution:__
--    Resolves parameterized path tokens (@:name:@, @:project:@) against the target
--    'Project' specification via 'resolveAssetPath'.
--
-- 2. __Existence Inspection:__
--    Checks whether the target file exists on disk via 'doesFileExist'.
--    If absent, plans 'ActionCreate' containing the generator's expected output
--    and executable flag requirement.
--
-- 3. __Cryptographic Differential Scanning:__
--    If present, reads the file strictly and computes SHA-256 digests of both the
--    disk content and the generator output.
--    * If the hashes diverge, plans 'ActionUpdate' preserving the existing content
--      for diff generation and supplying the canonical replacement content.
--
-- 4. __POSIX Mode Verification:__
--    If the hashes match bit-for-bit, inspects the file's owner executable bit via
--    'getPermissions'.
--    * If the executable permission disagrees with 'assetExecutable', plans 'ActionChmod'.
--    * If permissions match, plans 'ActionNoOp', indicating clean compliance.
--
-- === Worked Example
--
-- >>> import Gachi.Schema.Project (defaultProject, Author(..))
-- >>> import Gachi.Sync.Matrix (canonicalMatrix)
-- >>> let proj = defaultProject "gachi" (Author "Nadia" "nadia@example.com")
-- >>> plan <- computeSyncPlan "/home/nyc/src/gachi" proj canonicalMatrix
-- >>> planNoOpCount plan > 0
-- True
computeSyncPlan :: FilePath -> Project -> StandardizationMatrix -> IO SyncPlan
computeSyncPlan repoRoot proj matrix = do
  actions <- forM (matrixAssets matrix) $ \asset -> do
    let relPath = resolveAssetPath proj asset
        absPath = repoRoot </> relPath
    exists <- doesFileExist absPath
    if not exists
      then
        let expectedContent = assetGenerator asset proj
            execBit = assetExecutable asset
        in pure (ActionCreate relPath expectedContent execBit)
      else (do
        actualContent <- TIO.readFile absPath
        let expectedContent = assetGenerator asset proj
            execBit = assetExecutable asset
        if actualContent /= expectedContent
          then pure (ActionUpdate relPath actualContent expectedContent execBit)
          else do
            perms <- getPermissions absPath
            let actualExec = executable perms
            if actualExec /= execBit
              then pure (ActionChmod relPath actualExec execBit)
              else pure (ActionNoOp relPath)
        ) `catch` (\(e :: SomeException) ->
            pure (ActionError relPath (T.pack (displayException e))))

  let creates = length [() | ActionCreate {} <- actions]
      updates = length [() | ActionUpdate {} <- actions]
      chmods  = length [() | ActionChmod {} <- actions]
      noops   = length [() | ActionNoOp _ <- actions]

  pure SyncPlan
    { planTargetDir     = repoRoot
    , planProjectName   = projectName proj
    , planActions       = actions
    , planCreatedCount  = creates
    , planUpdatedCount  = updates
    , planChmodCount    = chmods
    , planNoOpCount     = noops
    }

--------------------------------------------------------------------------------
-- 3. Synchronization Execution
--------------------------------------------------------------------------------

-- | Result summary produced by executing a 'SyncPlan'.
data SyncResult = SyncResult
  { resultTargetDir   :: !FilePath
    -- ^ Target repository path.
  , resultProjectName :: !Text
    -- ^ Name of the synchronized project.
  , resultMode        :: !SyncMode
    -- ^ Mode under which synchronization executed ('DryRun' or 'ApplySync').
  , resultCreated     :: ![FilePath]
    -- ^ List of file paths created.
  , resultUpdated     :: ![FilePath]
    -- ^ List of file paths updated.
  , resultChmodded    :: ![FilePath]
    -- ^ List of file paths whose permissions were corrected.
  , resultUnchanged   :: ![FilePath]
    -- ^ List of file paths left untouched.
  , resultErrors      :: ![(FilePath, Text)]
    -- ^ List of errors encountered during execution.
  , resultSuccess     :: !Bool
    -- ^ Whether all actions executed without error.
  } deriving stock (Eq, Show)

-- | Executes a computed 'SyncPlan' under the specified 'SyncMode'.
--
-- === Operational Modes & Safety Invariants
--
-- * __'DryRun' Mode:__
--   Evaluates all actions declared in the plan, extracts summary statistics, and
--   constructs a 'SyncResult' representing the planned mutations without performing
--   any filesystem writes or permission modifications.
--
-- * __'ApplySync' Mode:__
--   Applies all mutating actions sequentially to the destination repository:
--   1. __Parent Directory Synthesis:__ Automatically creates any missing parent
--      directories (via 'createDirectoryIfMissing True') prior to writing file contents.
--   2. __Atomic Content Materialization:__ Writes canonical UTF-8 text contents to
--      disk for both 'ActionCreate' and 'ActionUpdate' operations.
--   3. __Permission Enforcement:__ When an asset requires executable permissions
--      (e.g., verification harnesses, Git hooks, tag generation scripts), explicitly
--      sets the owner executable bit via 'setOwnerExecutable True'.
--   4. __Exception Containment:__ Wraps each individual filesystem mutation in an
--      isolated exception handler. If a write fails (due to permissions, disk space,
--      or locks), the error is recorded in 'resultErrors' while continuing execution
--      for remaining assets.
--   5. __Idempotent Resonance:__ After successful execution under 'ApplySync', the
--      repository is mathematically stable: immediately calling 'computeSyncPlan'
--      produces 'isPlanClean plan == True'.
executeSyncPlan :: SyncMode -> SyncPlan -> IO SyncResult
executeSyncPlan mode plan = do
  case mode of
    DryRun -> do
      -- In DryRun mode, return planned changes without mutating disk
      let created = [p | ActionCreate p _ _ <- planActions plan]
          updated = [p | ActionUpdate p _ _ _ <- planActions plan]
          chmodded = [p | ActionChmod p _ _ <- planActions plan]
          unchanged = [p | ActionNoOp p <- planActions plan]
          errs = [(p, msg) | ActionError p msg <- planActions plan]
      pure SyncResult
        { resultTargetDir   = planTargetDir plan
        , resultProjectName = planProjectName plan
        , resultMode        = DryRun
        , resultCreated     = created
        , resultUpdated     = updated
        , resultChmodded    = chmodded
        , resultUnchanged   = unchanged
        , resultErrors      = errs
        , resultSuccess     = null errs
        }
    ApplySync -> do
      -- In ApplySync mode, apply mutations to disk safely
      resActions <- forM (planActions plan) $ \action -> do
        case action of
          ActionCreate relPath content isExec -> (do
            let absPath = planTargetDir plan </> relPath
            createDirectoryIfMissing True (takeDirectory absPath)
            TIO.writeFile absPath content
            when isExec $ do
              perms <- getPermissions absPath
              setPermissions absPath (setOwnerExecutable True perms)
            pure (ExecCreated relPath)
            ) `catch` (\(e :: SomeException) ->
                pure (ExecFailed relPath (T.pack (displayException e))))

          ActionUpdate relPath _ newContent isExec -> (do
            let absPath = planTargetDir plan </> relPath
            createDirectoryIfMissing True (takeDirectory absPath)
            TIO.writeFile absPath newContent
            when isExec $ do
              perms <- getPermissions absPath
              setPermissions absPath (setOwnerExecutable True perms)
            pure (ExecUpdated relPath)
            ) `catch` (\(e :: SomeException) ->
                pure (ExecFailed relPath (T.pack (displayException e))))

          ActionChmod relPath _ targetExec -> (do
            let absPath = planTargetDir plan </> relPath
            perms <- getPermissions absPath
            setPermissions absPath (setOwnerExecutable targetExec perms)
            pure (ExecChmodded relPath)
            ) `catch` (\(e :: SomeException) ->
                pure (ExecFailed relPath (T.pack (displayException e))))

          ActionNoOp relPath ->
            pure (ExecUnchanged relPath)

          ActionError relPath msg ->
            pure (ExecFailed relPath msg)

      let created = [p | ExecCreated p <- resActions]
          updated = [p | ExecUpdated p <- resActions]
          chmodded = [p | ExecChmodded p <- resActions]
          unchanged = [p | ExecUnchanged p <- resActions]
          errs = [(p, msg) | ExecFailed p msg <- resActions]

      pure SyncResult
        { resultTargetDir   = planTargetDir plan
        , resultProjectName = planProjectName plan
        , resultMode        = ApplySync
        , resultCreated     = created
        , resultUpdated     = updated
        , resultChmodded    = chmodded
        , resultUnchanged   = unchanged
        , resultErrors      = errs
        , resultSuccess     = null errs
        }

-- | Internal tracking of single action execution outcome during 'ApplySync'.
data ActionExecOutcome
  = ExecCreated !FilePath
  | ExecUpdated !FilePath
  | ExecChmodded !FilePath
  | ExecUnchanged !FilePath
  | ExecFailed !FilePath !Text

-- | High-level synchronization: computes plan and executes under 'SyncMode'.
syncRepository
  :: SyncMode
  -> FilePath
  -> Project
  -> StandardizationMatrix
  -> IO SyncResult
syncRepository mode repoRoot proj matrix = do
  plan <- computeSyncPlan repoRoot proj matrix
  executeSyncPlan mode plan

-- | Fast zero-configuration synchronization for a single repository checkout.
--
-- Automatically locates and decodes @project.dhall@ if present. If absent, derives
-- a default 'Project' model using the folder's canonical basename and standard ecosystem
-- author metadata. Synchronizes the checkout against 'canonicalMatrix'.
--
-- === Error Isolation
--
-- Returns 'Left' with diagnostic text if @project.dhall@ fails to parse or decode.
-- On successful plan execution, returns 'Right' containing the complete 'SyncResult'.
syncRepositoryQuick :: SyncMode -> FilePath -> IO (Either Text SyncResult)
syncRepositoryQuick mode repoRoot = do
  absRoot <- canonicalizePath repoRoot
  let dhallPath = absRoot </> "project.dhall"
  hasDhall <- doesFileExist dhallPath
  if hasDhall
    then (do
      proj <- decodeProjectFile dhallPath
      res <- syncRepository mode absRoot proj canonicalMatrix
      pure (Right res)
      ) `catch` (\(e :: SomeException) ->
          pure (Left ("Failed to decode project.dhall: " <> T.pack (displayException e))))
    else do
      let inferredName = T.pack (takeFileName absRoot)
          defaultAuthor = Author "Nadia Yvette Chambers" "nadia.yvette.chambers@ik.me"
          proj = defaultProject inferredName defaultAuthor
      res <- syncRepository mode absRoot proj canonicalMatrix
      pure (Right res)

--------------------------------------------------------------------------------
-- 4. Multi-Repository Ecosystem Orchestration
--------------------------------------------------------------------------------

-- | Discovers sibling ecosystem repositories residing within a parent directory.
--
-- Scans immediate child directories in 'parentDir' and identifies candidates that
-- exhibit repository traits:
-- * Presence of a @.git@ directory (VCS root).
-- * Presence of a @cabal.project@ manifest (Cabal multi-package workspace root).
-- * Presence of a @project.dhall@ specification (Gachi schema specification).
--
-- Ignores plain directories that lack all three indicators, preventing inadvertent
-- modification of unrelated scratch folders or temporary data.
--
-- === Worked Example
--
-- >>> repos <- discoverEcosystemRepositories "/home/nyc/src"
-- >>> any (\p -> takeFileName p == "gachi") repos
-- True
discoverEcosystemRepositories :: FilePath -> IO [FilePath]
discoverEcosystemRepositories parentDir = do
  exists <- doesDirectoryExist parentDir
  if not exists
    then pure []
    else do
      entries <- listDirectory parentDir
      repos <- forM entries $ \entry -> do
        let candidate = parentDir </> entry
        isDir <- doesDirectoryExist candidate
        if not isDir
          then pure Nothing
          else do
            hasGit <- doesDirectoryExist (candidate </> ".git")
            hasCabalProj <- doesFileExist (candidate </> "cabal.project")
            hasDhall <- doesFileExist (candidate </> "project.dhall")
            if hasGit || hasCabalProj || hasDhall
              then pure (Just candidate)
              else pure Nothing
      pure (catMaybes repos)

-- | Harmonically synchronizes all discovered repositories within an ecosystem directory.
--
-- Sequentially iterates over all checkouts discovered via 'discoverEcosystemRepositories',
-- evaluating and applying synchronization under the given 'SyncMode'.
--
-- === Failure Isolation & Reporting
--
-- Execution is resilient: if one repository fails to evaluate its @project.dhall@ or
-- encounters I/O errors during synchronization, the failure is captured as 'Left'
-- in the resulting association list while synchronization continues across all other
-- repositories.
syncEcosystem
  :: SyncMode
  -> FilePath
  -> StandardizationMatrix
  -> IO [(FilePath, Either Text SyncResult)]
syncEcosystem mode parentDir matrix = do
  repoPaths <- discoverEcosystemRepositories parentDir
  forM repoPaths $ \repoPath -> do
    let dhallPath = repoPath </> "project.dhall"
    hasDhall <- doesFileExist dhallPath
    if hasDhall
      then (do
        proj <- decodeProjectFile dhallPath
        res <- syncRepository mode repoPath proj matrix
        pure (repoPath, Right res)
        ) `catch` (\(e :: SomeException) ->
            pure (repoPath, Left ("Failed to decode project.dhall: " <> T.pack (displayException e))))
      else do
        let inferredName = T.pack (takeFileName repoPath)
            defaultAuthor = Author "Nadia Yvette Chambers" "nadia.yvette.chambers@ik.me"
            proj = defaultProject inferredName defaultAuthor
        res <- syncRepository mode repoPath proj matrix
        pure (repoPath, Right res)

--------------------------------------------------------------------------------
-- 5. Diagnostic Plan & Result Formatting
--------------------------------------------------------------------------------

-- | Renders a 'SyncPlan' as a formatted plain-text ASCII table.
--
-- Produces an aligned, terminal-friendly diagnostic report containing:
-- * Workspace root directory and target project name.
-- * Aggregate action summary counters (Created, Updated, Chmod, No-Op).
-- * Tabular inventory of every evaluated asset:
--   * @CREATE@: Missing files scheduled for creation, marked with @(+x)@ if executable.
--   * @UPDATE@: Drifted files showing 8-character SHA-256 prefixes (@oldHash -> newHash@).
--   * @CHMOD@: Permission fixes indicating transition (@exec: False -> True@).
--   * @NO-OP@: Cleanly compliant files left untouched.
--   * @ERROR@: Diagnostic message for files where I/O inspection failed.
--
-- === Output Stability
--
-- Suitable for stdout terminal streaming, CI log capture, or regression testing.
formatSyncPlan :: SyncPlan -> Text
formatSyncPlan plan = T.unlines $
  [ "================================================================================"
  , " Gachi Synchronization Plan (Pre-Mutation Preview)"
  , "================================================================================"
  , " Target Directory: " <> T.pack (planTargetDir plan)
  , " Project Name:     " <> planProjectName plan
  , " Actions Planned:  "
      <> "Create: " <> T.show (planCreatedCount plan)
      <> " | Update: " <> T.show (planUpdatedCount plan)
      <> " | Chmod: " <> T.show (planChmodCount plan)
      <> " | No-Op: " <> T.show (planNoOpCount plan)
  , "--------------------------------------------------------------------------------"
  , " Action     | Path"
  , "------------+-------------------------------------------------------------------"
  ]
  ++ map formatActionRow (planActions plan)
  ++
  [ "================================================================================" ]
  where
    formatActionRow (ActionCreate p _ isExec) =
      " CREATE     | " <> T.pack p <> (if isExec then " (+x)" else "")
    formatActionRow (ActionUpdate p oldTxt newTxt isExec) =
      let oldHash = T.take 8 (sha256Text oldTxt)
          newHash = T.take 8 (sha256Text newTxt)
      in " UPDATE     | " <> T.pack p <> " (" <> oldHash <> " -> " <> newHash <> ")"
           <> (if isExec then " (+x)" else "")
    formatActionRow (ActionChmod p actual expected) =
      " CHMOD      | " <> T.pack p <> " (exec: " <> T.show actual <> " -> " <> T.show expected <> ")"
    formatActionRow (ActionNoOp p) =
      " NO-OP      | " <> T.pack p <> " (compliant)"
    formatActionRow (ActionError p msg) =
      " ERROR      | " <> T.pack p <> " (" <> msg <> ")"

-- | Renders a 'SyncResult' as a formatted plain-text ASCII summary banner.
--
-- Formats the final disposition of a completed or simulated synchronization run:
-- * Destination directory and project identification slug.
-- * Active execution mode: @DRY RUN (Preview Only)@ vs @APPLIED@.
-- * Status verdict: @SUCCESS@ (zero errors) or @FAILED@ (one or more I/O errors).
-- * Exact counts of created, updated, chmodded, and unchanged files.
-- * Dedicated error section listing failing relative paths and exception messages.
formatSyncResult :: SyncResult -> Text
formatSyncResult res = T.unlines $
  [ "================================================================================"
  , " Gachi Synchronization Execution Summary"
  , "================================================================================"
  , " Target Directory: " <> T.pack (resultTargetDir res)
  , " Project Name:     " <> resultProjectName res
  , " Execution Mode:   " <> (if resultMode res == DryRun then "DRY RUN (Preview Only)" else "APPLIED")
  , " Status:           " <> (if resultSuccess res then "SUCCESS" else "FAILED")
  , " Files Created:    " <> T.show (length (resultCreated res))
  , " Files Updated:    " <> T.show (length (resultUpdated res))
  , " Permissions Fixed:" <> T.show (length (resultChmodded res))
  , " Files Unchanged:  " <> T.show (length (resultUnchanged res))
  ]
  ++ formatErrorSection (resultErrors res)
  ++
  [ "================================================================================" ]
  where
    formatErrorSection [] = []
    formatErrorSection errs =
      [ "--------------------------------------------------------------------------------"
      , " Errors Encountered:"
      ] ++ map (\(p, msg) -> "  * " <> T.pack p <> ": " <> msg) errs