packages feed

dojang-0.2.0: test/Dojang/Types/ContextSpec.hs

{-# LANGUAGE DisambiguateRecordFields #-}
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}

module Dojang.Types.ContextSpec (spec) where

import Data.List (isInfixOf, sort, sortOn)
import System.IO.Error
  ( ioeGetErrorString
  , ioeGetErrorType
  , ioeGetFileName
  , ioeGetLocation
  )
import Prelude hiding (readFile, writeFile)

import Data.ByteString qualified (length)
import Data.List.NonEmpty qualified as NE
import Data.Map.Strict (Map, fromList)
import System.FilePath (combine)
import System.OsPath (OsPath, encodeFS, (</>))
import Test.Hspec (Spec, describe, it, runIO, specify)
import Test.Hspec.Expectations.Pretty (shouldBe, shouldReturn, shouldThrow)

import Dojang.MonadFileSystem (MonadFileSystem (..))
import Dojang.MonadFileSystem qualified (FileType (..))
import Dojang.Syntax.Manifest.Writer (writeManifestFile)
import Dojang.TestUtils (Entry (..), makeFixtureTree, withTempDir)
import Dojang.Types.Context
  ( CandidateRoute (..)
  , Context (..)
  , FileCorrespondence (..)
  , FileDeltaKind (..)
  , FileEntry (..)
  , FileStat (..)
  , RouteMatch (..)
  , RouteState (..)
  , calculateSpecificity
  , filterBySpecificity
  , findMatchingRoutes
  , getRouteState
  , listFiles
  , makeCorrespond
  , makeCorrespondBetweenThreeDirs
  , makeCorrespondBetweenThreeFiles
  , makeCorrespondBetweenTwoDirs
  , makeCorrespondWithDestination
  , routePaths
  )
import Dojang.Types.Environment
  ( Architecture (..)
  , Environment (..)
  , Kernel (..)
  , OperatingSystem (..)
  )
import Dojang.Types.EnvironmentPredicate.Evaluate (EvaluationWarning (..))
import Dojang.Types.FilePathExpression (FilePathExpression (..), (+/+))
import Dojang.Types.FileRouteSpec (monikerMap)
import Dojang.Types.Manifest (manifest)
import Dojang.Types.MonikerName (MonikerName, parseMonikerName)
import Dojang.Types.Repository
  ( Repository (..)
  , RouteMapWarning (..)
  , RouteResult (..)
  )
import GHC.IO.Exception (IOErrorType (InappropriateType))


