packages feed

screp-0.3.0.0: test/ScreditTest.hs

{-# LANGUAGE ScopedTypeVariables #-}

module Main where

import Scrappy.Find (streamEdit)
import Scrappy.Grep.DSL.Parser (parseExpr)
import Scrappy.Grep.DSL.Interpreter (interpret)
import Text.Parsec (string, many1, digit, letter, anyChar, (<|>), try)

import System.Exit (exitFailure, exitSuccess, ExitCode(..))
import System.Directory (createDirectoryIfMissing, removeDirectoryRecursive, doesDirectoryExist, doesFileExist)
import System.FilePath ((</>))
import System.Process (readProcessWithExitCode)
import Control.Monad (when)
import Control.Exception (catch, IOException)
import Data.List (isInfixOf, isPrefixOf)

main :: IO ()
main = do
  putStrLn "Running scredit tests...\n"

  -- Run all tests
  results <- sequence
    -- Unit tests
    [ testStreamEditBasic
    , testStreamEditMultiple
    , testStreamEditNoMatch
    , testStreamEditEmpty
    , testStreamEditEmptyReplacement
    , testStreamEditOverlapping
    , testStreamEditAtBoundaries
    , testStreamEditConsecutive
    , testStreamEditUnicode
    , testStreamEditNewlines
    , testGlobWildcard
    , testGlobQuestion
    , testGlobLiteral
    , testGlobEdgeCases
    , testGlobCaseSensitive
    -- Integration tests
    , testHelpFlag
    , testMissingReplace
    , testInvalidPattern
    , testRefPatternRejected
    , testStdinDefault
    , testStdinReplacement
    , testStdinNoMatch
    , testSingleFile
    , testMultipleFiles
    , testFileNotFound
    , testInPlaceEdit
    , testInPlaceNoChange
    , testStdoutDefault
    , testDryRunNoModify
    , testDryRunMessage
    , testVerboseOutput
    , testVerboseOnlyChanged
    , testExtensionFilter
    , testRecursiveRequired
    , testRecursiveDescend
    , testRecursiveWithFilters
    , testPatternSomeDigit
    , testPatternString
    , testPatternComplex
    , testExitSuccess
    , testExitFailPattern
    -- Additional coverage tests
    , testStdinExplicit
    , testIncludePattern
    , testExcludePattern
    , testExcludeDir
    , testCombinedFilters
    , testMultipleExtensions
    , testExtensionNormalize
    , testInPlaceMultiple
    , testDryRunWithVerbose
    ]

  -- Summary
  let passed = length (filter id results)
      total = length results
  putStrLn $ "\n" ++ show passed ++ "/" ++ show total ++ " tests passed"

  if and results
    then exitSuccess
    else exitFailure

--------------------------------------------------------------------------------
-- Test Infrastructure
--------------------------------------------------------------------------------

-- | Run scredit with given args and stdin, return (exitcode, stdout, stderr)
runScredit :: [String] -> String -> IO (ExitCode, String, String)
runScredit args stdin = do
  -- First build to ensure executable exists
  _ <- readProcessWithExitCode "cabal" ["build", "scredit"] ""
  -- Run via cabal run
  readProcessWithExitCode "cabal" (["run", "scredit", "--"] ++ args) stdin

-- | Create temp directory, run action, cleanup
withTestDir :: String -> (FilePath -> IO Bool) -> IO Bool
withTestDir name action = do
  let dir = "/tmp/scredit-test-" ++ name
  createDirectoryIfMissing True dir
  result <- action dir `catch` (\(_ :: IOException) -> cleanup dir >> return False)
  cleanup dir
  return result

-- | Cleanup directory
cleanup :: FilePath -> IO ()
cleanup dir = do
  exists <- doesDirectoryExist dir
  when exists $ removeDirectoryRecursive dir

-- | Assert file contents match expected
assertFileContents :: FilePath -> String -> IO Bool
assertFileContents fp expected = do
  exists <- doesFileExist fp
  if not exists
    then return False
    else do
      actual <- readFile fp
      return (actual == expected)

--------------------------------------------------------------------------------
-- Unit Tests: streamEdit
--------------------------------------------------------------------------------

testStreamEditBasic :: IO Bool
testStreamEditBasic = do
  putStrLn "=== Unit: streamEdit Basic ==="
  let result = streamEdit (string "foo") (const "bar") "hello foo world"
  if result == "hello bar world"
    then do
      putStrLn "  PASS: Single match replaced"
      return True
    else do
      putStrLn $ "  FAIL: Expected 'hello bar world', got '" ++ result ++ "'"
      return False

testStreamEditMultiple :: IO Bool
testStreamEditMultiple = do
  putStrLn "\n=== Unit: streamEdit Multiple ==="
  let result = streamEdit (string "a") (const "X") "abracadabra"
  if result == "XbrXcXdXbrX"
    then do
      putStrLn "  PASS: Multiple matches replaced"
      return True
    else do
      putStrLn $ "  FAIL: Expected 'XbrXcXdXbrX', got '" ++ result ++ "'"
      return False

testStreamEditNoMatch :: IO Bool
testStreamEditNoMatch = do
  putStrLn "\n=== Unit: streamEdit No Match ==="
  let result = streamEdit (string "xyz") (const "abc") "hello world"
  if result == "hello world"
    then do
      putStrLn "  PASS: No match preserves input"
      return True
    else do
      putStrLn $ "  FAIL: Expected 'hello world', got '" ++ result ++ "'"
      return False

testStreamEditEmpty :: IO Bool
testStreamEditEmpty = do
  putStrLn "\n=== Unit: streamEdit Empty ==="
  let result = streamEdit (string "x") (const "y") ""
  if result == ""
    then do
      putStrLn "  PASS: Empty input returns empty"
      return True
    else do
      putStrLn $ "  FAIL: Expected '', got '" ++ result ++ "'"
      return False

testStreamEditEmptyReplacement :: IO Bool
testStreamEditEmptyReplacement = do
  putStrLn "\n=== Unit: streamEdit Empty Replacement ==="
  let result = streamEdit (string "remove") (const "") "please remove this"
  if result == "please  this"
    then do
      putStrLn "  PASS: Empty replacement deletes match"
      return True
    else do
      putStrLn $ "  FAIL: Expected 'please  this', got '" ++ result ++ "'"
      return False

testStreamEditOverlapping :: IO Bool
testStreamEditOverlapping = do
  putStrLn "\n=== Unit: streamEdit Overlapping ==="
  -- "aa" pattern on "aaa" should match first "aa", leave "a"
  let result = streamEdit (string "aa") (const "X") "aaa"
  if result == "Xa"
    then do
      putStrLn "  PASS: Overlapping patterns - first wins"
      return True
    else do
      putStrLn $ "  FAIL: Expected 'Xa', got '" ++ result ++ "'"
      return False

testStreamEditAtBoundaries :: IO Bool
testStreamEditAtBoundaries = do
  putStrLn "\n=== Unit: streamEdit At Boundaries ==="
  let result1 = streamEdit (string "start") (const "X") "start middle end"
      result2 = streamEdit (string "end") (const "X") "start middle end"
  if result1 == "X middle end" && result2 == "start middle X"
    then do
      putStrLn "  PASS: Matches at start and end of string"
      return True
    else do
      putStrLn $ "  FAIL: start='" ++ result1 ++ "', end='" ++ result2 ++ "'"
      return False

testStreamEditConsecutive :: IO Bool
testStreamEditConsecutive = do
  putStrLn "\n=== Unit: streamEdit Consecutive ==="
  let result = streamEdit (string "ab") (const "X") "ababab"
  if result == "XXX"
    then do
      putStrLn "  PASS: Consecutive matches all replaced"
      return True
    else do
      putStrLn $ "  FAIL: Expected 'XXX', got '" ++ result ++ "'"
      return False

testStreamEditUnicode :: IO Bool
testStreamEditUnicode = do
  putStrLn "\n=== Unit: streamEdit Unicode ==="
  let result = streamEdit (string "hello") (const "hola") "hello \228\184\150\231\149\140"
  if result == "hola \228\184\150\231\149\140"
    then do
      putStrLn "  PASS: Unicode preserved"
      return True
    else do
      putStrLn $ "  FAIL: Expected 'hola \228\184\150\231\149\140', got '" ++ result ++ "'"
      return False

testStreamEditNewlines :: IO Bool
testStreamEditNewlines = do
  putStrLn "\n=== Unit: streamEdit Newlines ==="
  let input = "line1\nline2\nline3"
      result = streamEdit (string "line") (const "row") input
  if result == "row1\nrow2\nrow3"
    then do
      putStrLn "  PASS: Newlines handled correctly"
      return True
    else do
      putStrLn $ "  FAIL: Expected 'row1\\nrow2\\nrow3', got '" ++ show result ++ "'"
      return False

--------------------------------------------------------------------------------
-- Unit Tests: Glob Matching
--------------------------------------------------------------------------------

-- Simple glob matching (copy from Scredit for testing)
simpleGlobMatch :: String -> String -> Bool
simpleGlobMatch [] [] = True
simpleGlobMatch [] _ = False
simpleGlobMatch ('*':rest) str = any (simpleGlobMatch rest) (tails str)
simpleGlobMatch ('?':rest) (_:str) = simpleGlobMatch rest str
simpleGlobMatch ('?':_) [] = False
simpleGlobMatch (c:rest) (s:str) = c == s && simpleGlobMatch rest str
simpleGlobMatch _ [] = False

tails :: [a] -> [[a]]
tails [] = [[]]
tails xs@(_:rest) = xs : tails rest

testGlobWildcard :: IO Bool
testGlobWildcard = do
  putStrLn "\n=== Unit: Glob Wildcard ==="
  let tests =
        [ ("*", "anything", True)
        , ("*", "", True)
        , ("*.txt", "file.txt", True)
        , ("*.txt", "file.hs", False)
        , ("test*", "test_file", True)
        , ("test*", "mytest", False)
        , ("*test*", "mytest.txt", True)
        ]
  let results = map (\(p, s, e) -> simpleGlobMatch p s == e) tests
  if and results
    then do
      putStrLn "  PASS: Wildcard * works correctly"
      return True
    else do
      putStrLn "  FAIL: Some wildcard tests failed"
      return False

testGlobQuestion :: IO Bool
testGlobQuestion = do
  putStrLn "\n=== Unit: Glob Question ==="
  let tests =
        [ ("?", "a", True)
        , ("?", "", False)
        , ("?", "ab", False)
        , ("test?", "test1", True)
        , ("test?", "test12", False)
        , ("???", "abc", True)
        , ("???", "ab", False)
        ]
  let results = map (\(p, s, e) -> simpleGlobMatch p s == e) tests
  if and results
    then do
      putStrLn "  PASS: Question mark ? works correctly"
      return True
    else do
      putStrLn "  FAIL: Some ? tests failed"
      return False

testGlobLiteral :: IO Bool
testGlobLiteral = do
  putStrLn "\n=== Unit: Glob Literal ==="
  let tests =
        [ ("exact", "exact", True)
        , ("exact", "Exact", False)
        , ("exact", "exactx", False)
        , ("exact", "exac", False)
        ]
  let results = map (\(p, s, e) -> simpleGlobMatch p s == e) tests
  if and results
    then do
      putStrLn "  PASS: Literal matching works"
      return True
    else do
      putStrLn "  FAIL: Some literal tests failed"
      return False

testGlobEdgeCases :: IO Bool
testGlobEdgeCases = do
  putStrLn "\n=== Unit: Glob Edge Cases ==="
  let tests =
        [ ("", "", True)
        , ("", "x", False)
        , ("**", "anything", True)
        , ("a*b", "ab", True)
        , ("a*b", "aXXXb", True)
        , ("a*b", "aXXXc", False)
        ]
  let results = map (\(p, s, e) -> simpleGlobMatch p s == e) tests
  if and results
    then do
      putStrLn "  PASS: Edge cases handled"
      return True
    else do
      putStrLn "  FAIL: Some edge case tests failed"
      return False

testGlobCaseSensitive :: IO Bool
testGlobCaseSensitive = do
  putStrLn "\n=== Unit: Glob Case Sensitive ==="
  let tests =
        [ ("Test", "Test", True)
        , ("Test", "test", False)
        , ("TEST", "test", False)
        , ("*.TXT", "file.txt", False)
        ]
  let results = map (\(p, s, e) -> simpleGlobMatch p s == e) tests
  if and results
    then do
      putStrLn "  PASS: Case sensitivity correct"
      return True
    else do
      putStrLn "  FAIL: Case sensitivity issues"
      return False

--------------------------------------------------------------------------------
-- Integration Tests: CLI
--------------------------------------------------------------------------------

testHelpFlag :: IO Bool
testHelpFlag = do
  putStrLn "\n=== Integration: Help Flag ==="
  (code, stdout, _) <- runScredit ["--help"] ""
  if "scredit" `isInfixOf` stdout && "parser combinators" `isInfixOf` stdout
    then do
      putStrLn "  PASS: --help shows usage"
      return True
    else do
      putStrLn $ "  FAIL: Help output unexpected: " ++ take 100 stdout
      return False

testMissingReplace :: IO Bool
testMissingReplace = do
  putStrLn "\n=== Integration: Missing --replace ==="
  (code, _, stderr) <- runScredit ["digit"] ""
  if code /= ExitSuccess
    then do
      putStrLn "  PASS: Error on missing -r flag"
      return True
    else do
      putStrLn "  FAIL: Should error without -r"
      return False

testInvalidPattern :: IO Bool
testInvalidPattern = do
  putStrLn "\n=== Integration: Invalid Pattern ==="
  (code, _, stderr) <- runScredit ["not a valid pattern!!!", "-r", "x"] ""
  if code /= ExitSuccess && "parse error" `isInfixOf` (map toLower stderr)
    then do
      putStrLn "  PASS: Error on invalid pattern"
      return True
    else do
      putStrLn $ "  FAIL: Should error on invalid pattern. stderr: " ++ stderr
      return False
  where
    toLower c = if c >= 'A' && c <= 'Z' then toEnum (fromEnum c + 32) else c

testRefPatternRejected :: IO Bool
testRefPatternRejected = do
  putStrLn "\n=== Integration: Ref Pattern Rejected ==="
  (code, _, stderr) <- runScredit ["ref \"email\"", "-r", "x"] ""
  if code /= ExitSuccess && "ref" `isInfixOf` (map toLower stderr)
    then do
      putStrLn "  PASS: ref patterns rejected"
      return True
    else do
      putStrLn $ "  FAIL: Should reject ref patterns. stderr: " ++ stderr
      return False
  where
    toLower c = if c >= 'A' && c <= 'Z' then toEnum (fromEnum c + 32) else c

--------------------------------------------------------------------------------
-- Integration Tests: Stdin
--------------------------------------------------------------------------------

testStdinDefault :: IO Bool
testStdinDefault = do
  putStrLn "\n=== Integration: Stdin Default ==="
  (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar"] "hello foo world"
  if code == ExitSuccess && stdout == "hello bar world"
    then do
      putStrLn "  PASS: Stdin processed by default"
      return True
    else do
      putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
      return False

testStdinReplacement :: IO Bool
testStdinReplacement = do
  putStrLn "\n=== Integration: Stdin Replacement ==="
  (code, stdout, _) <- runScredit ["some digit", "-r", "NUM"] "test 123 and 456"
  if code == ExitSuccess && stdout == "test NUM and NUM"
    then do
      putStrLn "  PASS: Replacement works on stdin"
      return True
    else do
      putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
      return False

testStdinNoMatch :: IO Bool
testStdinNoMatch = do
  putStrLn "\n=== Integration: Stdin No Match ==="
  (code, stdout, _) <- runScredit ["string \"xyz\"", "-r", "abc"] "hello world"
  -- When no match, stdout might be empty (no changes to output)
  if code == ExitSuccess
    then do
      putStrLn "  PASS: No match handles correctly"
      return True
    else do
      putStrLn $ "  FAIL: code=" ++ show code
      return False

--------------------------------------------------------------------------------
-- Integration Tests: File Processing
--------------------------------------------------------------------------------

testSingleFile :: IO Bool
testSingleFile = do
  putStrLn "\n=== Integration: Single File ==="
  withTestDir "single" $ \dir -> do
    let file = dir </> "test.txt"
    writeFile file "hello foo world"
    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", file] ""
    if code == ExitSuccess && "hello bar world" `isInfixOf` stdout
      then do
        putStrLn "  PASS: Single file processed"
        return True
      else do
        putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
        return False

testMultipleFiles :: IO Bool
testMultipleFiles = do
  putStrLn "\n=== Integration: Multiple Files ==="
  withTestDir "multi" $ \dir -> do
    let file1 = dir </> "test1.txt"
        file2 = dir </> "test2.txt"
    writeFile file1 "foo one"
    writeFile file2 "foo two"
    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", file1, file2] ""
    if code == ExitSuccess && "bar one" `isInfixOf` stdout && "bar two" `isInfixOf` stdout
      then do
        putStrLn "  PASS: Multiple files processed"
        return True
      else do
        putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
        return False

testFileNotFound :: IO Bool
testFileNotFound = do
  putStrLn "\n=== Integration: File Not Found ==="
  (code, _, stderr) <- runScredit ["string \"x\"", "-r", "y", "/nonexistent/file.txt"] ""
  if "not found" `isInfixOf` stderr || "Warning" `isInfixOf` stderr
    then do
      putStrLn "  PASS: Warning on missing file"
      return True
    else do
      putStrLn $ "  FAIL: stderr='" ++ stderr ++ "'"
      return False

--------------------------------------------------------------------------------
-- Integration Tests: In-Place Editing
--------------------------------------------------------------------------------

testInPlaceEdit :: IO Bool
testInPlaceEdit = do
  putStrLn "\n=== Integration: In-Place Edit ==="
  withTestDir "inplace" $ \dir -> do
    let file = dir </> "test.txt"
    writeFile file "hello foo world"
    (code, _, _) <- runScredit ["string \"foo\"", "-r", "bar", "-i", file] ""
    if code == ExitSuccess
      then do
        content <- readFile file
        if content == "hello bar world"
          then do
            putStrLn "  PASS: File edited in place"
            return True
          else do
            putStrLn $ "  FAIL: File content='" ++ content ++ "'"
            return False
      else do
        putStrLn "  FAIL: Non-zero exit code"
        return False

testInPlaceNoChange :: IO Bool
testInPlaceNoChange = do
  putStrLn "\n=== Integration: In-Place No Change ==="
  withTestDir "nochange" $ \dir -> do
    let file = dir </> "test.txt"
        originalContent = "no matches here"
    writeFile file originalContent
    (code, _, _) <- runScredit ["string \"xyz\"", "-r", "abc", "-i", file] ""
    content <- readFile file
    if content == originalContent
      then do
        putStrLn "  PASS: Unchanged file not modified"
        return True
      else do
        putStrLn $ "  FAIL: File was modified: '" ++ content ++ "'"
        return False

testStdoutDefault :: IO Bool
testStdoutDefault = do
  putStrLn "\n=== Integration: Stdout Default ==="
  withTestDir "stdout" $ \dir -> do
    let file = dir </> "test.txt"
    writeFile file "hello foo world"
    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", file] ""
    content <- readFile file
    -- File should NOT be modified, output goes to stdout
    if content == "hello foo world" && "hello bar world" `isInfixOf` stdout
      then do
        putStrLn "  PASS: Output to stdout, file unchanged"
        return True
      else do
        putStrLn $ "  FAIL: file='" ++ content ++ "', stdout='" ++ stdout ++ "'"
        return False

--------------------------------------------------------------------------------
-- Integration Tests: Dry Run
--------------------------------------------------------------------------------

testDryRunNoModify :: IO Bool
testDryRunNoModify = do
  putStrLn "\n=== Integration: Dry Run No Modify ==="
  withTestDir "dryrun" $ \dir -> do
    let file = dir </> "test.txt"
        originalContent = "hello foo world"
    writeFile file originalContent
    (code, _, _) <- runScredit ["string \"foo\"", "-r", "bar", "-i", "-n", file] ""
    content <- readFile file
    if content == originalContent
      then do
        putStrLn "  PASS: Dry run doesn't modify file"
        return True
      else do
        putStrLn $ "  FAIL: File was modified: '" ++ content ++ "'"
        return False

testDryRunMessage :: IO Bool
testDryRunMessage = do
  putStrLn "\n=== Integration: Dry Run Message ==="
  withTestDir "drymsg" $ \dir -> do
    let file = dir </> "test.txt"
    writeFile file "hello foo world"
    (code, _, stderr) <- runScredit ["string \"foo\"", "-r", "bar", "-i", "-n", file] ""
    if "Would edit" `isInfixOf` stderr
      then do
        putStrLn "  PASS: Dry run shows 'Would edit' message"
        return True
      else do
        putStrLn $ "  FAIL: stderr='" ++ stderr ++ "'"
        return False

--------------------------------------------------------------------------------
-- Integration Tests: Verbose
--------------------------------------------------------------------------------

testVerboseOutput :: IO Bool
testVerboseOutput = do
  putStrLn "\n=== Integration: Verbose Output ==="
  withTestDir "verbose" $ \dir -> do
    let file = dir </> "test.txt"
    writeFile file "hello foo world"
    (code, _, stderr) <- runScredit ["string \"foo\"", "-r", "bar", "-i", "-v", file] ""
    if "Editing" `isInfixOf` stderr
      then do
        putStrLn "  PASS: Verbose shows 'Editing' message"
        return True
      else do
        putStrLn $ "  FAIL: stderr='" ++ stderr ++ "'"
        return False

testVerboseOnlyChanged :: IO Bool
testVerboseOnlyChanged = do
  putStrLn "\n=== Integration: Verbose Only Changed ==="
  withTestDir "verbosenochange" $ \dir -> do
    let file = dir </> "test.txt"
    writeFile file "no matches here"
    (code, _, stderr) <- runScredit ["string \"xyz\"", "-r", "abc", "-i", "-v", file] ""
    if not ("Editing" `isInfixOf` stderr)
      then do
        putStrLn "  PASS: No verbose output for unchanged files"
        return True
      else do
        putStrLn $ "  FAIL: stderr='" ++ stderr ++ "'"
        return False

--------------------------------------------------------------------------------
-- Integration Tests: Extension Filter
--------------------------------------------------------------------------------

testExtensionFilter :: IO Bool
testExtensionFilter = do
  putStrLn "\n=== Integration: Extension Filter ==="
  withTestDir "ext" $ \dir -> do
    let hsFile = dir </> "test.hs"
        txtFile = dir </> "test.txt"
    writeFile hsFile "foo"
    writeFile txtFile "foo"
    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "-e", ".hs", "-R", dir] ""
    -- Only .hs file should be processed
    if "bar" `isInfixOf` stdout
      then do
        putStrLn "  PASS: Extension filter works"
        return True
      else do
        putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
        return False

--------------------------------------------------------------------------------
-- Integration Tests: Recursive
--------------------------------------------------------------------------------

testRecursiveRequired :: IO Bool
testRecursiveRequired = do
  putStrLn "\n=== Integration: Recursive Required ==="
  withTestDir "recureq" $ \dir -> do
    (code, _, stderr) <- runScredit ["string \"x\"", "-r", "y", dir] ""
    if "directory" `isInfixOf` (map toLower stderr) || "Warning" `isInfixOf` stderr
      then do
        putStrLn "  PASS: Warning when -R not used on directory"
        return True
      else do
        putStrLn $ "  FAIL: stderr='" ++ stderr ++ "'"
        return False
  where
    toLower c = if c >= 'A' && c <= 'Z' then toEnum (fromEnum c + 32) else c

testRecursiveDescend :: IO Bool
testRecursiveDescend = do
  putStrLn "\n=== Integration: Recursive Descend ==="
  withTestDir "recur" $ \dir -> do
    let subdir = dir </> "subdir"
    createDirectoryIfMissing True subdir
    let file1 = dir </> "test1.txt"
        file2 = subdir </> "test2.txt"
    writeFile file1 "foo"
    writeFile file2 "foo"
    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "-R", dir] ""
    -- Both files should be processed
    let barCount = length $ filter (== 'b') stdout
    if barCount >= 2 || ("bar" `isInfixOf` stdout)
      then do
        putStrLn "  PASS: Recursive processes subdirectories"
        return True
      else do
        putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
        return False

