packages feed

dojang-0.2.0: src/Dojang/Types/Context.hs

{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE NoFieldSelectors #-}

module Dojang.Types.Context
  ( CandidateRoute (..)
  , Context (..)
  , FileCorrespondence (..)
  , FileDeltaKind (..)
  , FileEntry (..)
  , FileStat (..)
  , IgnoredFile (..)
  , RouteMatch (..)
  , RouteState (..)
  , UnregisteredFile (..)
  , calculateSpecificity
  , filterBySpecificity
  , findCandidateRoutesFor
  , findMatchingRoutes
  , getIgnoredFiles
  , getRouteState
  , getUnregisteredFiles
  , listFiles
  , makeCorrespond
  , makeCorrespondBetweenThreeDirs
  , makeCorrespondBetweenThreeFiles
  , makeCorrespondBetweenTwoDirs
  , makeCorrespondWithDestination
  , routePaths
  ) where

import Control.Monad (forM, when)
import Control.Monad.IO.Class (MonadIO (liftIO))
import Data.List (isPrefixOf)
import GHC.IO.Exception (IOErrorType (InappropriateType))
import GHC.Stack (HasCallStack)
import System.IO.Error
  ( ioeSetErrorString
  , ioeSetFileName
  , isPermissionError
  , mkIOError
  )
import Prelude hiding (readFile)

import Control.Monad.Except (MonadError (..))
import Data.Map.Strict
  ( Map
  , filter
  , findWithDefault
  , fromList
  , keysSet
  , unionWith
  , (!?)
  )
import System.Directory.OsPath (makeAbsolute)
import System.FilePattern (FilePattern, matchMany)
import System.OsPath
  ( OsPath
  , OsString
  , joinPath
  , makeRelative
  , normalise
  , splitDirectories
  , (</>)
  )

import Data.List.NonEmpty (NonEmpty (..))
import Data.List.NonEmpty qualified as NE
import Data.Set (toList, union)
import Dojang.MonadFileSystem (MonadFileSystem (..))
import Dojang.MonadFileSystem qualified (FileType (..))
import Dojang.Types.Environment (Environment)
import Dojang.Types.FilePathExpression (EnvironmentVariable)
import Dojang.Types.Manifest (Manifest (..))
import Dojang.Types.Repository
  ( Repository (..)
  , RouteMapWarning
  , RouteResult (..)
  )
import Dojang.Types.Repository qualified (routePaths)


-- | The context in which repository operations are performed.
data (MonadFileSystem m) => Context m = Context
  { repository :: Repository
  -- ^ The repository.
  , environment :: Environment
  -- ^ The environment.
  , environmentVariableGetter :: EnvironmentVariable -> m (Maybe OsString)
  -- ^ A function to look up an environment variable.
  }


-- | Route the paths in the repository.  This will return a list of expanded
-- paths, along with any warnings that were generated.  Null routes will be
-- ignored.
routePaths
  :: (MonadFileSystem m)
  => Context m
  -- ^ The context in which to perform path routing.
  -> m ([RouteResult], [RouteMapWarning])
  -- ^ The expanded paths, along with any warnings that were generated.
routePaths ctx =
  Dojang.Types.Repository.routePaths
    ctx.repository
    ctx.environment
    ctx.environmentVariableGetter


-- | The small stat of a file.
data FileStat
  = -- | The file is missing.
    Missing
  | -- | The file is a directory.
    Directory
  | -- | The file is a regular file, and this is its size in bytes.
    File Integer
  | -- | The file is a symlink, and this is the path to the symlink target.
    Symlink OsPath
  deriving (Eq, Ord, Show)


-- | A file/directory in the repository or the destination directory.
data FileEntry = FileEntry
  { path :: OsPath
  -- ^ The path to the file.
  , stat :: FileStat
  -- ^ Whether the file is a file, a directory, or missing.
  }
  deriving (Eq, Ord, Show)


-- | The kind of change that was made to a file.
data FileDeltaKind = Unchanged | Added | Removed | Modified
  deriving (Eq, Ord, Show)


-- | A correspondence between a source file, an intermediate file, and
-- a destination file.
data FileCorrespondence = FileCorrespondence
  { source :: FileEntry
  -- ^ The source file.
  , sourceDelta :: FileDeltaKind
  -- ^ The kind of change that was made to the source file.
  , intermediate :: FileEntry
  -- ^ The intermediate file.
  , destination :: FileEntry
  -- ^ The destination file.
  , destinationDelta :: FileDeltaKind
  -- ^ The kind of change that was made to the destination file.
  }
  deriving (Eq, Ord, Show)


-- | Creates a 'FileCorrespondence' from a single destination file.
makeCorrespondWithDestination
  :: forall m
   . (MonadFileSystem m)
  => Context m
  -- ^ The context in which to perform file correspondence.
  -> OsPath
  -- ^ The path to the destination file.
  -> m (Maybe FileCorrespondence, [RouteMapWarning])
  -- ^ The file correspondence, along with a list of warnings that occurred
  -- during path routing (if any).  The file paths in the returned
  -- 'FileCorrespondence' value are absolute, or relative to the current
  -- working directory at least.
makeCorrespondWithDestination ctx dstPath = do
  (routeMatch, warnings) <- findMatchingRoutes ctx dstPath
  case routeMatch of
    NoMatch -> return (Nothing, warnings)
    SingleMatch route -> do
      correspond <- makeCorrespondForRoute route
      return (Just correspond, warnings)
    AmbiguousMatch candidates -> do
      -- For backward compatibility: auto-select by existence if possible,
      -- or return the first route.
      let existingCandidates =
            [c | c <- NE.toList candidates, c.sourceExists]
      route <- case existingCandidates of
        [c] -> return c.route
        _ -> return (NE.head candidates).route
      correspond <- makeCorrespondForRoute route
      return (Just correspond, warnings)
 where
  makeCorrespondForRoute :: RouteResult -> m FileCorrespondence
  makeCorrespondForRoute route = do
    let normalized = normalise route.destinationPath
    let relPath = makeRelative normalized dstPath
    period <- encodePath "."
    let (interPath, srcPath) =
          if normalized == dstPath || relPath == period
            then
              let interPath' =
                    normalise
                      (ctx.repository.intermediatePath </> route.routeName)
                  srcPath' = normalise route.sourcePath
              in (interPath', srcPath')
            else
              let interPath' =
                    normalise $
                      ctx.repository.intermediatePath
                        </> route.routeName
                        </> relPath
                  srcPath' = normalise $ route.sourcePath </> relPath
              in (interPath', srcPath')
    makeCorrespondBetweenThreeFiles interPath srcPath dstPath


-- | Creates a list of file correspondences between the source files, the
-- intermediate files, and the destination files.  Throws an 'IOError' if any of
-- the files cannot be read.
makeCorrespond
  :: (HasCallStack, MonadFileSystem m)
  => Context m
  -- ^ The context in which to perform file correspondence.
  -> m ([FileCorrespondence], [RouteMapWarning])
  -- ^ The file correspondences, along with a list of warnings that occurred
  -- during path routing (if any).  The file paths in the returned
  -- 'FileCorrespondence' values are absolute, or relative to the current
  -- working directory at least.
makeCorrespond ctx = do
  (paths, warnings) <- routePaths ctx
  files <- forM paths $ \expanded -> do
    let interAbsPath = repo.intermediatePath </> expanded.routeName
    case expanded.fileType of
      Dojang.MonadFileSystem.Directory -> do
        fs <-
          makeCorrespondBetweenThreeDirs
            interAbsPath
            expanded.sourcePath
            expanded.destinationPath
            (findWithDefault [] (normalise expanded.routeName) ignorePatterns)
        return
          [ correspond
              { source =
                  correspond.source{path = expanded.sourcePath </> correspond.source.path}
              , intermediate =
                  correspond.intermediate
                    { path = interAbsPath </> correspond.intermediate.path
                    }
              , destination =
                  correspond.destination
                    { path =
                        expanded.destinationPath </> correspond.destination.path
                    }
              }
          | correspond <- fs
          ]
      _ -> do
        f <-
          makeCorrespondBetweenThreeFiles
            interAbsPath
            expanded.sourcePath
            expanded.destinationPath
        return [f]
  return (concat files, warnings)
 where
  repo :: Repository
  repo = ctx.repository
  ignorePatterns :: Map OsPath [FilePattern]
  ignorePatterns = repo.manifest.ignorePatterns


makeCorrespondBetweenThreeFiles
  :: forall m
   . (HasCallStack, MonadFileSystem m)
  => OsPath
  -> OsPath
  -> OsPath
  -> m FileCorrespondence
makeCorrespondBetweenThreeFiles intermediatePath srcPath dstPath = do
  interStat <- getFileStat intermediatePath
  let interEntry = FileEntry intermediatePath interStat
  srcStat <- getFileStat srcPath
  let srcEntry = FileEntry srcPath srcStat
  srcDelta <- getDelta interEntry srcEntry
  dstStat <- getFileStat dstPath
  let dstEntry = FileEntry dstPath dstStat
  dstDelta <- getDelta interEntry dstEntry
  return
    FileCorrespondence
      { source = srcEntry
      , sourceDelta = srcDelta
      , intermediate = interEntry
      , destination = dstEntry
      , destinationDelta = dstDelta
      }
 where
  getFileStat :: OsPath -> m FileStat
  getFileStat path = do
    isDir <- isDirectory path
    when isDir $ do
      path' <- decodePath path
      throwError $
        userError
          ( "makeCorrespondBetweenThreeFiles: "
              ++ "expected a file, but got a directory"
          )
          `ioeSetFileName` path'
    exists' <- exists path
    if exists'
      then do
        size <- getFileSize path
        return $ File size
      else return Missing
  getDelta :: FileEntry -> FileEntry -> m FileDeltaKind
  getDelta (FileEntry _ Missing) (FileEntry _ Missing) = return Unchanged
  getDelta (FileEntry _ Missing) (FileEntry _ _) = return Added
  getDelta (FileEntry _ File{}) (FileEntry _ Missing) = return Removed
  getDelta (FileEntry _ Symlink{}) (FileEntry _ Missing) = return Removed
  getDelta (FileEntry _ (Symlink path)) (FileEntry _ (Symlink path')) =
    if path == path'
      then return Unchanged
      else return Modified
  getDelta interEntry targetEntry =
    if interEntry.stat /= targetEntry.stat
      then return Modified
      else do
        interData <- readFile interEntry.path
        targetData <- readFile targetEntry.path
        return $ if interData == targetData then Unchanged else Modified


makeCorrespondBetweenThreeDirs
  :: forall m
   . (HasCallStack, MonadFileSystem m)
  => OsPath
  -> OsPath
  -> OsPath
  -> [FilePattern]
  -> m [FileCorrespondence]
makeCorrespondBetweenThreeDirs intermediatePath srcPath dstPath ignores = do
  srcEntries <- makeCorrespondBetweenTwoDirs intermediatePath srcPath []
  dstEntries <-
    makeCorrespondBetweenTwoDirs intermediatePath dstPath ignores
  let paths = keysSet srcEntries `union` keysSet dstEntries
  forM (Data.Set.toList paths) $ \path -> do
    let missing = FileEntry path Missing
    (interEntry, srcEntry, srcDelta) <- case srcEntries !? path of
      Nothing -> return (missing, missing, Unchanged)
      Just (interEntry', srcEntry') -> do
        delta <-
          getDelta path srcPath interEntry'.stat srcEntry'.stat
        return (interEntry', srcEntry', delta)
    (interEntry2, dstEntry, dstDelta) <- case dstEntries !? path of
      Nothing -> return (missing, missing, Unchanged)
      Just (interEntry', dstEntry') -> do
        delta <- getDelta path dstPath interEntry'.stat dstEntry'.stat
        return (interEntry', dstEntry', delta)
    (dstEntry', dstDelta') <-
      if null ignores || dstEntry.stat /= Missing
        then return (dstEntry, dstDelta)
        else do
          actualDstStat <- getFileStat (dstPath </> path)
          actualDelta <- getDelta path dstPath interEntry2.stat actualDstStat
          return (dstEntry{stat = actualDstStat}, actualDelta)
    return $
      FileCorrespondence
        { source = srcEntry
        , sourceDelta = srcDelta
        , intermediate =
            if interEntry.stat == Missing
              then interEntry2
              else interEntry
        , destination = dstEntry'
        , destinationDelta = dstDelta'
        }
 where
  getDelta
    :: OsPath
    -- \^ The relative path to the file.
    -> OsPath
    -- \^ The path to the target directory.
    -> FileStat
    -- \^ An intermediate file stat.
    -> FileStat
    -- \^ A target (source or destination) file stat.
    -> m FileDeltaKind
  getDelta _ _ Directory Directory = return Unchanged
  getDelta _ _ Directory Missing = return Removed
  getDelta _ _ Directory _ = return Modified
  getDelta path targetPath (File size) (File size') =
    if size /= size'
      then return Modified
      else do
        interData <- readFile (intermediatePath </> path)
        targetData <- readFile (targetPath </> path)
        return $ if interData == targetData then Unchanged else Modified
  getDelta _ _ (File _) Missing = return Removed
  getDelta _ _ (File _) _ = return Modified
  getDelta _ _ (Symlink path) (Symlink path') =
    if path == path'
      then return Unchanged
      else return Modified
  getDelta _ _ (Symlink _) Missing = return Added
  getDelta _ _ (Symlink _) _ = return Modified
  getDelta _ _ Missing Missing = return Unchanged
  getDelta _ _ Missing _ = return Added
  getFileStat :: OsPath -> m FileStat
  getFileStat path' = do
    isSym <- isSymlink path'
    if isSym
      then do
        target <- readSymlinkTarget path'
        return $ Symlink target
      else do
        isDir <- isDirectory path'
        if isDir
          then return Directory
          else do
            isFle <- isFile path'
            if isFle
              then do
                size <- getFileSize path'
                return $ File size
              else return Missing


makeCorrespondBetweenTwoDirs
  :: forall m
   . (HasCallStack, MonadFileSystem m)
  => OsPath
  -> OsPath
  -> [FilePattern]
  -> m (Map OsPath (FileEntry, FileEntry))
makeCorrespondBetweenTwoDirs intermediatePath targetPath ignorePatterns = do
  intermediateDirExists <- exists intermediatePath
  intermediateFiles <-
    if intermediateDirExists
      then listFiles intermediatePath []
      else return []
  targetFiles <- listFiles targetPath ignorePatterns
  let intermediateEntries =
        fromList
          [(p, (e, e{stat = Missing})) | e@(FileEntry p _) <- intermediateFiles]
      targetEntries =
        fromList
          [(p, (e{stat = Missing}, e)) | e@(FileEntry p _) <- targetFiles]
      allEntries = unionWith combinePairs intermediateEntries targetEntries
  return $ Data.Map.Strict.filter eitherExists allEntries
 where
  combinePairs
    :: (FileEntry, FileEntry)
    -> (FileEntry, FileEntry)
    -> (FileEntry, FileEntry)
  combinePairs (a1, b1) (a2, b2)
    | isNotMissing a1 && isNotMissing b2 = (a1, b2)
    | isNotMissing a2 && isNotMissing b1 = (a2, b1)
    | isMissing a1 && isNotMissing b2 = (a2, b2)
    | otherwise = (a1, b1)
   where
    isMissing :: FileEntry -> Bool
    isMissing (FileEntry _ Missing) = True
    isMissing _ = False
    isNotMissing :: FileEntry -> Bool
    isNotMissing = not . isMissing
  eitherExists :: (FileEntry, FileEntry) -> Bool
  eitherExists (FileEntry _ Missing, FileEntry _ Missing) = False
  eitherExists _ = True


-- | Lists all 'FilEntry' values in the given directory.  Throws an 'IOError' if
-- the directory cannot be read.
listFiles
  :: (MonadFileSystem m)
  => OsPath
  -- ^ The path to the directory to list entries in.
  -> [FilePattern]
  -- ^ The file patterns to ignore.  If a directory matches one of these
  -- patterns, then its contents will not be listed either.
  -> m [FileEntry]
  -- ^ The list of 'FileEntry' values in the directory.
listFiles path ignorePatterns = do
  isSymlink' <- isSymlink path
  isFile' <- isFile path
  when (isSymlink' || isFile') $ do
    path' <- decodePath path
    throwError $
      mkIOError InappropriateType "listFiles" Nothing (Just path')
        `ioeSetErrorString` "not a directory"
  exists' <- exists path
  if exists'
    then do
      entries <-
        listDirectoryRecursively path ignorePatterns `catchError` \e ->
          if isPermissionError e
            then return []
            else throwError e
      forM entries $ \case
        (Dojang.MonadFileSystem.Directory, d) ->
          return $ FileEntry d Directory
        (Dojang.MonadFileSystem.File, f) -> do
          size <- getFileSize $ path </> f
          return $ FileEntry f $ File size
        (Dojang.MonadFileSystem.Symlink, s) -> do
          target <- readSymlinkTarget $ path </> s
          return $ FileEntry s $ Symlink target
    else return []


-- | Represents the route state of a (potential) destination file
-- in the repository.
data RouteState
  = -- | The file is routed.  The 'OsPath' is the name of the relevant route
    -- name.
    Routed OsPath
  | -- | The file is routed but ignored.  The 'OsPath' is the name of
    -- the relevant route name, and the 'FilePattern' is the ignore pattern
    -- that matched the file.
    Ignored OsPath FilePattern
  | -- | The file is not routed at all.
    NotRouted
  deriving (Eq, Ord, Show)


-- | Represents a file that is ignored by a route's ignore patterns.
data IgnoredFile = IgnoredFile
  { routeName :: OsPath
  -- ^ The name of the route that contains the ignore pattern.
  , sourcePath :: OsPath
  -- ^ The path to the source file (in the repository).
  , destinationPath :: OsPath
  -- ^ The path to the destination file.
  , pattern :: FilePattern
  -- ^ The ignore pattern that matched the file.
  }
  deriving (Eq, Show)


-- | A candidate route with computed metadata for disambiguation.
data CandidateRoute = CandidateRoute
  { route :: RouteResult
  -- ^ The route result.
  , specificity :: Int
  -- ^ The specificity of the route, i.e., the number of path components
  -- from the route's destination to the target path.  Lower is more specific.
  , sourceFilePath :: OsPath
  -- ^ The computed source file path (combining route source path with relative
  -- path from destination to target).
  , sourceExists :: Bool
  -- ^ Whether the source file for this route already exists.
  }
  deriving (Eq, Show)


-- | Result of finding routes for a destination path.
data RouteMatch
  = -- | No matching routes found.
    NoMatch
  | -- | A single unambiguous route was found.
    SingleMatch RouteResult
  | -- | Multiple routes match with the same specificity.
    AmbiguousMatch (NonEmpty CandidateRoute)
  deriving (Eq, Show)


-- | Calculate the specificity of a route relative to a target path.
-- Returns the number of path components between the route's destination
-- and the target path.  Lower values indicate more specific routes.
--
-- ==== Examples
--
-- >>> calculateSpecificity "/foo/bar/baz" route  -- route.destinationPath = "/foo"
-- 2  -- bar/baz
--
-- >>> calculateSpecificity "/foo" route  -- route.destinationPath = "/foo"
-- 0  -- exact match
calculateSpecificity
  :: OsPath
  -- ^ The target path.
  -> RouteResult
  -- ^ The route result.
  -> Int
  -- ^ The specificity (number of path components from route destination to
  -- target).
calculateSpecificity targetPath route =
  let dstDirs = splitDirectories $ normalise route.destinationPath
      targetDirs = splitDirectories $ normalise targetPath
  in length targetDirs - length dstDirs


-- | Filter routes by specificity, keeping only those with the lowest
-- (most specific) specificity value.
filterBySpecificity
  :: OsPath
  -- ^ The target path.
  -> [RouteResult]
  -- ^ The list of routes to filter.
  -> [RouteResult]
  -- ^ The routes with the lowest specificity.
filterBySpecificity _ [] = []
filterBySpecificity targetPath routes =
  let specificityCounts = [(r, calculateSpecificity targetPath r) | r <- routes]
      minSpec = minimum $ map snd specificityCounts
  in [r | (r, s) <- specificityCounts, s == minSpec]


-- | Find matching routes for a destination path.
-- Returns a 'RouteMatch' indicating whether no routes, a single route,
-- or multiple ambiguous routes match.
findMatchingRoutes
  :: (MonadFileSystem m)
  => Context m
  -- ^ The context in which to perform route matching.
  -> OsPath
  -- ^ The target path.
  -> m (RouteMatch, [RouteMapWarning])
  -- ^ The route match result, along with any warnings that were generated.
findMatchingRoutes ctx targetPath = do
  (routes, warnings) <- routePaths ctx
  let targetDirs = splitDirectories $ normalise targetPath
  let matching = [r | r <- routes, startsWithRoute r targetDirs]
  case matching of
    [] -> return (NoMatch, warnings)
    [r] -> return (SingleMatch r, warnings)
    rs -> do
      let filtered = filterBySpecificity targetPath rs
      case filtered of
        [] -> return (NoMatch, warnings)
        [r] -> return (SingleMatch r, warnings)
        (r : rs') -> do
          candidates <- mapM (makeCandidateRoute targetPath) (r :| rs')
          return (AmbiguousMatch candidates, warnings)
 where
  startsWithRoute :: RouteResult -> [OsPath] -> Bool
  startsWithRoute route targetDirs' =
    splitDirectories (normalise route.destinationPath) `isPrefixOf` targetDirs'
  makeCandidateRoute
    :: (MonadFileSystem m) => OsPath -> RouteResult -> m CandidateRoute
  makeCandidateRoute target route = do
    let spec = calculateSpecificity target route
    let relPath = makeRelative (normalise route.destinationPath) (normalise target)
    period <- encodePath "."
    let srcPath =
          if relPath == period
            then normalise route.sourcePath
            else normalise $ route.sourcePath </> relPath
    srcExists <- exists srcPath
    return $ CandidateRoute route spec srcPath srcExists


-- | Gets the route state of the given path (which is a potential destination).
getRouteState
  :: (HasCallStack, MonadFileSystem m, MonadIO m)
  => Context m
  -- ^ The context in which to perform path routing.
  -> OsPath
  -- ^ The path (which is a potential destination) to get the route state of.
  -> m (RouteState, [RouteMapWarning])
  -- ^ The route state of the path, along with any warnings that were generated.
getRouteState ctx path = do
  absPath <- liftIO $ makeAbsolute path
  let dirs = splitDirectories absPath
  (routes, ws) <- routePaths ctx
  states <- forM routes $ \route -> do
    dstPath <- liftIO $ makeAbsolute route.destinationPath
    let prefix = splitDirectories dstPath
    if prefix `isPrefixOf` dirs
      then do
        let ignores =
              findWithDefault
                []
                (normalise route.routeName)
                ignorePatterns
        relPath <- decodePath $ joinPath $ drop (length prefix) dirs
        case matchMany [(i, i) | i <- ignores] [((), relPath)] of
          (pattern, _, _) : _ -> return $ Ignored route.routeName pattern
          _ -> return $ Routed route.routeName
      else return NotRouted
  return (if null states then NotRouted else minimum states, ws)
 where
  ignorePatterns :: Map OsPath [FilePattern]
  ignorePatterns = ctx.repository.manifest.ignorePatterns


-- | Gets a list of files that are ignored by the repository's ignore patterns.
-- This function scans all directory routes for files that would be excluded
-- by their ignore patterns.
getIgnoredFiles
  :: forall m
   . (HasCallStack, MonadFileSystem m)
  => Context m
  -- ^ The context in which to perform the operation.
  -> m [IgnoredFile]
  -- ^ The list of ignored files.
getIgnoredFiles ctx = do
  (routes, _) <- routePaths ctx
  ignoredLists <- forM routes $ \route ->
    case route.fileType of
      Dojang.MonadFileSystem.Directory -> do
        let ignores = findWithDefault [] (normalise route.routeName) ignorePatterns
        if null ignores
          then return []
          else do
            -- List all files (including those that would be ignored)
            allFiles <- listFilesWithoutIgnore route.destinationPath
            -- Find which files match ignore patterns
            forM allFiles $ \entry -> do
              relPath <- decodePath entry.path
              case matchMany [(i, i) | i <- ignores] [((), relPath)] of
                (ptn, _, _) : _ ->
                  return $
                    Just $
                      IgnoredFile
                        { routeName = route.routeName
                        , sourcePath = route.sourcePath </> entry.path
                        , destinationPath = route.destinationPath </> entry.path
                        , pattern = ptn
                        }
                _ -> return Nothing
      _ -> return []
  return $ concat [[f | Just f <- fs] | fs <- ignoredLists]
 where
  ignorePatterns :: Map OsPath [FilePattern]
  ignorePatterns = ctx.repository.manifest.ignorePatterns

  listFilesWithoutIgnore :: OsPath -> m [FileEntry]
  listFilesWithoutIgnore path = do
    exists' <- exists path
    if exists'
      then do
        entries <-
          listDirectoryRecursively path [] `catchError` \e ->
            if isPermissionError e
              then return []
              else throwError e
        forM entries $ \case
          (Dojang.MonadFileSystem.Directory, d) ->
            return $ FileEntry d Directory
          (Dojang.MonadFileSystem.File, f) -> do
            size <- getFileSize $ path </> f
            return $ FileEntry f $ File size
          (Dojang.MonadFileSystem.Symlink, s) -> do
            target <- readSymlinkTarget $ path </> s
            return $ FileEntry s $ Symlink target
      else return []


-- | Represents a file that is not registered in any route.
data UnregisteredFile = UnregisteredFile
  { filePath :: OsPath
  -- ^ The path to the unregistered file in the destination.
  , candidateRoutes :: [RouteResult]
  -- ^ The candidate routes that could contain this file (based on path prefix).
  }
  deriving (Eq, Show)


-- | Gets a list of files in destination directories that are not registered
-- in any route.  These are files that exist in destination directories but
-- are not part of the repository.
getUnregisteredFiles
  :: forall m
   . (HasCallStack, MonadFileSystem m)
  => Context m
  -- ^ The context in which to perform the operation.
  -> m [UnregisteredFile]
  -- ^ The list of unregistered files.
getUnregisteredFiles ctx = do
  (routes, _) <- routePaths ctx
  -- Get all files from makeCorrespond (these are registered)
  (registeredCorrespondences, _) <- makeCorrespond ctx
  let registeredPaths =
        [ normalise c.destination.path
        | c <- registeredCorrespondences
        , c.destination.stat /= Missing
        ]

  -- For each directory route, find files not in the registered set
  unregisteredLists <- forM routes $ \route ->
    case route.fileType of
      Dojang.MonadFileSystem.Directory -> do
        allFiles <- listFilesInDir route.destinationPath
        let unregistered =
              [ f
              | f <- allFiles
              , normalise (route.destinationPath </> f.path)
                  `notElem` registeredPaths
              ]
        -- For each unregistered file, find candidate routes
        forM unregistered $ \entry -> do
          let fullPath = route.destinationPath </> entry.path
          candidates <- findCandidateRoutesFor ctx fullPath
          return $
            UnregisteredFile
              { filePath = fullPath
              , candidateRoutes = candidates
              }
      _ -> return []

  return $ concat unregisteredLists
 where
  listFilesInDir :: OsPath -> m [FileEntry]
  listFilesInDir path = do
    exists' <- exists path
    if exists'
      then do
        entries <-
          listDirectoryRecursively path [] `catchError` \e ->
            if isPermissionError e
              then return []
              else throwError e
        forM entries $ \case
          (Dojang.MonadFileSystem.Directory, d) ->
            return $ FileEntry d Directory
          (Dojang.MonadFileSystem.File, f) -> do
            size <- getFileSize $ path </> f
            return $ FileEntry f $ File size
          (Dojang.MonadFileSystem.Symlink, s) -> do
            target <- readSymlinkTarget $ path </> s
            return $ FileEntry s $ Symlink target
      else return []


-- | Finds candidate routes for an unregistered file.
-- Returns routes whose destination path is a prefix of the file path.
findCandidateRoutesFor
  :: forall m
   . (MonadFileSystem m)
  => Context m
  -- ^ The context in which to perform the operation.
  -> OsPath
  -- ^ The path to the unregistered file.
  -> m [RouteResult]
  -- ^ The list of candidate routes.
findCandidateRoutesFor ctx filePath = do
  (routes, _) <- routePaths ctx
  let fileDirs = splitDirectories $ normalise filePath
  return
    [ route
    | route <- routes
    , route.fileType == Dojang.MonadFileSystem.Directory
    , let routeDirs = splitDirectories $ normalise route.destinationPath
    , routeDirs `isPrefixOf` fileDirs
    ]