spec :: Spec
spec = do
  src <- runIO $ encodeFS "src"
  inter <- runIO $ encodeFS "inter"
  dst <- runIO $ encodeFS "dst"
  foo <- runIO $ encodeFS "foo"
  bar <- runIO $ encodeFS "bar"
  baz <- runIO $ encodeFS "baz"
  qux <- runIO $ encodeFS "qux"
  -- cSpell:ignore quux corge
  quux <- runIO $ encodeFS "quux"
  corge <- runIO $ encodeFS "corge"
  intermediateDir <- runIO $ encodeFS ".dojang"
  manifestFilename' <- runIO $ encodeFS "dojang.toml"

  let Right posix = parseMonikerName "posix"
  let Right undefined' = parseMonikerName "undefined"
  let Right windows = parseMonikerName "windows"

  let fileRoutes =
        [
          ( baz
          ,
            [
              ( undefined'
              , Just $ Root Nothing +/+ BareComponent "__dojang_test_baz__"
              )
            , (windows, Just $ Substitution "BAZ")
            ]
          )
        ]
          :: Map OsPath [(MonikerName, Maybe FilePathExpression)]
  let dirRoutes =
        [ (foo, [(posix, Just $ Substitution "FOO"), (windows, Nothing)])
        ,
          ( bar
          ,
            [ (posix, Just $ Substitution "BAR")
            , (windows, Just $ Root $ Just 'B')
            ]
          )
        ]
          :: Map OsPath [(MonikerName, Maybe FilePathExpression)]

  let manifest' =
        Dojang.Types.Manifest.manifest
          monikerMap
          fileRoutes
          dirRoutes
          [(foo, ["ignore-*"])]
          mempty

  let repositoryFixture repoPath = do
        -- The tree of the repository fixture looks like this:
        -- .
        -- ├── bar/
        -- │   └── foo
        -- ├── baz
        -- ├── dojang.toml
        -- └── foo/
        --     ├── bar/
        --     │   └── foo
        --     ├── baz/
        --     └── foo
        -- Total 4 directories and 5 files
        writeManifestFile manifest' $ repoPath </> manifestFilename'
        createDirectory $ repoPath </> foo
        writeFile (repoPath </> foo </> foo) "asdf"
        createDirectory $ repoPath </> foo </> bar
        writeFile (repoPath </> foo </> bar </> foo) "asdf asdf"
        createDirectory $ repoPath </> foo </> baz
        createDirectory $ repoPath </> bar
        writeFile (repoPath </> bar </> foo) "foobar"
        writeFile (repoPath </> baz) "foo bar baz"
        let repo =
              Repository
                repoPath
                (repoPath </> intermediateDir)
                manifest'
        return repo

  let withContextFixture action = withTempDir $ \tmpDir _ -> do
        createDirectory $ tmpDir </> src
        createDirectory $ tmpDir </> foo
        repo <- repositoryFixture $ tmpDir </> src
        let ctx = Context
              repo
              (Environment Linux X86_64 $ Kernel "Linux" "5.10.0-8")
              $ \e ->
                return $ case e of
                  "FOO" -> Just $ tmpDir </> foo
                  "BAR" -> Just $ tmpDir </> bar
                  "BAZ" -> Just $ tmpDir </> baz
                  _ -> Nothing
        action ctx tmpDir

  specify "routePaths" $ withContextFixture $ \ctx tmpDir -> do
    (results, ws) <- routePaths ctx
    ws `shouldBe` [EnvironmentPredicateWarning $ UndefinedMoniker undefined']
    sortOn (.sourcePath) results
      `shouldBe` [ RouteResult
                     { sourcePath = tmpDir </> src </> bar
                     , routeName = bar
                     , destinationPath = tmpDir </> bar
                     , fileType = Dojang.MonadFileSystem.Directory
                     }
                 , RouteResult
                     { sourcePath = tmpDir </> src </> foo
                     , routeName = foo
                     , destinationPath = tmpDir </> foo
                     , fileType = Dojang.MonadFileSystem.Directory
                     }
                 ]
  specify "makeCorrespondWithDestination" $
    withContextFixture $
      \ctx tmpDir -> do
        (correspond, ws) <-
          makeCorrespondWithDestination ctx (tmpDir </> bar </> foo)
        ws `shouldBe` [EnvironmentPredicateWarning $ UndefinedMoniker undefined']
        correspond
          `shouldBe` Just
            ( FileCorrespondence
                { source =
                    FileEntry
                      (tmpDir </> src </> bar </> foo)
                      (File 6)
                , sourceDelta = Added
                , intermediate =
                    FileEntry
                      (tmpDir </> src </> intermediateDir </> bar </> foo)
                      Missing
                , destination = FileEntry (tmpDir </> bar </> foo) Missing
                , destinationDelta = Unchanged
                }
            )
        let ctx' = ctx{environment = ctx.environment{operatingSystem = Windows}}
        (correspond', ws') <-
          makeCorrespondWithDestination ctx' (tmpDir </> baz)
        ws' `shouldBe` [EnvironmentPredicateWarning $ UndefinedMoniker undefined']
        correspond'
          `shouldBe` Just
            ( FileCorrespondence
                { source = FileEntry (tmpDir </> src </> baz) (File 11)
                , sourceDelta = Added
                , intermediate =
                    FileEntry (tmpDir </> src </> intermediateDir </> baz) Missing
                , destination = FileEntry (tmpDir </> baz) Missing
                , destinationDelta = Unchanged
                }
            )
        (correspond'', ws'') <-
          makeCorrespondWithDestination ctx (tmpDir </> corge)
        correspond'' `shouldBe` Nothing
        ws'' `shouldBe` ws

  specify "makeCorrespond" $ withContextFixture $ \ctx tmpDir -> do
    (corresponds, ws) <- makeCorrespond ctx
    ws `shouldBe` [EnvironmentPredicateWarning $ UndefinedMoniker undefined']
    sortOn (.source.path) corresponds
      `shouldBe` [ FileCorrespondence
                     { source =
                         FileEntry
                           (tmpDir </> src </> bar </> foo)
                           (File 6)
                     , sourceDelta = Added
                     , intermediate =
                         FileEntry
                           (tmpDir </> src </> intermediateDir </> bar </> foo)
                           Missing
                     , destination = FileEntry (tmpDir </> bar </> foo) Missing
                     , destinationDelta = Unchanged
                     }
                 , FileCorrespondence
                     { source =
                         FileEntry
                           (tmpDir </> src </> foo </> bar)
                           Directory
                     , sourceDelta = Added
                     , intermediate =
                         FileEntry
                           (tmpDir </> src </> intermediateDir </> foo </> bar)
                           Missing
                     , destination = FileEntry (tmpDir </> foo </> bar) Missing
                     , destinationDelta = Unchanged
                     }
                 , FileCorrespondence
                     { source =
                         FileEntry
                           (tmpDir </> src </> foo </> bar </> foo)
                           (File 9)
                     , sourceDelta = Added
                     , intermediate =
                         FileEntry
                           ( tmpDir
                               </> src
                               </> intermediateDir
                               </> foo
                               </> bar
                               </> foo
                           )
                           Missing
                     , destination =
                         FileEntry (tmpDir </> foo </> bar </> foo) Missing
                     , destinationDelta = Unchanged
                     }
                 , FileCorrespondence
                     { source =
                         FileEntry (tmpDir </> src </> foo </> baz) Directory
                     , sourceDelta = Added
                     , intermediate =
                         FileEntry
                           (tmpDir </> src </> intermediateDir </> foo </> baz)
                           Missing
                     , destination = FileEntry (tmpDir </> foo </> baz) Missing
                     , destinationDelta = Unchanged
                     }
                 , FileCorrespondence
                     { source =
                         FileEntry (tmpDir </> src </> foo </> foo) (File 4)
                     , sourceDelta = Added
                     , intermediate =
                         FileEntry
                           (tmpDir </> src </> intermediateDir </> foo </> foo)
                           Missing
                     , destination = FileEntry (tmpDir </> foo </> foo) Missing
                     , destinationDelta = Unchanged
                     }
                 ]

  describe "listFiles" $ do
    it "lists files recursively" $ withTempDir $ \tmpDir _ -> do
      _ <- repositoryFixture tmpDir
      files <- listFiles tmpDir []
      manifestData <- readFile $ tmpDir </> manifestFilename'
      let manifestSize = toEnum $ Data.ByteString.length manifestData
      sort files
        `shouldBe` [ FileEntry bar Directory
                   , FileEntry (bar </> foo) $ File 6
                   , FileEntry baz $ File 11
                   , FileEntry manifestFilename' $ File manifestSize
                   , FileEntry foo Directory
                   , FileEntry (foo </> bar) Directory
                   , FileEntry (foo </> bar </> foo) $ File 9
                   , FileEntry (foo </> baz) Directory
                   , FileEntry (foo </> foo) $ File 4
                   ]

    it "filters out files by ignorePatterns" $ withTempDir $ \tmpDir _ -> do
      _ <- repositoryFixture tmpDir
      files <- listFiles tmpDir ["foo/b*"]
      manifestData <- readFile $ tmpDir </> manifestFilename'
      let manifestSize = toEnum $ Data.ByteString.length manifestData
      sort files
        `shouldBe` [ FileEntry bar Directory
                   , FileEntry (bar </> foo) $ File 6
                   , FileEntry baz $ File 11
                   , FileEntry manifestFilename' $ File manifestSize
                   , FileEntry foo Directory
                   , FileEntry (foo </> foo) $ File 4
                   ]

    it "only accepts a directory path" $ withTempDir $ \tmpDir tmpDir' -> do
      () <- writeFile (tmpDir </> foo) "foo"
      listFiles (tmpDir </> foo) [] `shouldThrow` \e ->
        (ioeGetErrorType e == InappropriateType)
          && (ioeGetLocation e == "listFiles")
          && (ioeGetFileName e == Just (tmpDir' `combine` "foo"))
          && ("not a directory" `isInfixOf` show e)

  specify "makeCorrespondBetweenThreeFiles" $ withTempDir $ \tmpDir _ -> do
    let makeCorrespond_ =
          makeCorrespondBetweenThreeFiles
            (tmpDir </> inter)
            (tmpDir </> src)
            (tmpDir </> dst)

    writeFile (tmpDir </> inter) "unchanged"
    writeFile (tmpDir </> src) "unchanged"
    writeFile (tmpDir </> dst) "unchanged"
    makeCorrespond_
      `shouldReturn` FileCorrespondence
        { source = FileEntry (tmpDir </> src) $ File 9
        , sourceDelta = Unchanged
        , intermediate = FileEntry (tmpDir </> inter) $ File 9
        , destination = FileEntry (tmpDir </> dst) $ File 9
        , destinationDelta = Unchanged
        }

    writeFile (tmpDir </> src) "modified!"
    writeFile (tmpDir </> dst) "modified"
    makeCorrespond_
      `shouldReturn` FileCorrespondence
        { source = FileEntry (tmpDir </> src) $ File 9
        , sourceDelta = Modified
        , intermediate = FileEntry (tmpDir </> inter) $ File 9
        , destination = FileEntry (tmpDir </> dst) $ File 8
        , destinationDelta = Modified
        }

    removeFile (tmpDir </> inter)
    writeFile (tmpDir </> src) "added"
    writeFile (tmpDir </> dst) "added"
    makeCorrespond_
      `shouldReturn` FileCorrespondence
        { source = FileEntry (tmpDir </> src) $ File 5
        , sourceDelta = Added
        , intermediate = FileEntry (tmpDir </> inter) Missing
        , destination = FileEntry (tmpDir </> dst) $ File 5
        , destinationDelta = Added
        }

    writeFile (tmpDir </> inter) "removed"
    removeFile (tmpDir </> src)
    removeFile (tmpDir </> dst)
    makeCorrespond_
      `shouldReturn` FileCorrespondence
        { source = FileEntry (tmpDir </> src) Missing
        , sourceDelta = Removed
        , intermediate = FileEntry (tmpDir </> inter) $ File 7
        , destination = FileEntry (tmpDir </> dst) Missing
        , destinationDelta = Removed
        }

    writeFile (tmpDir </> inter) "dir is disallowed"
    createDirectory (tmpDir </> src)
    filename <- decodePath $ tmpDir </> src
    makeCorrespond_ `shouldThrow` \e ->
      ( ioeGetErrorString e
          == "makeCorrespondBetweenThreeFiles: "
            ++ "expected a file, but got a directory"
      )
        && (ioeGetFileName e == Just filename)

  specify "makeCorrespondBetweenThreeDirs" $ withTempDir $ \tmpDir _ -> do
    () <-
      makeFixtureTree
        (tmpDir </> foo)
        [ (inter, D [])
        , (src, D [])
        , (dst, D [])
        ]
    emptyCorresponds <-
      makeCorrespondBetweenThreeDirs
        (tmpDir </> foo </> inter)
        (tmpDir </> foo </> src)
        (tmpDir </> foo </> dst)
        []
    sortOn (.source.path) emptyCorresponds `shouldBe` []

    unchanged <- encodePath "unchanged"
    added <- encodePath "added"
    removed <- encodePath "removed"
    modified <- encodePath "modified"
    ignored <- encodePath "ignored"
    () <-
      makeFixtureTree
        (tmpDir </> bar)
        [
          ( inter
          , D
              [ (unchanged, D [(foo, F "foo-unchanged")])
              , (added, D [])
              ,
                ( removed
                , D
                    [ (foo, F "foo-src-removed")
                    , (bar, F "bar-dst-removed")
                    , (baz, F "baz-both-removed")
                    , (qux, D [])
                    ]
                )
              ,
                ( modified
                , D
                    [ (foo, F "foo")
                    , (bar, F "bar")
                    , (baz, F "baz")
                    , (qux, F "qux-file-to-dir")
                    , (quux, D [])
                    , (corge, F "same-size-but-different-content")
                    ]
                )
              ,
                ( ignored
                , D
                    [ (foo, F "foo-ignored-but-tracked")
                    , (baz, F "baz-ignored-but-indexed")
                    ]
                )
              ]
          )
        ,
          ( src
          , D
              [ (unchanged, D [(foo, F "foo-unchanged")])
              ,
                ( added
                , D
                    [ (foo, F "foo-src-added")
                    , (baz, F "baz-both-added")
                    , (qux, D [])
                    ]
                )
              , (removed, D [(bar, F "bar-dst-removed")])
              ,
                ( modified
                , D
                    [ (foo, F "foo-src-modified")
                    , (bar, F "bar")
                    , (baz, F "baz-src-modified")
                    , (qux, D [])
                    , (quux, F "qux-dir-to-file")
                    , (corge, F "same-length-but--different-body")
                    ]
                )
              ,
                ( ignored
                , D
                    [ (foo, F "foo-ignored-but-tracked")
                    , (bar, F "bar-ignored-but-added")
                    ]
                )
              ]
          )
        ,
          ( dst
          , D
              [ (unchanged, D [(foo, F "foo-unchanged")])
              ,
                ( added
                , D
                    [ (bar, F "bar-dst-added")
                    , (baz, F "baz-both-added")
                    , (qux, D [])
                    ]
                )
              , (removed, D [(foo, F "foo-src-removed")])
              ,
                ( modified
                , D
                    [ (foo, F "foo")
                    , (bar, F "bar-dst-modified")
                    , (baz, F "baz-dst-modified")
                    , (qux, F "qux-file-to-dir")
                    , (quux, D [])
                    , (corge, F "equivalent-length-but-different")
                    ]
                )
              ,
                ( ignored
                , D
                    [ (foo, F "foo-ignored-but-tracked")
                    , (bar, F "bar-ignored-but-added")
                    , (baz, F "baz-ignored-but-indexed")
                    ]
                )
              ]
          )
        ]
    corresponds <-
      makeCorrespondBetweenThreeDirs
        (tmpDir </> bar </> inter)
        (tmpDir </> bar </> src)
        (tmpDir </> bar </> dst)
        ["ignored"]
    sortOn (.source.path) corresponds
      `shouldBe` [ FileCorrespondence
                     { source = FileEntry added Directory
                     , sourceDelta = Unchanged
                     , intermediate = FileEntry added Directory
                     , destination = FileEntry added Directory
                     , destinationDelta = Unchanged
                     }
                 , FileCorrespondence
                     { source = FileEntry (added </> bar) Missing
                     , sourceDelta = Unchanged
                     , intermediate = FileEntry (added </> bar) Missing
                     , destination = FileEntry (added </> bar) (File 13)
                     , destinationDelta = Added
                     }
                 , FileCorrespondence
                     { source = FileEntry (added </> baz) (File 14)
                     , sourceDelta = Added
                     , intermediate = FileEntry (added </> baz) Missing
                     , destination = FileEntry (added </> baz) (File 14)
                     , destinationDelta = Added
                     }
                 , FileCorrespondence
                     { source = FileEntry (added </> foo) (File 13)
                     , sourceDelta = Added
                     , intermediate = FileEntry (added </> foo) Missing
                     , destination = FileEntry (added </> foo) Missing
                     , destinationDelta = Unchanged
                     }
                 , FileCorrespondence
                     { source = FileEntry (added </> qux) Directory
                     , sourceDelta = Added
                     , intermediate = FileEntry (added </> qux) Missing
                     , destination = FileEntry (added </> qux) Directory
                     , destinationDelta = Added
                     }
                 , FileCorrespondence
                     { source = FileEntry ignored Directory
                     , sourceDelta = Unchanged
                     , intermediate = FileEntry ignored Directory
                     , destination = FileEntry ignored Directory
                     , destinationDelta = Unchanged
                     }
                 , FileCorrespondence
                     { source = FileEntry (ignored </> bar) (File 21)
                     , sourceDelta = Added
                     , intermediate = FileEntry (ignored </> bar) Missing
                     , destination = FileEntry (ignored </> bar) (File 21)
                     , destinationDelta = Added
                     }
                 , FileCorrespondence
                     { source = FileEntry (ignored </> baz) Missing
                     , sourceDelta = Removed
                     , intermediate = FileEntry (ignored </> baz) (File 23)
                     , destination = FileEntry (ignored </> baz) (File 23)
                     , destinationDelta = Unchanged
                     }
                 , FileCorrespondence
                     { source = FileEntry (ignored </> foo) (File 23)
                     , sourceDelta = Unchanged
                     , intermediate = FileEntry (ignored </> foo) (File 23)
                     , destination = FileEntry (ignored </> foo) (File 23)
                     , destinationDelta = Unchanged
                     }
                 , FileCorrespondence
                     { source = FileEntry modified Directory
                     , sourceDelta = Unchanged
                     , intermediate = FileEntry modified Directory
                     , destination = FileEntry modified Directory
                     , destinationDelta = Unchanged
                     }
                 , FileCorrespondence
                     { source = FileEntry (modified </> bar) (File 3)
                     , sourceDelta = Unchanged
                     , intermediate = FileEntry (modified </> bar) (File 3)
                     , destination = FileEntry (modified </> bar) (File 16)
                     , destinationDelta = Modified
                     }
                 , FileCorrespondence
                     { source = FileEntry (modified </> baz) (File 16)
                     , sourceDelta = Modified
                     , intermediate = FileEntry (modified </> baz) (File 3)
                     , destination = FileEntry (modified </> baz) (File 16)
                     , destinationDelta = Modified
                     }
                 , FileCorrespondence
                     { source = FileEntry (modified </> corge) (File 31)
                     , sourceDelta = Modified
                     , intermediate = FileEntry (modified </> corge) (File 31)
                     , destination = FileEntry (modified </> corge) (File 31)
                     , destinationDelta = Modified
                     }
                 , FileCorrespondence
                     { source = FileEntry (modified </> foo) (File 16)
                     , sourceDelta = Modified
                     , intermediate = FileEntry (modified </> foo) (File 3)
                     , destination = FileEntry (modified </> foo) (File 3)
                     , destinationDelta = Unchanged
                     }
                 , FileCorrespondence
                     { source = FileEntry (modified </> quux) (File 15)
                     , sourceDelta = Modified
                     , intermediate = FileEntry (modified </> quux) Directory
                     , destination = FileEntry (modified </> quux) Directory
                     , destinationDelta = Unchanged
                     }
                 , FileCorrespondence
                     { source = FileEntry (modified </> qux) Directory
                     , sourceDelta = Modified
                     , intermediate = FileEntry (modified </> qux) (File 15)
                     , destination = FileEntry (modified </> qux) (File 15)
                     , destinationDelta = Unchanged
                     }
                 , FileCorrespondence
                     { source = FileEntry removed Directory
                     , sourceDelta = Unchanged
                     , intermediate = FileEntry removed Directory
                     , destination = FileEntry removed Directory
                     , destinationDelta = Unchanged
                     }
                 , FileCorrespondence
                     { source = FileEntry (removed </> bar) (File 15)
                     , sourceDelta = Unchanged
                     , intermediate = FileEntry (removed </> bar) (File 15)
                     , destination = FileEntry (removed </> bar) Missing
                     , destinationDelta = Removed
                     }
                 , FileCorrespondence
                     { source = FileEntry (removed </> baz) Missing
                     , sourceDelta = Removed
                     , intermediate = FileEntry (removed </> baz) (File 16)
                     , destination = FileEntry (removed </> baz) Missing
                     , destinationDelta = Removed
                     }
                 , FileCorrespondence
                     { source = FileEntry (removed </> foo) Missing
                     , sourceDelta = Removed
                     , intermediate = FileEntry (removed </> foo) (File 15)
                     , destination = FileEntry (removed </> foo) (File 15)
                     , destinationDelta = Unchanged
                     }
                 , FileCorrespondence
                     { source = FileEntry (removed </> qux) Missing
                     , sourceDelta = Removed
                     , intermediate = FileEntry (removed </> qux) Directory
                     , destination = FileEntry (removed </> qux) Missing
                     , destinationDelta = Removed
                     }
                 , FileCorrespondence
                     { source = FileEntry unchanged Directory
                     , sourceDelta = Unchanged
                     , intermediate = FileEntry unchanged Directory
                     , destination = FileEntry unchanged Directory
                     , destinationDelta = Unchanged
                     }
                 , FileCorrespondence
                     { source = FileEntry (unchanged </> foo) (File 13)
                     , sourceDelta = Unchanged
                     , intermediate = FileEntry (unchanged </> foo) (File 13)
                     , destination = FileEntry (unchanged </> foo) (File 13)
                     , destinationDelta = Unchanged
                     }
                 ]

  specify "makeCorrespondBetweenTwoDirs" $ withTempDir $ \tmpDir _ -> do
    () <-
      makeFixtureTree
        (tmpDir </> foo)
        [ (foo, F "foo")
        ,
          ( bar
          , D
              [ (foo, F "bar/foo")
              , (bar, F "bar/bar")
              , (baz, D [])
              ]
          )
        , (baz, F "baz")
        ,
          ( qux
          , D
              [ (foo, F "qux/foo")
              , (quux, F "qux/quux")
              ]
          )
        ]
    () <-
      makeFixtureTree
        (tmpDir </> bar)
        [ (foo, F "foo 2")
        ,
          ( bar
          , D
              [ (foo, F "bar/foo")
              , (baz, D [(bar, F "bar/baz/bar")])
              ]
          )
        , (baz, F "baz")
        ,
          ( qux
          , D
              [ (foo, F "qux/foo")
              , (quux, F "qux/quux 2")
              ]
          )
        ]
    makeCorrespondBetweenTwoDirs (tmpDir </> foo) (tmpDir </> bar) []
      `shouldReturn` fromList
        [ (foo, (FileEntry foo $ File 3, FileEntry foo $ File 5))
        , (bar, (FileEntry bar Directory, FileEntry bar Directory))
        ,
          ( bar </> foo
          , (FileEntry (bar </> foo) $ File 7, FileEntry (bar </> foo) $ File 7)
          )
        ,
          ( bar </> bar
          , (FileEntry (bar </> bar) $ File 7, FileEntry (bar </> bar) Missing)
          )
        ,
          ( bar </> baz
          ,
            ( FileEntry (bar </> baz) Directory
            , FileEntry (bar </> baz) Directory
            )
          )
        ,
          ( bar </> baz </> bar
          ,
            ( FileEntry (bar </> baz </> bar) Missing
            , FileEntry (bar </> baz </> bar) $ File 11
            )
          )
        , (baz, (FileEntry baz $ File 3, FileEntry baz $ File 3))
        ,
          ( qux
          , (FileEntry qux Directory, FileEntry qux Directory)
          )
        ,
          ( qux </> foo
          , (FileEntry (qux </> foo) $ File 7, FileEntry (qux </> foo) $ File 7)
          )
        ,
          ( qux </> quux
          ,
            ( FileEntry (qux </> quux) $ File 8
            , FileEntry (qux </> quux) $ File 10
            )
          )
        ]
    makeCorrespondBetweenTwoDirs
      (tmpDir </> baz) -- This dir does not exist
      (tmpDir </> bar)
      []
      `shouldReturn` fromList
        [ (foo, (FileEntry foo Missing, FileEntry foo $ File 5))
        , (bar, (FileEntry bar Missing, FileEntry bar Directory))
        ,
          ( bar </> foo
          ,
            ( FileEntry (bar </> foo) Missing
            , FileEntry (bar </> foo) $ File 7
            )
          )
        ,
          ( bar </> baz
          ,
            ( FileEntry (bar </> baz) Missing
            , FileEntry (bar </> baz) Directory
            )
          )
        ,
          ( bar </> baz </> bar
          ,
            ( FileEntry (bar </> baz </> bar) Missing
            , FileEntry (bar </> baz </> bar) $ File 11
            )
          )
        , (baz, (FileEntry baz Missing, FileEntry baz $ File 3))
        , (qux, (FileEntry qux Missing, FileEntry qux Directory))
        ,
          ( qux </> foo
          , (FileEntry (qux </> foo) Missing, FileEntry (qux </> foo) $ File 7)
          )
        ,
          ( qux </> quux
          ,
            ( FileEntry (qux </> quux) Missing
            , FileEntry (qux </> quux) $ File 10
            )
          )
        ]

  specify "getRouteState" $ withContextFixture $ \ctx tmpDir -> do
    (state, ws) <- getRouteState ctx (tmpDir </> src)
    ws `shouldBe` [EnvironmentPredicateWarning $ UndefinedMoniker undefined']
    state `shouldBe` NotRouted
    (state', ws') <- getRouteState ctx (tmpDir </> foo </> foo)
    ws' `shouldBe` ws
    state' `shouldBe` Routed foo
    ignoreMe <- encodePath "ignore-me"
    (state'', ws'') <- getRouteState ctx (tmpDir </> foo </> ignoreMe)
    ws'' `shouldBe` ws
    state'' `shouldBe` Ignored foo "ignore-*"
    let ctx' = ctx{environment = ctx.environment{operatingSystem = FreeBSD}}
    (stateOnNullRouting, ws''') <- getRouteState ctx' (tmpDir </> src)
    ws''' `shouldBe` ws
    stateOnNullRouting `shouldBe` NotRouted

  describe "calculateSpecificity" $ do
    it "returns 0 for exact match" $ withTempDir $ \tmpDir _ -> do
      let route =
            RouteResult
              { sourcePath = tmpDir </> foo
              , routeName = foo
              , destinationPath = tmpDir </> bar
              , fileType = Dojang.MonadFileSystem.Directory
              }
      calculateSpecificity (tmpDir </> bar) route `shouldBe` 0

    it "returns 1 for direct child" $ withTempDir $ \tmpDir _ -> do
      let route =
            RouteResult
              { sourcePath = tmpDir </> foo
              , routeName = foo
              , destinationPath = tmpDir </> bar
              , fileType = Dojang.MonadFileSystem.Directory
              }
      calculateSpecificity (tmpDir </> bar </> baz) route `shouldBe` 1

    it "returns 2 for grandchild" $ withTempDir $ \tmpDir _ -> do
      let route =
            RouteResult
              { sourcePath = tmpDir </> foo
              , routeName = foo
              , destinationPath = tmpDir </> bar
              , fileType = Dojang.MonadFileSystem.Directory
              }
      calculateSpecificity (tmpDir </> bar </> baz </> qux) route `shouldBe` 2

  describe "filterBySpecificity" $ do
    it "returns empty list for empty input" $ withTempDir $ \tmpDir _ -> do
      filterBySpecificity (tmpDir </> foo) [] `shouldBe` []

    it "keeps only most specific routes" $ withTempDir $ \tmpDir _ -> do
      let route1 =
            RouteResult
              { sourcePath = tmpDir </> src </> foo
              , routeName = foo
              , destinationPath = tmpDir </> bar
              , fileType = Dojang.MonadFileSystem.Directory
              }
      let route2 =
            RouteResult
              { sourcePath = tmpDir </> src </> baz
              , routeName = baz
              , destinationPath = tmpDir </> bar </> qux
              , fileType = Dojang.MonadFileSystem.Directory
              }
      -- route1: specificity = 2 (bar/qux/foo)
      -- route2: specificity = 1 (qux/foo)
      let target = tmpDir </> bar </> qux </> foo
      filterBySpecificity target [route1, route2] `shouldBe` [route2]

    it "keeps all routes when equally specific" $ withTempDir $ \tmpDir _ -> do
      let route1 =
            RouteResult
              { sourcePath = tmpDir </> src </> foo
              , routeName = foo
              , destinationPath = tmpDir </> dst
              , fileType = Dojang.MonadFileSystem.Directory
              }
      let route2 =
            RouteResult
              { sourcePath = tmpDir </> src </> bar
              , routeName = bar
              , destinationPath = tmpDir </> dst
              , fileType = Dojang.MonadFileSystem.Directory
              }
      -- Both routes have the same destinationPath, so same specificity
      let target = tmpDir </> dst </> baz
      filterBySpecificity target [route1, route2] `shouldBe` [route1, route2]

  describe "findMatchingRoutes" $ do
    it "returns NoMatch when no routes match" $ withContextFixture $ \ctx tmpDir -> do
      (result, _) <- findMatchingRoutes ctx (tmpDir </> corge)
      result `shouldBe` NoMatch

    it "returns SingleMatch for unambiguous route" $ withContextFixture $ \ctx tmpDir -> do
      (result, _) <- findMatchingRoutes ctx (tmpDir </> foo </> foo)
      case result of
        SingleMatch route -> route.routeName `shouldBe` foo
        _ -> fail "Expected SingleMatch"

    it "returns SingleMatch when filtered by specificity" $ withTempDir $ \tmpDir _ -> do
      -- Create a context with overlapping routes at different depths
      createDirectory $ tmpDir </> src
      createDirectory $ tmpDir </> dst
      createDirectory $ tmpDir </> dst </> foo
      writeFile (tmpDir </> src </> manifestFilename') ""

      -- route1 points to DST (via env var), route2 points to DST_FOO (via env var)
      -- For target dst/foo/bar, route2 is more specific (specificity=1 vs 2)
      let overlappingFileRoutes =
            [ (foo, [(posix, Just $ Substitution "DST")])
            , (bar, [(posix, Just $ Substitution "DST_FOO")])
            ]
              :: Map OsPath [(MonikerName, Maybe FilePathExpression)]
      let overlappingManifest =
            Dojang.Types.Manifest.manifest
              monikerMap
              []
              overlappingFileRoutes
              []
              mempty
      let repo =
            Repository
              (tmpDir </> src)
              (tmpDir </> src </> intermediateDir)
              overlappingManifest
      let ctx =
            Context
              repo
              (Environment Linux X86_64 $ Kernel "Linux" "5.10.0-8")
              $ \e -> return $ case e of
                "DST" -> Just $ tmpDir </> dst
                "DST_FOO" -> Just $ tmpDir </> dst </> foo
                _ -> Nothing
      (result, _) <- findMatchingRoutes ctx (tmpDir </> dst </> foo </> baz)
      case result of
        SingleMatch route -> route.routeName `shouldBe` bar
        _ -> fail $ "Expected SingleMatch, got " ++ show result

    it "returns AmbiguousMatch for equally specific routes" $ withTempDir $ \tmpDir _ -> do
      -- Create a context with two routes pointing to the same destination
      createDirectory $ tmpDir </> src
      createDirectory $ tmpDir </> src </> foo
      createDirectory $ tmpDir </> src </> bar
      createDirectory $ tmpDir </> dst
      writeFile (tmpDir </> src </> manifestFilename') ""

      -- Both foo and bar routes point to DST (via env var)
      let ambiguousFileRoutes =
            [ (foo, [(posix, Just $ Substitution "DST")])
            , (bar, [(posix, Just $ Substitution "DST")])
            ]
              :: Map OsPath [(MonikerName, Maybe FilePathExpression)]
      let ambiguousManifest =
            Dojang.Types.Manifest.manifest
              monikerMap
              []
              ambiguousFileRoutes
              []
              mempty
      let repo =
            Repository
              (tmpDir </> src)
              (tmpDir </> src </> intermediateDir)
              ambiguousManifest
      let ctx =
            Context
              repo
              (Environment Linux X86_64 $ Kernel "Linux" "5.10.0-8")
              $ \e -> return $ case e of
                "DST" -> Just $ tmpDir </> dst
                _ -> Nothing
      (result, _) <- findMatchingRoutes ctx (tmpDir </> dst </> baz)
      case result of
        AmbiguousMatch candidates -> do
          length candidates `shouldBe` 2
          -- All candidates should have same specificity
          let firstSpec = (NE.head candidates).specificity
          all (\c -> c.specificity == firstSpec) (NE.toList candidates) `shouldBe` True
        _ -> fail $ "Expected AmbiguousMatch, got " ++ show result