packages feed

gachi-plan-0.1.0.0: test/Main.hs

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

-- | Module: Main (gachi-plan test suite)
-- Description: Unit and integration tests for detached Git plan store architecture
--
-- Validates Milestone 5.1 of Phase 5 (Ecosystem Plan Governance & Rollback per ~/src/PLAN.md §1.A).
-- Tests detached bare Git repository lifecycle, non-interference invariants,
-- governed document tracking, dirty status detection, and commit metadata.
module Main (main) where

import Control.Exception (SomeException, bracket, catch)
import Control.Monad (forM_)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import Data.Time.Clock.POSIX (getPOSIXTime)
import System.Directory
  ( createDirectoryIfMissing
  , doesDirectoryExist
  , getTemporaryDirectory
  , removeDirectoryRecursive
  )
import System.FilePath ((</>))
import Test.Hspec

import Gachi.Plan
  ( PlanStore(..)
  , PlanStoreStatus(..)
  , commitStore
  , defaultGovernedFiles
  , getPlanStoreDiff
  , governedFilesExist
  , initPlanStore
  , isPlanStoreClean
  , isStoreInitialized
  , listTrackedFiles
  , queryPlanStore
  , resolvePlanStorePath
  , stageFiles
  , trackGovernedFiles
  )

-- | Helper executing an action within an isolated temporary directory.
withTempDir :: String -> (FilePath -> IO a) -> IO a
withTempDir prefix action = do
  base <- getTemporaryDirectory
  t <- getPOSIXTime
  let dirPath = base </> (prefix ++ "-" ++ show (round (t * 1000) :: Integer))
  bracket
    (createDirectoryIfMissing True dirPath >> pure dirPath)
    (\p -> catch (removeDirectoryRecursive p) (\(_ :: SomeException) -> pure ()))
    action

