packages feed

clod-0.1.13: test/Clod/MainSpec.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleContexts #-}

-- |
-- Module      : Clod.MainSpec
-- Description : Tests for command-line interface
-- Copyright   : (c) Fuzz Leonard, 2025
-- License     : MIT
-- Maintainer  : ink@fuzz.ink
-- Stability   : experimental
--
-- This module contains tests for the main command-line interface.

module Clod.MainSpec (spec) where

import Test.Hspec
import System.Directory
import System.FilePath
import System.IO.Temp (withSystemTempDirectory)
-- Import System.Environment only for instances
import System.Environment ()
import Data.Either (isRight)
import qualified System.IO
import qualified Control.Exception as Exception
import Control.Exception (SomeException)
import Control.Monad.IO.Class ()

import qualified Clod.Core as Core
import Clod.Types (ClodConfig(..), runClodM, fileReadCap, fileWriteCap)
import qualified Options.Applicative as Opt
import Clod.TestHelpers (defaultTestConfig)

-- We'll create a mock Options type instead of importing Main
-- This avoids circular dependencies and follows test isolation principles

-- | Mock Options type that matches Main module's Options type
data Options = Options
  { optStagingDir  :: String  -- ^ Directory where files will be staged
  , optAllFiles    :: Bool    -- ^ Import all files
  , optTestMode    :: Bool    -- ^ Run in test mode
  , optVerbose     :: Bool    -- ^ Enable verbose output
  , optFlush       :: Bool    -- ^ Flush stale entries from the database
  , optLast        :: Bool    -- ^ Use previous staging directory
  } deriving (Show, Eq)

-- | Mock parser for options that matches Main module's optionsParser
optionsParser :: Opt.Parser Options
optionsParser = Options
  <$> Opt.strOption
      ( Opt.long "staging-dir"
     <> Opt.short 'd'
     <> Opt.metavar "DIR"
     <> Opt.help "Directory where files will be staged for Claude"
     <> Opt.value ""
     <> Opt.showDefault )
  <*> Opt.switch
      ( Opt.long "all"
     <> Opt.short 'a'
     <> Opt.help "Import all files" )
  <*> Opt.switch
      ( Opt.long "test"
     <> Opt.short 't'
     <> Opt.help "Run in test mode" )
  <*> Opt.switch
      ( Opt.long "verbose"
     <> Opt.short 'v'
     <> Opt.help "Enable verbose output" )
  <*> Opt.switch
      ( Opt.long "flush"
     <> Opt.short 'f'
     <> Opt.help "Flush missing entries from the database" )
  <*> Opt.switch
      ( Opt.long "last"
     <> Opt.short 'l'
     <> Opt.help "Use previous staging directory" )

-- | Mock opts that matches Main module's opts
opts :: Opt.ParserInfo Options
opts = Opt.info (optionsParser Opt.<**> Opt.helper)
  ( Opt.fullDesc
  <> Opt.progDesc "Prepare files from a git repository for upload to Claude's Project Knowledge"
  <> Opt.header "clod - Claude Git Project File Uploader" )

-- | Test specification for Main module
spec :: Spec
spec = do
  commandLineOptionsSpec
  cliWorkflowSpec

