packages feed

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

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

-- | Module: Gachi.Sync.Audit
-- Description: Non-destructive repository audit engine and diagnostic reporter
--
-- This module implements the read-only audit engine for inspecting local
-- checkouts against the canonical Standardization Matrix (~/src/PLAN.md §1.A).
--
-- = Architectural Context & Invariants
--
-- In accordance with Note [Ecosystem Standardization Invariants]
-- [ref:note-ecosystem-standardization] and Note [Cross-Repository Standardization Matrix and Audit Semantics]
-- [ref:note-standardization-matrix], audits are strictly read-only and non-destructive.
-- They evaluate physical file existence, exact textual content matching against
-- canonical generators, and POSIX executable permissions (@+x@) without mutating
-- any filesystem state.
--
-- == Audit Classification Rules
--
-- For each asset registered in the 'StandardizationMatrix', the audit engine produces
-- exactly one 'AssetAuditResult' containing an 'AssetAuditStatus':
--
-- * 'StatusCompliant':
--   The file exists at the expected path, its textual content matches the canonical
--   generator byte-for-byte, and its executable bits conform to the specification.
--
-- * 'StatusMissing':
--   The file does not exist on disk. For required assets, this is flagged as an
--   audit violation; for optional assets (e.g. polyglot formatting rules), it is
--   recorded without failing the verification gate.
--
-- * 'StatusContentDrift':
--   The file exists on disk, but its content differs from the output produced by
--   the canonical generator. The lengths of actual and expected contents are recorded.
--
-- * 'StatusPermissionDrift':
--   The file exists and its content matches, but its executable bit differs from
--   'assetExecutable' (e.g. a script in @bin/@ lacking executable permissions).
--
-- * 'StatusIOError':
--   An unexpected filesystem exception occurred while attempting to read the file.
--
-- == Read-Only & Non-Destructive Invariant
--
-- The audit engine strictly adheres to the principle of observational purity:
-- 1. __Zero Mutations:__ No files, symlinks, or directories are created, modified,
--    or deleted during audit execution.
-- 2. __Exception Containment:__ I/O exceptions (e.g., permission denied, broken symlinks,
--    dangling filesystem nodes) are captured and reported as 'StatusIOError' rather
--    than aborting the audit process abruptly.
-- 3. __Differential Hashing:__ Content comparisons are performed in memory against
--    pure generator outputs, ensuring that audits require no network access or external
--    database queries.
--
-- = Worked Example
--
-- >>> import Gachi.Schema.Project (defaultProject, Author(..))
-- >>> import Gachi.Sync.Matrix (canonicalMatrix)
-- >>> let auth = Author "Nadia Yvette Chambers" "nadia.yvette.chambers@ik.me"
-- >>> let proj = defaultProject "gachi" auth
-- >>> report <- auditRepository "/home/nyc/src/gachi" proj canonicalMatrix
-- >>> reportTotalAssets report >= 20
-- True
-- >>> reportCompliancePct report >= 50.0
-- True
module Gachi.Sync.Audit
  ( -- * Repository Audit Operations
    auditRepository
  , auditAsset
  , auditRepositoryQuick

    -- * Diagnostic Report Formatting
  , formatAuditReport
  , formatAuditReportString

    -- * Result Filter Helpers
  , filterCompliantResults
  , filterDriftResults
  , filterMissingResults
  ) where

import Control.Exception (SomeException, catch, displayException)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import System.Directory (doesFileExist, getPermissions, executable)
import System.FilePath ((</>), takeFileName)

import Gachi.Schema.Dhall (decodeProjectFile)
import Gachi.Schema.Project (Author(..), Project, projectName, defaultProject)
import Gachi.Sync.Matrix (canonicalMatrix)
import Gachi.Sync.Types
  ( AssetAuditResult(..)
  , AssetAuditStatus(..)
  , AuditReport(..)
  , StandardAsset(..)
  , StandardizationMatrix(..)
  , categoryName
  , isCompliant
  , isDrift
  , isMissing
  , resolveAssetPath
  )

--------------------------------------------------------------------------------
-- 1. Single Asset Audit
--------------------------------------------------------------------------------