main :: IO ()
main = hspec $ do
  describe "Gachi.Plan.Store (Detached Git Store Lifecycle)" $ do
    it "initializes a fresh bare Git store in a temporary directory" $ do
      withTempDir "gachi-plan-init" $ \tmpDir -> do
        let storePath = tmpDir </> "store.git"
            workPath  = tmpDir </> "work"
        res <- initPlanStore storePath workPath
        case res of
          Left err -> expectationFailure ("initPlanStore failed: " ++ show err)
          Right store -> do
            planGitDir store `shouldBe` storePath
            planWorkTree store `shouldBe` workPath
            planGovernedFiles store `shouldBe` defaultGovernedFiles
            isInit <- isStoreInitialized storePath
            isInit `shouldBe` True

    it "idempotently re-initializes an already existing bare store" $ do
      withTempDir "gachi-plan-reinit" $ \tmpDir -> do
        let storePath = tmpDir </> "store.git"
            workPath  = tmpDir </> "work"
        res1 <- initPlanStore storePath workPath
        case res1 of
          Left err -> expectationFailure ("First init failed: " ++ show err)
          Right _ -> do
            res2 <- initPlanStore storePath workPath
            case res2 of
              Left err -> expectationFailure ("Second init failed: " ++ show err)
              Right store2 -> do
                isInit <- isStoreInitialized (planGitDir store2)
                isInit `shouldBe` True

    it "resolves explicit store path to an absolute path" $ do
      withTempDir "gachi-plan-resolve" $ \tmpDir -> do
        let explicitPath = tmpDir </> "custom.git"
        resolved <- resolvePlanStorePath (Just explicitPath)
        resolved `shouldBe` explicitPath

    it "reports false for non-existent store directory" $ do
      withTempDir "gachi-plan-missing" $ \tmpDir -> do
        let bogusPath = tmpDir </> "does-not-exist.git"
        isInit <- isStoreInitialized bogusPath
        isInit `shouldBe` False

  describe "Gachi.Plan.Store (Non-Interference & Worktree Isolation Invariants)" $ do
    it "strictly avoids creating .git in worktree root" $ do
      withTempDir "gachi-plan-isolation" $ \tmpDir -> do
        let storePath = tmpDir </> "store.git"
            workPath  = tmpDir </> "work"
        createDirectoryIfMissing True workPath
        _ <- initPlanStore storePath workPath
        -- Verify no .git directory or file was created in workPath
        hasDotGit <- doesDirectoryExist (workPath </> ".git")
        hasDotGit `shouldBe` False

    it "preserves child repository .git checkouts without interference" $ do
      withTempDir "gachi-plan-children" $ \tmpDir -> do
        let storePath = tmpDir </> "store.git"
            workPath  = tmpDir </> "work"
            childA    = workPath </> "gachi"
            childB    = workPath </> "polytropos"
        -- Simulate pre-existing child repository workspaces
        createDirectoryIfMissing True (childA </> ".git")
        createDirectoryIfMissing True (childB </> ".git")
        TIO.writeFile (childA </> "Main.hs") "module Main where"
        TIO.writeFile (childB </> "Lib.hs") "module Lib where"

        -- Initialize detached plan store at worktree root
        res <- initPlanStore storePath workPath
        case res of
          Left err -> expectationFailure ("init failed: " ++ show err)
          Right store -> do
            -- Create top-level ecosystem plan files
            TIO.writeFile (workPath </> "PLAN.md") "# Ecosystem Plan"
            TIO.writeFile (workPath </> "STATE.md") "# Ecosystem State"

            -- Stage and commit governed files
            stageRes <- trackGovernedFiles store
            case stageRes of
              Left err -> expectationFailure ("trackGovernedFiles failed: " ++ show err)
              Right staged -> length staged `shouldBe` 2

            commitRes <- commitStore store "Initial plan commit"
            case commitRes of
              Left err -> expectationFailure ("commitStore failed: " ++ show err)
              Right commitSha -> T.length commitSha `shouldBe` 40

            -- Child repo .git directories MUST remain intact
            childAExists <- doesDirectoryExist (childA </> ".git")
            childBExists <- doesDirectoryExist (childB </> ".git")
            childAExists `shouldBe` True
            childBExists `shouldBe` True

            -- Worktree root MUST NOT have a .git directory
            rootDotGit <- doesDirectoryExist (workPath </> ".git")
            rootDotGit `shouldBe` False

  describe "Gachi.Plan.Store (Governed File Tracking & Dirty Detection)" $ do
    it "reports empty tracked files and clean state on fresh store" $ do
      withTempDir "gachi-plan-empty" $ \tmpDir -> do
        let storePath = tmpDir </> "store.git"
            workPath  = tmpDir </> "work"
        Right store <- initPlanStore storePath workPath
        status <- queryPlanStore store
        statusHeadCommit status `shouldBe` Nothing
        statusLastCommitTime status `shouldBe` Nothing
        statusTrackedFiles status `shouldBe` []
        statusDirtyFiles status `shouldBe` []
        statusIsDirty status `shouldBe` False
        isPlanStoreClean status `shouldBe` True

    it "detects untracked governed planning files as dirty" $ do
      withTempDir "gachi-plan-dirty" $ \tmpDir -> do
        let storePath = tmpDir </> "store.git"
            workPath  = tmpDir </> "work"
        Right store <- initPlanStore storePath workPath

        -- Create mock governed planning files
        TIO.writeFile (workPath </> "PLAN.md") "# Master Plan\n"
        TIO.writeFile (workPath </> "STATE.md") "# Operational Frontier\n"

        status <- queryPlanStore store
        statusIsDirty status `shouldBe` True
        isPlanStoreClean status `shouldBe` False
        statusDirtyFiles status `shouldContain` ["PLAN.md"]
        statusDirtyFiles status `shouldContain` ["STATE.md"]

    it "ignores untracked files in child directories during dirty queries" $ do
      withTempDir "gachi-plan-ignore-child" $ \tmpDir -> do
        let storePath = tmpDir </> "store.git"
            workPath  = tmpDir </> "work"
            childDir  = workPath </> "some-child-package"
        createDirectoryIfMissing True childDir
        Right store <- initPlanStore storePath workPath

        -- Write untracked file in child package
        TIO.writeFile (childDir </> "SomeCode.hs") "module SomeCode where"

        -- Status should remain clean because governed files do not exist and child files are ignored
        status <- queryPlanStore store
        statusIsDirty status `shouldBe` False
        statusDirtyFiles status `shouldBe` []

    it "transitions from dirty to clean upon staging and committing" $ do
      withTempDir "gachi-plan-commit" $ \tmpDir -> do
        let storePath = tmpDir </> "store.git"
            workPath  = tmpDir </> "work"
        Right store <- initPlanStore storePath workPath

        -- Create all four canonical governed files
        createDirectoryIfMissing True (workPath </> "docs")
        TIO.writeFile (workPath </> "PLAN.md") "# Plan\n"
        TIO.writeFile (workPath </> "STATE.md") "# State\n"
        TIO.writeFile (workPath </> "INDEX.md") "# Index\n"
        TIO.writeFile (workPath </> "docs" </> "INDEX.md") "# Docs Index\n"

        -- Check physical file presence
        existList <- governedFilesExist store
        length existList `shouldBe` 4
        forM_ existList $ \(_, exists) -> exists `shouldBe` True

        -- Stage governed files
        stageRes <- stageFiles store ["PLAN.md", "STATE.md", "INDEX.md", "docs/INDEX.md"]
        case stageRes of
          Left err -> expectationFailure ("stageFiles failed: " ++ show err)
          Right () -> pure ()

        -- Files should now be tracked
        tracked <- listTrackedFiles store
        tracked `shouldContain` ["PLAN.md"]
        tracked `shouldContain` ["STATE.md"]
        tracked `shouldContain` ["INDEX.md"]
        tracked `shouldContain` ["docs/INDEX.md"]

        -- Commit changes
        commitRes <- commitStore store "Initial ecosystem governance checkpoint"
        case commitRes of
          Left err -> expectationFailure ("commitStore failed: " ++ show err)
          Right commitSha -> do
            T.length commitSha `shouldBe` 40

            -- Query updated status: should be clean with HEAD commit and timestamp
            status <- queryPlanStore store
            statusHeadCommit status `shouldBe` Just commitSha
            statusIsDirty status `shouldBe` False
            isPlanStoreClean status `shouldBe` True
            statusDirtyFiles status `shouldBe` []
            statusLastCommitTime status `shouldSatisfy` (/= Nothing)

    it "detects modifications to tracked planning documents and provides diff" $ do
      withTempDir "gachi-plan-diff" $ \tmpDir -> do
        let storePath = tmpDir </> "store.git"
            workPath  = tmpDir </> "work"
        Right store <- initPlanStore storePath workPath

        -- Initialize and commit a plan
        TIO.writeFile (workPath </> "PLAN.md") "# Version 1.0\n"
        _ <- stageFiles store ["PLAN.md"]
        _ <- commitStore store "Baseline commit"

        -- Verify clean
        status1 <- queryPlanStore store
        statusIsDirty status1 `shouldBe` False

        -- Modify PLAN.md
        TIO.appendFile (workPath </> "PLAN.md") "Added Phase 5 milestone details.\n"

        -- Verify dirty
        status2 <- queryPlanStore store
        statusIsDirty status2 `shouldBe` True
        statusDirtyFiles status2 `shouldContain` ["PLAN.md"]

        -- Inspect diff
        diffRes <- getPlanStoreDiff store Nothing
        case diffRes of
          Left err -> expectationFailure ("diff failed: " ++ show err)
          Right diffText -> do
            T.isInfixOf "Added Phase 5 milestone details." diffText `shouldBe` True