packages feed

seihou-core-0.6.0.0: src/Seihou/Core/ArtifactOriginDetect.hs

-- | Turn an absolute artifact directory into a portable 'ArtifactOrigin'.
--
-- Module discovery hands every caller an absolute directory, because
-- @Seihou.Core.Module.defaultSearchPaths@ is built from
-- 'System.Directory.getCurrentDirectory' and
-- 'System.Directory.getXdgDirectory'. Absolute paths must never reach
-- @.seihou\/manifest.json@, which is checked into version control and read
-- on other developers' machines, so every manifest write site funnels its
-- directory through 'detectArtifactOrigin' first.
--
-- The read side of @.seihou-origin.json@ lives here rather than in
-- @seihou-cli@ because this module needs it and @seihou-core@ cannot depend
-- on @seihou-cli-internal@. @Seihou.CLI.InstallShared@ re-exports it, so
-- existing importers are unaffected; the write side ('OriginMeta',
-- @installModuleDir@) stays in the CLI.
module Seihou.Core.ArtifactOriginDetect
  ( detectArtifactOrigin,
    OriginInfo (..),
    readOriginInfo,
  )
where

import Control.Exception (IOException, try)
import Data.Aeson (FromJSON (..), withObject, (.:), (.:?))
import Data.Aeson qualified as Aeson
import Data.ByteString.Lazy qualified as LBS
import Data.Generics.Labels ()
import Data.Text qualified as T
import Seihou.Core.Types (ArtifactOrigin (..))
import Seihou.Prelude
import System.Directory (canonicalizePath, doesFileExist)
import System.FilePath (makeRelative, pathSeparator, takeFileName)

-- | Read side of @.seihou-origin.json@. Tolerates files written by older
-- 'seihou install' runs that may have been missing optional fields.
data OriginInfo = OriginInfo
  { sourceUrl :: !Text,
    repoName :: !(Maybe Text),
    version :: !(Maybe Text)
  }
  deriving stock (Eq, Generic, Show)

instance FromJSON OriginInfo where
  parseJSON = withObject "OriginInfo" $ \v ->
    OriginInfo <$> v .: "sourceUrl" <*> v .:? "repoName" <*> v .:? "version"

-- | Read and parse @.seihou-origin.json@ at the given installed-module
-- directory. Returns 'Nothing' if the file is absent or unparseable.
readOriginInfo :: FilePath -> IO (Maybe OriginInfo)
readOriginInfo installedDir = do
  let path = installedDir </> ".seihou-origin.json"
  exists <- doesFileExist path
  if not exists
    then pure Nothing
    else do
      bs <- LBS.readFile path
      pure (Aeson.decode bs)

-- | Classify an absolute artifact directory into a portable origin.
--
-- @projectRoot@ is the absolute path of the project being generated into
-- (the directory holding @.seihou@). @artifactDir@ is the absolute
-- directory that holds the artifact's @module.dhall@, @recipe.dhall@,
-- @blueprint.dhall@, or @prompt.dhall@.
--
-- Classification, in order:
--
--   1. If @artifactDir@ is inside @projectRoot@, the result is a
--      'ProjectOrigin' holding the path relative to @projectRoot@ with
--      forward slashes.
--   2. Otherwise, if @artifactDir@ contains a readable
--      @.seihou-origin.json@ with a @sourceUrl@, the result is a
--      'RemoteOrigin' carrying that URL, the directory's base name, and
--      the recorded @repoName@.
--   3. Otherwise the result is a 'LocalOrigin' holding the directory's
--      base name.
detectArtifactOrigin :: FilePath -> FilePath -> IO ArtifactOrigin
detectArtifactOrigin projectRoot artifactDir = do
  root <- canonicalizeOr projectRoot
  dir <- canonicalizeOr artifactDir
  case insideProject root dir of
    Just relative -> pure (ProjectOrigin relative)
    Nothing -> do
      originInfo <- readOriginInfo dir
      let name = T.pack (takeFileName dir)
      pure $ case originInfo of
        Just info -> RemoteOrigin (info ^. #sourceUrl) name (info ^. #repoName)
        Nothing -> LocalOrigin name

-- | 'canonicalizePath' throws when an intermediate component does not
-- exist, which happens in tests and for artifacts that were removed between
-- discovery and manifest write. Fall back to the raw path in that case.
canonicalizeOr :: FilePath -> IO FilePath
canonicalizeOr path = do
  result <- try @IOException (canonicalizePath path)
  pure (either (const path) id result)

-- | The artifact directory's path relative to the project root, when it is
-- genuinely inside it.
--
-- 'makeRelative' returns its second argument unchanged when the two paths
-- share no prefix, and returns @"."@ when they are the same directory, so
-- both cases have to be rejected explicitly. A leading @".."@ cannot appear
-- (GHC's 'makeRelative' never produces one) but is rejected anyway so a
-- future implementation change cannot smuggle an escaping path into the
-- manifest.
insideProject :: FilePath -> FilePath -> Maybe FilePath
insideProject root dir
  | relative == dir = Nothing
  | relative == "." = Nothing
  | take 2 relative == ".." = Nothing
  | otherwise = Just (map toForwardSlash relative)
  where
    relative = makeRelative root dir
    toForwardSlash c = if c == pathSeparator then '/' else c