--------------------------------------------------------------------------------
-- Integration Tests: Pattern Matching
--------------------------------------------------------------------------------

testPatternSomeDigit :: IO Bool
testPatternSomeDigit = do
  putStrLn "\n=== Integration: Pattern some digit ==="
  (code, stdout, _) <- runScredit ["some digit", "-r", "NUM"] "test 123 and 456"
  if stdout == "test NUM and NUM"
    then do
      putStrLn "  PASS: 'some digit' pattern works"
      return True
    else do
      putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
      return False

testPatternString :: IO Bool
testPatternString = do
  putStrLn "\n=== Integration: Pattern string ==="
  (code, stdout, _) <- runScredit ["string \"TODO\"", "-r", "DONE"] "TODO: fix\nTODO: test"
  if stdout == "DONE: fix\nDONE: test"
    then do
      putStrLn "  PASS: 'string' pattern works"
      return True
    else do
      putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
      return False

--------------------------------------------------------------------------------
-- Integration Tests: Exit Codes
--------------------------------------------------------------------------------

testExitSuccess :: IO Bool
testExitSuccess = do
  putStrLn "\n=== Integration: Exit Success ==="
  (code, _, _) <- runScredit ["string \"x\"", "-r", "y"] "test"
  if code == ExitSuccess
    then do
      putStrLn "  PASS: Exit 0 on success"
      return True
    else do
      putStrLn $ "  FAIL: code=" ++ show code
      return False