-- | Inspects an individual standardized asset against a target workspace.
--
-- Resolves the asset's concrete path using 'resolveAssetPath', checks for file
-- existence, reads the file's textual contents, checks executable permissions,
-- and compares the actual state against the expected generator output.
--
-- This operation is strictly read-only and catches all filesystem exceptions,
-- returning 'StatusIOError' upon failure rather than throwing unhandled exceptions.
--
-- === Evaluation Steps
--
-- 1. Resolves concrete relative path via 'resolveAssetPath' (substituting @:name:@ tokens).
-- 2. Checks existence using 'System.Directory.doesFileExist'.
-- 3. If file does not exist, returns 'StatusMissing'.
-- 4. If file exists, reads text content using 'TIO.readFile'.
-- 5. Evaluates the pure generator 'assetGenerator' against 'proj'.
-- 6. If textual contents diverge, returns 'StatusContentDrift'.
-- 7. If contents match, inspects POSIX executable permissions.
-- 8. If executable requirements differ, returns 'StatusPermissionDrift'.
-- 9. If everything matches, returns 'StatusCompliant'.
--
-- === Worked Example
--
-- >>> import Gachi.Schema.Project (defaultProject, Author(..))
-- >>> let proj = defaultProject "gachi" (Author "Nadia" "nadia@example.com")
-- >>> let asset = StandardAsset ".hlint.yaml" Gachi.Sync.Types.CategoryLinter True False "HLint" (const "")
-- >>> result <- auditAsset "/home/nyc/src/gachi" proj asset
-- >>> resultAsset result == asset
-- True
auditAsset :: FilePath -> Project -> StandardAsset -> IO AssetAuditResult
auditAsset repoRoot proj asset = do
  let relPath = resolveAssetPath proj asset
      absPath = repoRoot </> relPath
  exists <- doesFileExist absPath
  if not exists
    then pure (AssetAuditResult asset StatusMissing)
    else checkContentAndPerms absPath
  where
    checkContentAndPerms absPath = (do
      actualContent <- TIO.readFile absPath
      let expectedContent = assetGenerator asset proj
      if actualContent /= expectedContent
        then
          let actualLen = T.length actualContent
              expectedLen = T.length expectedContent
          in pure (AssetAuditResult asset (StatusContentDrift actualLen expectedLen))
        else do
          perms <- getPermissions absPath
          let actualExec = executable perms
              expectedExec = assetExecutable asset
          if expectedExec && not actualExec
            then pure (AssetAuditResult asset (StatusPermissionDrift actualExec expectedExec))
            else pure (AssetAuditResult asset StatusCompliant)
      ) `catch` (\(e :: SomeException) ->
          pure (AssetAuditResult asset (StatusIOError (T.pack (displayException e)))))

--------------------------------------------------------------------------------
-- 2. Complete Repository Audit
--------------------------------------------------------------------------------

-- | Audits an entire repository workspace against a 'StandardizationMatrix'.
--
-- Evaluates each asset registered in 'matrixAssets', tabulates compliance and
-- discrepancy metrics, and constructs a comprehensive 'AuditReport' summarizing
-- findings across all standardization dimensions.
--
-- === Metrics Computed
--
-- * 'reportTotalAssets': Total count of assets evaluated.
-- * 'reportCompliantCount': Assets matching content and permissions exactly.
-- * 'reportDriftCount': Assets present on disk with differing text or permissions.
-- * 'reportMissingCount': Required assets absent from the filesystem.
-- * 'reportCompliancePct': Ratio of compliant assets to total assets (0.0% to 100.0%).
--
-- === Worked Example
--
-- >>> import Gachi.Schema.Project (defaultProject, Author(..))
-- >>> import Gachi.Sync.Matrix (canonicalMatrix)
-- >>> let proj = defaultProject "gachi" (Author "Nadia" "nadia@example.com")
-- >>> report <- auditRepository "/home/nyc/src/gachi" proj canonicalMatrix
-- >>> reportTotalAssets report >= 20
-- True
auditRepository :: FilePath -> Project -> StandardizationMatrix -> IO AuditReport
auditRepository repoRoot proj matrix = do
  results <- traverse (auditAsset repoRoot proj) (matrixAssets matrix)
  let total = length results
      compliant = length (filter (isCompliant . resultStatus) results)
      drift = length (filter (isDrift . resultStatus) results)
      missing = length (filter (\r -> isMissing (resultStatus r) && assetRequired (resultAsset r)) results)
      pct = if total == 0
              then 100.0
              else (fromIntegral compliant / fromIntegral total) * 100.0
  pure AuditReport
    { reportTargetDir      = repoRoot
    , reportProjectName    = projectName proj
    , reportResults        = results
    , reportTotalAssets    = total
    , reportCompliantCount = compliant
    , reportDriftCount     = drift
    , reportMissingCount   = missing
    , reportCompliancePct  = pct
    }

