packages feed

dojang-0.2.1: src/Dojang/Commands/Reflect.hs

{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell #-}

module Dojang.Commands.Reflect (reflect) where

import Control.Monad (filterM, forM, forM_, unless, when)
import Control.Monad.IO.Class (MonadIO (liftIO))
import Data.List (isPrefixOf, nub)
import System.Exit (ExitCode (..), exitWith)
import System.IO (hIsTerminalDevice, stderr, stdin)

import Control.Monad.Logger (logDebug, logDebugSH)
import Control.Monad.Reader (asks)
import Data.List.NonEmpty qualified as NE
import Data.Text (pack)
import FortyTwo.Prompts.Confirm (confirm)
import FortyTwo.Prompts.Select (select)
import System.Directory.OsPath (makeAbsolute)
import System.OsPath
  ( OsPath
  , makeRelative
  , normalise
  , splitDirectories
  , takeDirectory
  , (</>)
  )

import Dojang.App (App, AppEnv (manifestFile), ensureContext)
import Dojang.Commands
  ( Admonition (..)
  , codeStyleFor
  , die'
  , dieWithErrors
  , pathStyleFor
  , printStderr
  , printStderr'
  )
import Dojang.Commands.Disambiguation
  ( disambiguateRoutes
  , getAutoSelectMode
  )
import Dojang.Commands.Status (printWarnings)
import Dojang.ExitCodes
  ( ambiguousRouteError
  , fileNotFoundError
  , fileNotRoutedError
  , ignoredFileError
  , sourceCannotBeTargetError
  , userCancelledError
  )
import Dojang.MonadFileSystem (MonadFileSystem (..))
import Dojang.Types.Context
  ( CandidateRoute (..)
  , Context (..)
  , FileCorrespondence (..)
  , FileDeltaKind (..)
  , FileEntry (..)
  , FileStat (..)
  , IgnoredFile (..)
  , RouteMatch (..)
  , RouteState (..)
  , UnregisteredFile (..)
  , findMatchingRoutes
  , getIgnoredFiles
  , getRouteState
  , getUnregisteredFiles
  , makeCorrespond
  , makeCorrespondBetweenThreeFiles
  )
import Dojang.Types.Repository (Repository (..), RouteResult (..))


reflect
  :: (MonadFileSystem i, MonadIO i)
  => Bool
  -- ^ Force flag.
  -> Bool
  -- ^ All flag (skip confirmation).
  -> Bool
  -- ^ Include unregistered files flag.
  -> Maybe OsPath
  -- ^ Explicit source path.
  -> [OsPath]
  -- ^ Target paths (may be empty for all changed files).
  -> App i ExitCode
reflect force allFlag includeUnregistered _explicitSource [] = do
  -- No arguments: reflect all changed files
  ctx <- ensureContext
  pathStyle <- pathStyleFor stderr
  codeStyle <- codeStyleFor stderr
  (allFiles, ws) <- makeCorrespond ctx
  let changedFiles = filter isChanged allFiles
  printWarnings ws

  -- Get ignored files and warn about them
  ignoredFiles <- getIgnoredFiles ctx
  -- Filter to only ignored files that exist in destination
  existingIgnored <- filterM (exists . (.destinationPath)) ignoredFiles
  -- Create correspondences for ignored files if --force is used
  ignoredCorrespondences <-
    if force && not (null existingIgnored)
      then do
        forM existingIgnored $ \ignored -> do
          let interPath =
                ctx.repository.intermediatePath
                  </> ignored.routeName
                  </> makeRelative
                    (normalise ignored.routeName)
                    (normalise ignored.sourcePath)
          makeCorrespondBetweenThreeFiles
            interPath
            ignored.sourcePath
            ignored.destinationPath
      else return []
  let changedIgnored = filter isChanged ignoredCorrespondences

  -- Print warnings for ignored files
  unless (null existingIgnored) $ do
    if force
      then do
        printStderr' Note $
          "Including "
            <> pack (show $ length existingIgnored)
            <> " ignored file(s) due to "
            <> codeStyle "--force"
            <> ":"
        forM_ existingIgnored $ \ignored -> do
          printStderr $
            "  "
              <> pathStyle ignored.destinationPath
              <> " (pattern: "
              <> codeStyle (pack $ show ignored.pattern)
              <> ")"
      else do
        printStderr' Warning $
          "Skipping "
            <> pack (show $ length existingIgnored)
            <> " ignored file(s):"
        forM_ existingIgnored $ \ignored -> do
          printStderr $
            "  "
              <> pathStyle ignored.destinationPath
              <> " (pattern: "
              <> codeStyle (pack $ show ignored.pattern)
              <> ")"
        printStderr' Hint $
          "Use "
            <> codeStyle "--force"
            <> " to include ignored files."

  -- Handle unregistered files if --include-unregistered is used
  unregisteredCorrespondences <-
    if includeUnregistered
      then do
        unregisteredFiles <- getUnregisteredFiles ctx
        if null unregisteredFiles
          then return []
          else do
            printStderr $
              "Found "
                <> pack (show $ length unregisteredFiles)
                <> " unregistered file(s):"
            forM_ unregisteredFiles $ \unreg -> do
              printStderr $ "  " <> pathStyle unreg.filePath
            isTerminal <- liftIO $ hIsTerminalDevice stdin
            if not isTerminal
              then do
                printStderr' Warning $
                  "Cannot prompt for route selection in non-interactive mode."
                printStderr' Hint $
                  "Run interactively to select routes for unregistered files."
                return []
              else do
                -- Prompt for each unregistered file
                correspondences <- forM unregisteredFiles $ \unreg -> do
                  case unreg.candidateRoutes of
                    [] -> do
                      printStderr' Warning $
                        "No candidate routes for "
                          <> pathStyle unreg.filePath
                          <> ". Skipping."
                      return Nothing
                    [route] -> do
                      -- Auto-select single candidate
                      routeName' <- decodePath route.routeName
                      printStderr' Note $
                        "Auto-selecting route "
                          <> codeStyle (pack routeName')
                          <> " for "
                          <> pathStyle unreg.filePath
                      correspond <- createUnregisteredCorrespondence ctx unreg route
                      return $ Just correspond
                    routes -> do
                      -- Prompt user to select route
                      routeNames <- mapM (decodePath . (.routeName)) routes
                      printStderr $
                        "Select route for " <> pathStyle unreg.filePath <> ":"
                      selectedName <-
                        liftIO $
                          select "Route: " routeNames
                      case [r | (r, name) <- zip routes routeNames, name == selectedName] of
                        (selectedRoute : _) -> do
                          correspond <-
                            createUnregisteredCorrespondence ctx unreg selectedRoute
                          return $ Just correspond
                        [] -> do
                          printStderr' Warning "No matching route found. Skipping."
                          return Nothing
                return [c | Just c <- correspondences]
      else return []

  let allChangedFiles =
        changedFiles ++ changedIgnored ++ unregisteredCorrespondences
  if null allChangedFiles
    then do
      printStderr "No changed files to reflect."
      return ExitSuccess
    else do
      -- Display changed files
      printStderr $
        "Found "
          <> pack (show $ length allChangedFiles)
          <> " changed file(s):"
      forM_ allChangedFiles $ \fc -> do
        printStderr $ "  " <> pathStyle fc.destination.path
      -- Confirm unless --all is specified
      proceed <-
        if allFlag
          then return True
          else do
            isTerminal <- liftIO $ hIsTerminalDevice stdin
            if isTerminal
              then liftIO $ confirm "Reflect all changed files?"
              else return True -- Non-interactive: proceed
      if proceed
        then do
          reflectCorrespondences force allChangedFiles
          return ExitSuccess
        else do
          printStderr "Cancelled."
          liftIO $ exitWith userCancelledError
reflect force allFlag _includeUnregistered explicitSource paths = do
  ctx <- ensureContext
  pathStyle <- pathStyleFor stderr
  absPaths <- liftIO $ mapM makeAbsolute paths
  nonExistents <- filterM (fmap not . exists) absPaths
  let rejectUntrackedMissingPath path (correspond :: FileCorrespondence) =
        when
          ( path `elem` nonExistents
              && correspond.intermediate.stat == Missing
          )
          $ die'
            fileNotFoundError
            ("No such file: " <> pathStyle path <> ".")
  $(logDebugSH) (absPaths :: [OsPath])
  sourcePath' <- liftIO $ makeAbsolute ctx.repository.sourcePath
  let sourcePathPrefix = splitDirectories sourcePath'
  $(logDebugSH) sourcePathPrefix
  let overlappedPaths =
        [ p
        | p <- absPaths
        , sourcePathPrefix `isPrefixOf` splitDirectories p
        ]
  $(logDebugSH) overlappedPaths
  unless (null overlappedPaths) $ do
    dieWithErrors
      sourceCannotBeTargetError
      [ "Cannot reflect "
          <> pathStyle p
          <> " because it is a file inside the repository."
      | p <- overlappedPaths
      ]
  -- Separate directories and files
  (dirPaths, filePaths) <- partitionByType absPaths
  -- For directories: get changed files within
  dirFiles <-
    if null dirPaths
      then return []
      else do
        (allFiles, ws) <- makeCorrespond ctx
        printWarnings ws
        let changedFiles = filter isChanged allFiles
        -- Filter files within the directories
        filterFilesInDirs dirPaths changedFiles
  -- For files: process as before
  codeStyle <- codeStyleFor stderr
  warningLists <- forM filePaths $ \absPath -> do
    (state, ws) <- getRouteState ctx absPath
    case state of
      NotRouted -> do
        if absPath `elem` nonExistents
          then
            die'
              fileNotFoundError
              ("No such file: " <> pathStyle absPath <> ".")
          else do
            manifestFile' <- asks (.manifestFile)
            printWarnings ws
            printStderr'
              Error
              ("File " <> pathStyle absPath <> " is not routed.")
            printStderr'
              Hint
              ("Add a route for it in " <> pathStyle manifestFile' <> ".")
            liftIO $ exitWith fileNotRoutedError
      Routed _ -> do
        return ws
      Ignored name pattern -> do
        routeName' <- decodePath name
        if force
          then do
            printStderr' Note $
              "File "
                <> pathStyle absPath
                <> " is ignored due to pattern "
                <> codeStyle (pack $ show pattern)
                <> " (route name: "
                <> codeStyle (pack routeName')
                <> "), but reflect it anyway as you enforced it using "
                <> codeStyle "-f"
                <> "/"
                <> codeStyle "--force"
                <> " option."
            return ws
          else do
            printWarnings ws
            printStderr' Error $
              "File "
                <> pathStyle absPath
                <> " is ignored due to pattern "
                <> codeStyle (pack $ show pattern)
                <> " (route name: "
                <> codeStyle (pack routeName')
                <> ")."
            printStderr'
              Hint
              $ "You can reflect it anyway by enforcing it using "
                <> codeStyle "-f"
                <> "/"
                <> codeStyle "--force"
                <> " option."
            liftIO $ exitWith ignoredFileError
  autoSelectMode <- getAutoSelectMode
  fileCorrespondences <- forM filePaths $ \p -> do
    (routeMatch, ws) <- findMatchingRoutes ctx p
    printWarnings $ nub ws
    case routeMatch of
      NoMatch ->
        die' fileNotRoutedError ("File " <> pathStyle p <> " is not routed.")
      SingleMatch route -> do
        correspond <- makeCorrespondForRoute ctx p route
        rejectUntrackedMissingPath p correspond
        return correspond
      AmbiguousMatch candidates -> do
        maybeRoute <- disambiguateRoutes autoSelectMode explicitSource candidates
        case maybeRoute of
          Nothing -> do
            -- Disambiguation failed (ErrorOnAmbiguity mode)
            let routeNames =
                  [c.route.routeName | c <- NE.toList candidates]
            routeNameStrs <- mapM decodePath routeNames
            printStderr' Error $
              "Ambiguous source path for "
                <> pathStyle p
                <> ". Multiple routes match:"
            forM_ routeNameStrs $ \name ->
              printStderr' Note $ "  - " <> codeStyle (pack name)
            printStderr'
              Hint
              $ "Use "
                <> codeStyle "--source"
                <> " to specify which source path to use, or set "
                <> codeStyle "DOJANG_AUTO_SELECT=first"
                <> " to auto-select."
            liftIO $ exitWith ambiguousRouteError
          Just route -> do
            correspond <- makeCorrespondForRoute ctx p route
            rejectUntrackedMissingPath p correspond
            return correspond
  -- Combine directory files and individual file correspondences
  let allCorrespondences = dirFiles ++ fileCorrespondences
  -- For directory mode with confirmation
  when (not (null dirPaths) && not allFlag && not (null allCorrespondences)) $ do
    printStderr $
      "Found "
        <> pack (show $ length allCorrespondences)
        <> " changed file(s) in specified directories:"
    forM_ allCorrespondences $ \fc -> do
      printStderr $ "  " <> pathStyle fc.destination.path
    isTerminal <- liftIO $ hIsTerminalDevice stdin
    proceed <-
      if isTerminal
        then liftIO $ confirm "Reflect these files?"
        else return True
    unless proceed $ do
      printStderr "Cancelled."
      liftIO $ exitWith userCancelledError
  let conflicts = filterConflicts allCorrespondences
  $(logDebugSH) conflicts
  unless (force || null conflicts) $ do
    printWarnings $ nub $ concat warningLists
    dieWithErrors
      sourceCannotBeTargetError
      [ "Cannot reflect "
          <> pathStyle c.destination.path
          <> ", since "
          <> pathStyle c.source.path
          <> " is also changed."
      | c <- conflicts
      ]
  reflectCorrespondences force allCorrespondences
  printWarnings $ nub $ concat warningLists
  return ExitSuccess


-- | Check if a file correspondence has changes.
isChanged :: FileCorrespondence -> Bool
isChanged fc = fc.sourceDelta /= Unchanged || fc.destinationDelta /= Unchanged


-- | Partition paths into directories and files.
partitionByType
  :: (MonadFileSystem m)
  => [OsPath]
  -> m ([OsPath], [OsPath])
partitionByType paths = do
  results <- forM paths $ \p -> do
    isDir <- isDirectory p
    return (p, isDir)
  let dirs = [p | (p, True) <- results]
  let files = [p | (p, False) <- results]
  return (dirs, files)


-- | Filter file correspondences to those within the given directories.
filterFilesInDirs
  :: (Monad m)
  => [OsPath]
  -> [FileCorrespondence]
  -> m [FileCorrespondence]
filterFilesInDirs dirPaths correspondences = do
  let dirPrefixes = map splitDirectories dirPaths
  return
    [ fc
    | fc <- correspondences
    , let dstDirs = splitDirectories fc.destination.path
    , any (`isPrefixOf` dstDirs) dirPrefixes
    ]


-- | Perform the actual reflect operation on file correspondences.
reflectCorrespondences
  :: (MonadFileSystem i, MonadIO i)
  => Bool
  -- ^ Force flag.
  -> [FileCorrespondence]
  -> App i ()
reflectCorrespondences force files = do
  pathStyle <- pathStyleFor stderr
  let conflicts = filterConflicts files
  unless (force || null conflicts) $ do
    dieWithErrors
      sourceCannotBeTargetError
      [ "Cannot reflect "
          <> pathStyle c.destination.path
          <> ", since "
          <> pathStyle c.source.path
          <> " is also changed."
      | c <- conflicts
      ]
  forM_ files $ \c -> do
    if c.sourceDelta == Unchanged && c.destinationDelta == Unchanged
      then
        printStderr'
          Note
          ( "File "
              <> pathStyle c.destination.path
              <> " is skipped, since it is the same as file "
              <> pathStyle c.source.path
              <> "."
          )
      else do
        printStderr $
          "Reflect "
            <> pathStyle c.destination.path
            <> " to "
            <> pathStyle c.source.path
            <> "..."
        if c.destinationDelta == Removed
          then do
            cleanupExisting c.source
            cleanupExisting c.intermediate
          else do
            cleanup c.intermediate
            copy c.destination c.intermediate
            cleanup c.source
            copy c.intermediate c.source


-- | Create a 'FileCorrespondence' from a route result and destination path.
makeCorrespondForRoute
  :: (MonadFileSystem m)
  => Context m
  -> OsPath
  -- ^ The destination path.
  -> RouteResult
  -- ^ The selected route.
  -> m FileCorrespondence
makeCorrespondForRoute ctx dstPath 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


cleanup :: (MonadFileSystem i, MonadIO i) => FileEntry -> App i ()
cleanup fileEntry = do
  path <- decodePath fileEntry.path
  case fileEntry.stat of
    Missing -> return ()
    Directory -> do
      $(logDebug) $ "Remove directory recursively: " <> pack path
      removeDirectoryRecursively fileEntry.path
    _ -> do
      $(logDebug) $ "Remove file: " <> pack path
      removeFile fileEntry.path


cleanupExisting :: (MonadFileSystem i, MonadIO i) => FileEntry -> App i ()
cleanupExisting fileEntry = do
  pathExists <- (||) <$> exists fileEntry.path <*> isSymlink fileEntry.path
  when pathExists $ cleanup fileEntry


copy :: (MonadFileSystem i, MonadIO i) => FileEntry -> FileEntry -> App i ()
copy from to = do
  from' <- decodePath from.path
  to' <- decodePath to.path
  case from.stat of
    Directory -> do
      $(logDebug) $ "Create directory recursively: " <> pack to'
      createDirectories to.path
    _ -> do
      let parent = takeDirectory to.path
      parent' <- decodePath parent
      $(logDebug) $ "Create directory recursively: " <> pack parent'
      createDirectories parent
      $(logDebug) $ "Copy file: " <> pack from' <> " -> " <> pack to'
      copyFile from.path to.path


filterConflicts
  :: [FileCorrespondence]
  -> [FileCorrespondence]
filterConflicts corresponds = [c | c <- corresponds, c.sourceDelta /= Unchanged]


-- | Create a FileCorrespondence for an unregistered file.
-- The source file doesn't exist yet, so we create a correspondence that
-- will copy from destination to source.
createUnregisteredCorrespondence
  :: forall m
   . (MonadFileSystem m)
  => Context m
  -> UnregisteredFile
  -> RouteResult
  -> m FileCorrespondence
createUnregisteredCorrespondence ctx unreg route = do
  -- Calculate the relative path from the route's destination to the file
  let relPath = makeRelative (normalise route.destinationPath) (normalise unreg.filePath)
  period <- encodePath "."
  let (interPath, srcPath) =
        if relPath == period
          then
            ( normalise (ctx.repository.intermediatePath </> route.routeName)
            , normalise route.sourcePath
            )
          else
            ( normalise $
                ctx.repository.intermediatePath </> route.routeName </> relPath
            , normalise $ route.sourcePath </> relPath
            )
  -- Create correspondence: source doesn't exist, destination exists
  dstStat <- getFileStat unreg.filePath
  let srcEntry = FileEntry srcPath Missing
  let interEntry = FileEntry interPath Missing
  let dstEntry = FileEntry unreg.filePath dstStat
  return
    FileCorrespondence
      { source = srcEntry
      , sourceDelta = Removed -- Source is "missing" relative to intermediate
      , intermediate = interEntry
      , destination = dstEntry
      , destinationDelta = Added -- Destination was "added" relative to intermediate
      }
 where
  getFileStat :: OsPath -> m FileStat
  getFileStat path = do
    isDir <- isDirectory path
    if isDir
      then return Directory
      else do
        exists' <- exists path
        if exists'
          then do
            size <- getFileSize path
            return $ File size
          else return Missing