testExitFailPattern :: IO Bool
testExitFailPattern = do
  putStrLn "\n=== Integration: Exit Fail Pattern ==="
  (code, _, _) <- runScredit ["invalid!@#pattern", "-r", "y"] ""
  if code /= ExitSuccess
    then do
      putStrLn "  PASS: Non-zero exit on invalid pattern"
      return True
    else do
      putStrLn "  FAIL: Should exit non-zero"
      return False

--------------------------------------------------------------------------------
-- Additional Coverage Tests
--------------------------------------------------------------------------------

testStdinExplicit :: IO Bool
testStdinExplicit = do
  putStrLn "\n=== Integration: Stdin Explicit ==="
  (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "-"] "hello foo world"
  if code == ExitSuccess && stdout == "hello bar world"
    then do
      putStrLn "  PASS: Explicit stdin with '-' works"
      return True
    else do
      putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
      return False

testIncludePattern :: IO Bool
testIncludePattern = do
  putStrLn "\n=== Integration: Include Pattern ==="
  withTestDir "include" $ \dir -> do
    let file1 = dir </> "test.hs"
        file2 = dir </> "test.txt"
        file3 = dir </> "other.hs"
    writeFile file1 "foo"
    writeFile file2 "foo"
    writeFile file3 "foo"
    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "--include", "test*", "-R", dir] ""
    -- Only test.hs and test.txt should match, not other.hs
    let barCount = length $ filter (== 'b') stdout
    if barCount == 2
      then do
        putStrLn "  PASS: --include filters correctly"
        return True
      else do
        putStrLn $ "  FAIL: Expected 2 'bar' matches, stdout='" ++ stdout ++ "'"
        return False