-- | Fast, zero-configuration repository audit.
--
-- Attempts to locate and evaluate @project.dhall@ within 'repoRoot'. If present,
-- decodes the declarative specification via 'decodeProjectFile' and executes
-- 'auditRepository'. If absent, infers project metadata from the directory name
-- and standard author information, auditing against 'canonicalMatrix'.
--
-- Guarantees total evaluation: catches Dhall decoding exceptions and returns
-- a descriptive 'Left' error text rather than crashing.
--
-- === Worked Example
--
-- >>> res <- auditRepositoryQuick "/home/nyc/src/gachi"
-- >>> case res of { Right rep -> reportCompliancePct rep >= 50.0; Left _ -> False }
-- True
auditRepositoryQuick :: FilePath -> IO (Either Text AuditReport)
auditRepositoryQuick repoRoot = do
  let dhallPath = repoRoot </> "project.dhall"
  hasDhall <- doesFileExist dhallPath
  if hasDhall
    then (do
      proj <- decodeProjectFile dhallPath
      report <- auditRepository repoRoot proj canonicalMatrix
      pure (Right report)
      ) `catch` (\(e :: SomeException) ->
          pure (Left ("Failed to decode project.dhall: " <> T.pack (displayException e))))
    else do
      let inferredName = T.pack (takeFileName repoRoot)
          defaultAuthor = Author "Nadia Yvette Chambers" "nadia.yvette.chambers@ik.me"
          proj = defaultProject inferredName defaultAuthor
      report <- auditRepository repoRoot proj canonicalMatrix
      pure (Right report)

--------------------------------------------------------------------------------
-- 3. Result Filters
--------------------------------------------------------------------------------

-- | Filters an 'AuditReport' to only include compliant assets.
--
-- Useful for generating green status checklists and compliance confirmations.
filterCompliantResults :: AuditReport -> [AssetAuditResult]
filterCompliantResults = filter (isCompliant . resultStatus) . reportResults

-- | Filters an 'AuditReport' to only include assets exhibiting content or permission drift.
--
-- Used by synchronization engines ('gachi sync') to identify files requiring differential updates.
filterDriftResults :: AuditReport -> [AssetAuditResult]
filterDriftResults = filter (isDrift . resultStatus) . reportResults

-- | Filters an 'AuditReport' to only include missing required assets.
--
-- Identifies critical ecosystem gaps that must be populated during repository retrofitting.
filterMissingResults :: AuditReport -> [AssetAuditResult]
filterMissingResults =
  filter (\r -> isMissing (resultStatus r) && assetRequired (resultAsset r)) . reportResults

--------------------------------------------------------------------------------
-- 4. Diagnostic Report Formatting
--------------------------------------------------------------------------------