-- | Tests for command-line options parsing
commandLineOptionsSpec :: Spec
commandLineOptionsSpec = describe "Command line options parsing" $ do
  it "parses --all flag correctly" $ do
    let result = Opt.execParserPure Opt.defaultPrefs opts ["--all"]
    case result of
      Opt.Success options -> optAllFiles options `shouldBe` True
      _                   -> expectationFailure "Failed to parse --all flag"
      
  it "parses --flush flag correctly" $ do
    let result = Opt.execParserPure Opt.defaultPrefs opts ["--flush"]
    case result of
      Opt.Success options -> optFlush options `shouldBe` True
      _                   -> expectationFailure "Failed to parse --flush flag"
      
  it "parses --last flag correctly" $ do
    let result = Opt.execParserPure Opt.defaultPrefs opts ["--last"]
    case result of
      Opt.Success options -> optLast options `shouldBe` True
      _                   -> expectationFailure "Failed to parse --last flag"
      
  it "parses --test flag correctly" $ do
    let result = Opt.execParserPure Opt.defaultPrefs opts ["--test"]
    case result of
      Opt.Success options -> optTestMode options `shouldBe` True
      _                   -> expectationFailure "Failed to parse --test flag"
      
  it "parses --verbose flag correctly" $ do
    let result = Opt.execParserPure Opt.defaultPrefs opts ["--verbose"]
    case result of
      Opt.Success options -> optVerbose options `shouldBe` True
      _                   -> expectationFailure "Failed to parse --verbose flag"
      
  it "parses --staging-dir flag correctly" $ do
    let result = Opt.execParserPure Opt.defaultPrefs opts ["--staging-dir", "/tmp/test-staging"]
    case result of
      Opt.Success options -> optStagingDir options `shouldBe` "/tmp/test-staging"
      _                   -> expectationFailure "Failed to parse --staging-dir flag"
      
  it "parses multiple flags correctly" $ do
    let result = Opt.execParserPure Opt.defaultPrefs opts ["--all", "--verbose", "--test"]
    case result of
      Opt.Success options -> do
        optAllFiles options `shouldBe` True
        optVerbose options `shouldBe` True
        optTestMode options `shouldBe` True
      _ -> expectationFailure "Failed to parse multiple flags"
      
  it "uses correct defaults for options" $ do
    let result = Opt.execParserPure Opt.defaultPrefs opts []
    case result of
      Opt.Success options -> do
        optAllFiles options `shouldBe` False
        optTestMode options `shouldBe` False
        optVerbose options `shouldBe` False
        optFlush options `shouldBe` False
        optLast options `shouldBe` False
        optStagingDir options `shouldBe` ""
      _ -> expectationFailure "Failed to parse with default options"