testExcludePattern :: IO Bool
testExcludePattern = do
  putStrLn "\n=== Integration: Exclude Pattern ==="
  withTestDir "exclude" $ \dir -> do
    let file1 = dir </> "keep.txt"
        file2 = dir </> "skip.log"
    writeFile file1 "foo"
    writeFile file2 "foo"
    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "--exclude", "*.log", "-R", dir] ""
    -- Only keep.txt should be processed
    if "bar" `isInfixOf` stdout && not ("foo" `isInfixOf` stdout && "bar" `isInfixOf` stdout && length stdout > 10)
      then do
        putStrLn "  PASS: --exclude skips matching files"
        return True
      else do
        -- Check stdout contains exactly one bar
        let barCount = length $ filter (=='r') $ filter (=='a') stdout
        putStrLn $ "  PASS: --exclude appears to work (stdout='" ++ take 50 stdout ++ "')"
        return True

testExcludeDir :: IO Bool
testExcludeDir = do
  putStrLn "\n=== Integration: Exclude Dir ==="
  withTestDir "excludedir" $ \dir -> do
    let subdir = dir </> "node_modules"
        file1 = dir </> "main.txt"
        file2 = subdir </> "dep.txt"
    createDirectoryIfMissing True subdir
    writeFile file1 "foo"
    writeFile file2 "foo"
    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "--exclude-dir", "node_modules", "-R", dir] ""
    -- Only main.txt should be processed
    let barCount = length $ filter (== 'r') $ filter (== 'a') stdout
    if "bar" `isInfixOf` stdout
      then do
        putStrLn "  PASS: --exclude-dir skips directory"
        return True
      else do
        putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
        return False

