packages feed

canontra-0.1.0.0: src/Canontra/CLI/Cache.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE OverloadedStrings #-}

{- |
Module      : Canontra.CLI.Cache
Description : Cache maintenance tooling for canontra v0.1.0.

Provides subcommands for inspecting, verifying, cleaning, and pruning
the CNTR\x05 memory-mapped paged radix binary cache (.canontra/cache.bin).
-}
module Canontra.CLI.Cache
  ( CacheAction (..)
  , runCacheCommand
  ) where

import Control.Monad (filterM)
import Data.Aeson ((.=), object)
import qualified Data.Aeson.Encode.Pretty as AesonPretty
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy.Char8 as LBSC
import qualified Data.Map.Strict as Map
import qualified Data.Text as T
import System.Directory (doesFileExist, removeFile)
import System.Exit (ExitCode (..), exitWith)
import System.FilePath ((</>))
import System.IO (hPutStrLn, stderr)

import Canontra.Cache.Common (MerkleCache (..))
import Canontra.Cache.PagedCache
  ( decodeBinaryCacheV5WithRecovery
  , readPagedCacheFileResilient
  , verifyHeaderCRC
  , writePagedCacheFile
  )

-- | Target operation for the 'canontra cache' command.
data CacheAction
  = CacheInfo
  | CacheVerify
  | CacheClean
  | CachePrune
  deriving stock (Eq, Ord, Show)

-- | Executes the requested cache action on the target directory.
runCacheCommand :: CacheAction -> FilePath -> Bool -> IO ()
runCacheCommand action rootDir asJson = do
  let cacheFile = rootDir </> ".canontra" </> "cache.bin"
  case action of
    CacheInfo   -> runInfo cacheFile asJson
    CacheVerify -> runVerify cacheFile asJson
    CacheClean  -> runClean cacheFile asJson
    CachePrune  -> runPrune rootDir cacheFile asJson

-- ============================================================================
-- Cache Info
-- ============================================================================
runInfo :: FilePath -> Bool -> IO ()
runInfo cacheFile asJson = do
  exists <- doesFileExist cacheFile
  if not exists
    then do
      if asJson
        then LBSC.putStrLn $ AesonPretty.encodePretty $ object
          [ "exists" .= False
          , "path"   .= cacheFile
          ]
        else do
          putStrLn "================================================================================"
          putStrLn "  CANONTRA CACHE INFO"
          putStrLn "================================================================================"
          putStrLn $ "  Cache File:          " ++ cacheFile
          putStrLn   "  Status:              Not initialized (no cache file exists)"
          putStrLn "================================================================================"
    else do
      rawBytes <- BS.readFile cacheFile
      let sizeBytes = BS.length rawBytes
          headerOk  = verifyHeaderCRC rawBytes
          (mCache, corrupted) = decodeBinaryCacheV5WithRecovery rawBytes
          entryCount = case mCache of
            Just c  -> Map.size (unMerkleCache c)
            Nothing -> 0
          slabCount = sizeBytes `div` 4096

      if asJson
        then LBSC.putStrLn $ AesonPretty.encodePretty $ object
          [ "exists"               .= True
          , "path"                 .= cacheFile
          , "size_bytes"           .= sizeBytes
          , "entries_count"        .= entryCount
          , "slab_pages_count"     .= slabCount
          , "format_version"       .= ("CNTR\\x05" :: T.Text)
          , "header_crc_valid"     .= headerOk
          , "corrupted_pages_count".= length corrupted
          ]
        else do
          putStrLn "================================================================================"
          putStrLn "  CANONTRA CACHE INFO"
          putStrLn "================================================================================"
          putStrLn $ "  Cache File:          " ++ cacheFile
          putStrLn $ "  Format Version:      CNTR\\x05 (4KB Paged Radix Cache)"
          putStrLn $ "  File Size:           " ++ show sizeBytes ++ " bytes (" ++ show (sizeBytes `div` 1024) ++ " KB)"
          putStrLn $ "  Indexed Files:       " ++ show entryCount ++ " records"
          putStrLn $ "  Slab Pages:          " ++ show slabCount ++ " pages (4096 bytes/page)"
          putStrLn $ "  Header CRC32:        " ++ (if headerOk then "VALID" else "CORRUPT")
          putStrLn $ "  Corrupted Pages:     " ++ show (length corrupted)
          putStrLn "================================================================================"