-- | Tests for CLI workflow integration
cliWorkflowSpec :: Spec
cliWorkflowSpec = describe "CLI workflow" $ do
  it "follows SPEC.md behavior on first run and subsequent runs" $ do
    withSystemTempDirectory "clod-test" $ \tmpDir -> do
      -- Set up a test repository
      let projectDir = tmpDir </> "project"
      let stagingDir = tmpDir </> "staging"
      let configDir = projectDir </> ".clod"
      
      -- Create minimal project structure
      createDirectoryIfMissing True projectDir
      createDirectoryIfMissing True configDir
      createDirectoryIfMissing True stagingDir
      
      -- Create test files
      System.IO.writeFile (projectDir </> "file1.txt") "original content"
      System.IO.writeFile (projectDir </> "file2.txt") "original content"
      
      -- Create a basic config
      let config = (defaultTestConfig projectDir) {
            stagingDir = stagingDir,
            currentStaging = stagingDir,
            configDir = configDir,
            databaseFile = configDir </> "checksums.dhall"
          }
      
      -- Initialize test environment
      
      -- Run first time - should process all files
      _ <- Core.runClodApp config stagingDir False False
      
      -- Check if both files were processed
      file1Exists <- doesFileExist (stagingDir </> "file1.txt")
      file2Exists <- doesFileExist (stagingDir </> "file2.txt")
      file1Exists `shouldBe` True
      file2Exists `shouldBe` True
      
      -- Create a new staging directory for second run
      let stagingDir2 = tmpDir </> "staging2"
      createDirectoryIfMissing True stagingDir2
      
      -- Update config for second run
      let config2 = config {
            stagingDir = stagingDir2,
            currentStaging = stagingDir2
          }
      
      -- Make no changes to files
      result2 <- Core.runClodApp config2 stagingDir2 False False
      
      -- Print any errors for debugging
      case result2 of
        Left err -> putStrLn $ "Second run error: " ++ show err
        Right _ -> putStrLn "Second run completed successfully"
      
      -- Files should not be processed again
      file1Exists2 <- doesFileExist (stagingDir2 </> "file1.txt")
      file2Exists2 <- doesFileExist (stagingDir2 </> "file2.txt")
      manifestExists <- doesFileExist (stagingDir2 </> "_path_manifest.dhall")
      
      -- Check that files are processed according to SPEC.md
      -- The manifest is always created, but files should NOT be copied on second run
      -- if they haven't changed
      file1Exists2 `shouldBe` False  -- Unchanged files should NOT be processed on second run
      file2Exists2 `shouldBe` False  -- Unchanged files should NOT be processed on second run
      manifestExists `shouldBe` True  -- Manifest is always created
      
      -- Now modify a file and check third run
      System.IO.writeFile (projectDir </> "file1.txt") "modified content"
      
      -- Create a new staging directory for third run
      let stagingDir3 = tmpDir </> "staging3"
      createDirectoryIfMissing True stagingDir3
      
      -- Update config for third run
      let config3 = config {
            stagingDir = stagingDir3,
            currentStaging = stagingDir3
          }
      
      -- Run third time - should process only modified file
      _ <- Core.runClodApp config3 stagingDir3 False False
      
      -- Check which files were processed
      file1Exists3 <- doesFileExist (stagingDir3 </> "file1.txt")
      file2Exists3 <- doesFileExist (stagingDir3 </> "file2.txt")
      
      -- According to SPEC.md, on subsequent runs only changed files should be processed
      -- unless the --all flag is set
      file1Exists3 `shouldBe` True   -- Modified file should be copied
      file2Exists3 `shouldBe` False  -- Unchanged file should NOT be copied
  
  it "creates necessary directories" $ do
    withSystemTempDirectory "clod-test" $ \tmpDir -> do
      -- Set up a test repository
      let projectDir = tmpDir </> "project"
      let stagingDir = tmpDir </> "staging"
      
      -- Create minimal project structure
      createDirectoryIfMissing True projectDir
      createDirectoryIfMissing True (projectDir </> ".git")
      System.IO.writeFile (projectDir </> "test.txt") "test content"
      
      -- Run with test arguments
      -- Direct execution in production code would need a more sophisticated approach
      result <- Exception.try @SomeException $ do
        -- Create minimal config to use in test (avoiding main which could exit)
        let config = (defaultTestConfig projectDir) {
              stagingDir = stagingDir,
              currentStaging = stagingDir
            }
        
        -- Now execute what main would do but in a controlled way
        createDirectoryIfMissing True stagingDir
        createDirectoryIfMissing True (projectDir </> ".clod")
        
        -- Create a test file that should be processed
        System.IO.writeFile (projectDir </> "test.txt") "test content"
        
        -- Run core function directly instead of Main.main
        -- First run the main app to set up directories
        _ <- Core.runClodApp config stagingDir True False
        
        -- Now explicitly process the test file (since runClodApp doesn't process files by default)
        let readCap = fileReadCap [projectDir]
            writeCap = fileWriteCap [stagingDir]
            
        -- Process the test file manually
        runClodM config $ 
          Core.processFile readCap writeCap (projectDir </> "test.txt") "test.txt"
      
      -- Verify directories were created
      case result of
        Left err -> expectationFailure $ "Test failed with exception: " ++ show err
        Right coreResult -> do
          coreResult `shouldSatisfy` isRight
          
          -- Check if config directory was created
          configDirExists <- doesDirectoryExist (projectDir </> ".clod")
          configDirExists `shouldBe` True
          
          -- Check if staging directory was created
          stagingDirExists <- doesDirectoryExist stagingDir
          stagingDirExists `shouldBe` True
          
          -- Verify test.txt was processed and staged
          stagedFileExists <- doesFileExist (stagingDir </> "test.txt")
          stagedFileExists `shouldBe` True