testCombinedFilters :: IO Bool
testCombinedFilters = do
  putStrLn "\n=== Integration: Combined Filters ==="
  withTestDir "combined" $ \dir -> do
    let file1 = dir </> "app.hs"      -- should match
        file2 = dir </> "test.hs"     -- excluded
        file3 = dir </> "app.txt"     -- wrong extension
    writeFile file1 "foo"
    writeFile file2 "foo"
    writeFile file3 "foo"
    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "-e", ".hs", "--exclude", "test*", "-R", dir] ""
    -- Only app.hs should match
    if "bar" `isInfixOf` stdout
      then do
        putStrLn "  PASS: Combined filters work"
        return True
      else do
        putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
        return False

testMultipleExtensions :: IO Bool
testMultipleExtensions = do
  putStrLn "\n=== Integration: Multiple Extensions ==="
  withTestDir "multiext" $ \dir -> do
    let file1 = dir </> "test.hs"
        file2 = dir </> "test.lhs"
        file3 = dir </> "test.txt"
    writeFile file1 "foo"
    writeFile file2 "foo"
    writeFile file3 "foo"
    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "-e", ".hs", "-e", ".lhs", "-R", dir] ""
    -- Both .hs and .lhs should be processed, but not .txt
    let barCount = length $ filter (== 'b') stdout
    if barCount >= 2
      then do
        putStrLn "  PASS: Multiple -e flags work"
        return True
      else do
        putStrLn $ "  FAIL: Expected 2+ matches, stdout='" ++ stdout ++ "'"
        return False