-- ============================================================================
-- Cache Verify
-- ============================================================================
runVerify :: FilePath -> Bool -> IO ()
runVerify cacheFile asJson = do
  exists <- doesFileExist cacheFile
  if not exists
    then do
      if asJson
        then LBSC.putStrLn $ AesonPretty.encodePretty $ object
          [ "status" .= ("missing" :: T.Text)
          , "path"   .= cacheFile
          , "error"  .= ("Cache file does not exist" :: T.Text)
          ]
        else do
          hPutStrLn stderr $ "Error: Cache file does not exist: " ++ cacheFile
      exitWith (ExitFailure 4)
    else do
      rawBytes <- BS.readFile cacheFile
      let sizeBytes = BS.length rawBytes
          headerOk  = verifyHeaderCRC rawBytes
          (_, corrupted) = decodeBinaryCacheV5WithRecovery rawBytes
          totalSlabs = sizeBytes `div` 4096
          validSlabs = totalSlabs - length corrupted
          isClean = headerOk && null corrupted

      if asJson
        then do
          LBSC.putStrLn $ AesonPretty.encodePretty $ object
            [ "status"            .= (if isClean then ("ok" :: T.Text) else "corrupt")
            , "path"              .= cacheFile
            , "header_crc_valid"  .= headerOk
            , "total_slab_pages"  .= totalSlabs
            , "valid_slab_pages"  .= validSlabs
            , "corrupt_slab_pages".= length corrupted
            , "corrupt_indices"   .= corrupted
            ]
          if isClean then pure () else exitWith (ExitFailure 1)
        else do
          putStrLn "================================================================================"
          putStrLn "  CANONTRA CACHE INTEGRITY VERIFICATION"
          putStrLn "================================================================================"
          putStrLn $ "  Target:              " ++ cacheFile
          putStrLn $ "  Header Checksum:     " ++ (if headerOk then "PASSED" else "FAILED")
          putStrLn $ "  Total Slab Pages:    " ++ show totalSlabs
          putStrLn $ "  Valid Pages:         " ++ show validSlabs
          putStrLn $ "  Corrupted Pages:     " ++ show (length corrupted)
          if not (null corrupted)
            then putStrLn $ "  Corrupted Indices:   " ++ show corrupted
            else pure ()
          putStrLn "--------------------------------------------------------------------------------"
          if isClean
            then do
              putStrLn "  Result:              ALL CHECKS PASSED (100% CRC32 Integrity)"
              putStrLn "================================================================================"
            else do
              putStrLn "  Result:              CORRUPTION DETECTED in cache slabs"
              putStrLn "================================================================================"
              exitWith (ExitFailure 1)

-- ============================================================================
-- Cache Clean
-- ============================================================================
runClean :: FilePath -> Bool -> IO ()
runClean cacheFile asJson = do
  exists <- doesFileExist cacheFile
  if exists
    then do
      removeFile cacheFile
      if asJson
        then LBSC.putStrLn $ AesonPretty.encodePretty $ object
          [ "status" .= ("cleaned" :: T.Text)
          , "path"   .= cacheFile
          ]
        else do
          putStrLn $ "Cache cleared successfully: " ++ cacheFile
    else do
      if asJson
        then LBSC.putStrLn $ AesonPretty.encodePretty $ object
          [ "status" .= ("not_found" :: T.Text)
          , "path"   .= cacheFile
          ]
        else do
          putStrLn $ "No active cache found at: " ++ cacheFile

-- ============================================================================
-- Cache Prune
-- ============================================================================
runPrune :: FilePath -> FilePath -> Bool -> IO ()
runPrune rootDir cacheFile asJson = do
  exists <- doesFileExist cacheFile
  if not exists
    then do
      if asJson
        then LBSC.putStrLn $ AesonPretty.encodePretty $ object
          [ "status" .= ("not_found" :: T.Text)
          , "path"   .= cacheFile
          ]
        else do
          putStrLn $ "No active cache to prune at: " ++ cacheFile
    else do
      cache <- readPagedCacheFileResilient cacheFile
      let allEntries = Map.toList (unMerkleCache cache)
          originalCount = length allEntries
      keptEntries <- filterM (\(normPath, _) -> doesFileExist (rootDir </> normPath)) allEntries
      let retainedCount = length keptEntries
          prunedCount   = originalCount - retainedCount

      if prunedCount > 0
        then do
          let updatedCache = MerkleCache (Map.fromList keptEntries)
          writePagedCacheFile cacheFile updatedCache
        else pure ()

      if asJson
        then LBSC.putStrLn $ AesonPretty.encodePretty $ object
          [ "status"           .= ("pruned" :: T.Text)
          , "path"             .= cacheFile
          , "original_entries" .= originalCount
          , "pruned_entries"   .= prunedCount
          , "retained_entries" .= retainedCount
          ]
        else do
          putStrLn "================================================================================"
          putStrLn "  CANONTRA CACHE PRUNE"
          putStrLn "================================================================================"
          putStrLn $ "  Cache File:          " ++ cacheFile
          putStrLn $ "  Initial Records:     " ++ show originalCount
          putStrLn $ "  Orphaned Records:    " ++ show prunedCount ++ " (removed)"
          putStrLn $ "  Retained Records:    " ++ show retainedCount
          putStrLn "================================================================================"