-- | Formats an 'AuditReport' as human-readable plain text with ASCII diagnostic tables.
--
-- Displays:
-- 1. Header banner with repository target path and compliance percentage.
-- 2. Summary statistics (compliant, drift, missing counts).
-- 3. Per-asset tabular evaluation detailing status, category, and relative path.
-- 4. Granular drift or missing asset diagnostics.
--
-- === Output Format Example
--
-- @
-- ================================================================================
--  Gachi Standardization Matrix Audit Report
-- ================================================================================
--  Target Directory: /home/nyc/src/gachi
--  Project Name:     gachi
--  Total Assets:     25
--  Compliant:        22
--  Drift Detected:   1
--  Missing Required: 2
--  Compliance Rate:  88.0%
-- --------------------------------------------------------------------------------
--  Status     | Dimension              | Path
-- ------------+------------------------+------------------------------------------
--  OK         | Code Formatters        | fourmolu.yaml
--  DRIFT      | Linters & Guardrails   | .hlint.yaml
--  MISSING    | Git Governance         | .githooks/commit-msg
-- ================================================================================
-- @
formatAuditReport :: AuditReport -> Text
formatAuditReport report = T.unlines $
  [ "================================================================================"
  , " Gachi Standardization Matrix Audit Report"
  , "================================================================================"
  , " Target Directory: " <> T.pack (reportTargetDir report)
  , " Project Name:     " <> reportProjectName report
  , " Total Assets:     " <> T.show (reportTotalAssets report)
  , " Compliant:        " <> T.show (reportCompliantCount report)
  , " Drift Detected:   " <> T.show (reportDriftCount report)
  , " Missing Required: " <> T.show (reportMissingCount report)
  , " Compliance Rate:  " <> formatPct (reportCompliancePct report)
  , "--------------------------------------------------------------------------------"
  , " Status     | Dimension              | Path"
  , "------------+------------------------+------------------------------------------"
  ]
  ++ map formatRow (reportResults report)
  ++ formatDriftSection (filterDriftResults report)
  ++ formatMissingSection (filterMissingResults report)
  ++
  [ "================================================================================" ]
  where
    formatPct :: Double -> Text
    formatPct val =
      let whole = floor val :: Int
          dec = floor (val * 10) `mod` 10 :: Int
      in T.show whole <> "." <> T.show dec <> "%"

    formatRow :: AssetAuditResult -> Text
    formatRow res =
      let stat = formatStatus (resultStatus res)
          cat = padRight 22 (categoryName (assetCategory (resultAsset res)))
          path = T.pack (assetPath (resultAsset res))
      in " " <> padRight 10 stat <> " | " <> cat <> " | " <> path

    formatStatus :: AssetAuditStatus -> Text
    formatStatus StatusCompliant          = "OK"
    formatStatus StatusMissing            = "MISSING"
    formatStatus (StatusContentDrift _ _) = "DRIFT"
    formatStatus (StatusPermissionDrift _ _) = "PERM-DRIFT"
    formatStatus (StatusIOError _)        = "ERROR"

    padRight :: Int -> Text -> Text
    padRight n txt =
      let len = T.length txt
      in if len >= n
           then txt
           else txt <> T.replicate (n - len) " "

    formatDriftSection :: [AssetAuditResult] -> [Text]
    formatDriftSection [] = []
    formatDriftSection drifts =
      [ "--------------------------------------------------------------------------------"
      , " Content & Permission Drift Details:"
      ] ++ map explainDrift drifts

    explainDrift :: AssetAuditResult -> Text
    explainDrift res =
      let p = T.pack (assetPath (resultAsset res))
      in case resultStatus res of
        StatusContentDrift actual expected ->
          "  * " <> p <> ": content divergence (actual len: " <> T.show actual
          <> " chars, expected: " <> T.show expected <> " chars)"
        StatusPermissionDrift actual expected ->
          "  * " <> p <> ": permission mismatch (actual exec: " <> T.show actual
          <> ", expected: " <> T.show expected <> ")"
        StatusIOError err ->
          "  * " <> p <> ": I/O error (" <> err <> ")"
        _ -> "  * " <> p

    formatMissingSection :: [AssetAuditResult] -> [Text]
    formatMissingSection [] = []
    formatMissingSection missings =
      [ "--------------------------------------------------------------------------------"
      , " Missing Required Assets:"
      ] ++ map explainMissing missings

    explainMissing :: AssetAuditResult -> Text
    explainMissing res =
      let p = T.pack (assetPath (resultAsset res))
          desc = assetDescription (resultAsset res)
      in "  * " <> p <> " — " <> desc

-- | Convenience helper rendering 'formatAuditReport' to a 'String'.
--
-- Facilitates output to standard terminal streams via 'putStrLn'.
formatAuditReportString :: AuditReport -> String
formatAuditReportString = T.unpack . formatAuditReport