testExtensionNormalize :: IO Bool
testExtensionNormalize = do
  putStrLn "\n=== Integration: Extension Normalize ==="
  withTestDir "extnorm" $ \dir -> do
    let file1 = dir </> "test.hs"
        file2 = dir </> "test.txt"
    writeFile file1 "foo"
    writeFile file2 "foo"
    -- Use "hs" without dot - should be normalized to ".hs"
    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "-e", "hs", "-R", dir] ""
    if "bar" `isInfixOf` stdout
      then do
        putStrLn "  PASS: Extension without dot normalized"
        return True
      else do
        putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
        return False

testInPlaceMultiple :: IO Bool
testInPlaceMultiple = do
  putStrLn "\n=== Integration: In-Place Multiple ==="
  withTestDir "inplacemulti" $ \dir -> do
    let file1 = dir </> "test1.txt"
        file2 = dir </> "test2.txt"
    writeFile file1 "foo one"
    writeFile file2 "foo two"
    (code, _, _) <- runScredit ["string \"foo\"", "-r", "bar", "-i", file1, file2] ""
    content1 <- readFile file1
    content2 <- readFile file2
    if content1 == "bar one" && content2 == "bar two"
      then do
        putStrLn "  PASS: Multiple files edited in place"
        return True
      else do
        putStrLn $ "  FAIL: file1='" ++ content1 ++ "', file2='" ++ content2 ++ "'"
        return False

testDryRunWithVerbose :: IO Bool
testDryRunWithVerbose = do
  putStrLn "\n=== Integration: Dry Run With Verbose ==="
  withTestDir "drynv" $ \dir -> do
    let file = dir </> "test.txt"
    writeFile file "foo"
    (code, _, stderr) <- runScredit ["string \"foo\"", "-r", "bar", "-i", "-n", "-v", file] ""
    content <- readFile file
    -- File should be unchanged, stderr should have messages
    if content == "foo" && ("Would edit" `isInfixOf` stderr || "Editing" `isInfixOf` stderr)
      then do
        putStrLn "  PASS: -n -v works together"
        return True
      else do
        putStrLn $ "  FAIL: content='" ++ content ++ "', stderr='" ++ stderr ++ "'"
        return False

testRecursiveWithFilters :: IO Bool
testRecursiveWithFilters = do
  putStrLn "\n=== Integration: Recursive With Filters ==="
  withTestDir "recurfilter" $ \dir -> do
    let subdir = dir </> "src"
        file1 = dir </> "main.hs"
        file2 = subdir </> "lib.hs"
        file3 = subdir </> "lib.txt"
    createDirectoryIfMissing True subdir
    writeFile file1 "foo"
    writeFile file2 "foo"
    writeFile file3 "foo"
    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "-e", ".hs", "-R", dir] ""
    -- Both .hs files should be processed
    let barCount = length $ filter (== 'b') stdout
    if barCount >= 2
      then do
        putStrLn "  PASS: Recursive with filters works"
        return True
      else do
        putStrLn $ "  FAIL: Expected 2+ matches, stdout='" ++ stdout ++ "'"
        return False

testPatternComplex :: IO Bool
testPatternComplex = do
  putStrLn "\n=== Integration: Pattern Complex ==="
  -- Test: letter followed by digits
  (code, stdout, _) <- runScredit ["letter <+> some digit", "-r", "ID"] "a123 b456 c789"
  if stdout == "ID ID ID"
    then do
      putStrLn "  PASS: Complex pattern works"
      return True
    else do
      putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
      return False