huggingface-0.1.0.0: src/HuggingFace/Types/Repository.hs
{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE OverloadedStrings #-}
-- |
-- Module : HuggingFace.Types.Repository
-- Description : Repository names, revisions, paths, and commit identities
-- Copyright : (c) 2026 Emily Pillmore
-- License : BSD-3-Clause
-- Maintainer : emilypi@cohomolo.gy
-- Stability : experimental
-- Portability : GHC
module HuggingFace.Types.Repository
( RepoId, parseRepoId, repoIdText, repoOwner, repoName, qualifiedRepo
, RepoName, parseRepoName, repoNameText
, AccountName, parseAccountName, accountNameText
, Revision, parseRevision, revisionText, mainRevision, commitRevision
, RepoPath, parseRepoPath, repoPathText
, CommitSha, parseCommitSha, commitShaText
, RepoType (..)
, repoIdParser, repoPlural
) where
import Control.Monad (replicateM_, void)
import Data.Aeson
import Data.Char (isAscii, isHexDigit, isControl)
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.List.NonEmpty as NE
import Data.Text (Text)
import qualified Data.Text as T
import qualified Text.Parsec as P
import HuggingFace.Error (HuggingFaceError)
import HuggingFace.Utils (TextParser, parseValue, parseTextJSON, matched, asciiAlphaNum, takeWhile1P)
-- | Repository basename, distinct from its optional owner.
newtype RepoName = RepoName
{ repoNameText :: Text
-- ^ Render a repository basename.
}
deriving stock (Show)
deriving (Eq, Ord, ToJSON) via Text
-- | Parse a Hub repository basename.
parseRepoName :: Text -> Either HuggingFaceError RepoName
parseRepoName = parseValue "Expected a Hub repository basename" (RepoName <$> nameParser)
instance FromJSON RepoName where parseJSON = parseTextJSON "RepoName" parseRepoName
-- | Repository identity with an optional owner and a basename. Parsers and
-- smart constructors assemble it from parsed names.
data RepoId = RepoId
{ repoOwner :: Maybe AccountName -- ^ Optional owner of a repository.
, repoName :: RepoName -- ^ Repository basename.
}
deriving stock (Eq, Ord, Show)
-- | Parse either a bare repository name or @owner/name@.
parseRepoId :: Text -> Either HuggingFaceError RepoId
parseRepoId = parseValue "Expected a repository name or owner/name" repoIdParser
-- | Compose a repository parser with a larger grammar, such as a routing suffix.
-- Use 'parseRepoId' when parsing a complete external value.
repoIdParser :: TextParser RepoId
repoIdParser = do
first <- nameParser
P.option (RepoId Nothing (RepoName first)) $
qualifiedRepo (AccountName first) . RepoName <$> (P.char '/' *> nameParser)
-- | Assemble a qualified repository from parsed components without reparsing.
qualifiedRepo :: AccountName -> RepoName -> RepoId
qualifiedRepo owner = RepoId (Just owner)
-- | Render a repository identity for display or persistence.
repoIdText :: RepoId -> Text
repoIdText (RepoId owner name) = maybe "" ((<> "/") . accountNameText) owner <> repoNameText name
instance ToJSON RepoId where toJSON = toJSON . repoIdText
instance FromJSON RepoId where parseJSON = parseTextJSON "RepoId" parseRepoId
-- | Branch, tag, or commit reference; slash-separated components are retained.
newtype Revision = Revision (NonEmpty Text)
deriving stock (Show)
deriving (Eq, Ord) via (NonEmpty Text)
-- | Parse a revision. Slashes are encoded together as one URL segment.
parseRevision :: Text -> Either HuggingFaceError Revision
parseRevision = parseValue "Expected nonempty revision segments without traversal, controls, or backslashes" (Revision <$> components)
-- | Render a revision.
revisionText :: Revision -> Text
revisionText (Revision parts) = T.intercalate "/" (NE.toList parts)
-- | The default Hub branch.
mainRevision :: Revision
mainRevision = Revision ("main" :| [])
-- | Pin a revision to an already parsed full commit SHA.
commitRevision :: CommitSha -> Revision
commitRevision sha = Revision (commitShaText sha :| [])
instance ToJSON Revision where toJSON = toJSON . revisionText
instance FromJSON Revision where parseJSON = parseTextJSON "Revision" parseRevision
-- | Nonempty relative file path with no empty, dot, or parent segments.
newtype RepoPath = RepoPath (NonEmpty Text)
deriving stock (Show)
deriving (Eq, Ord) via (NonEmpty Text)
-- | Parse a repository-relative path, preserving Unicode and spaces.
parseRepoPath :: Text -> Either HuggingFaceError RepoPath
parseRepoPath = parseValue "Expected a relative repository file path without traversal, controls, or backslashes" (RepoPath <$> components)
-- | Render a repository-relative path.
repoPathText :: RepoPath -> Text
repoPathText (RepoPath parts) = T.intercalate "/" (NE.toList parts)
instance ToJSON RepoPath where toJSON = toJSON . repoPathText
instance FromJSON RepoPath where parseJSON = parseTextJSON "RepoPath" parseRepoPath
-- | Full SHA-1 commit identifier, stored in canonical lowercase form.
newtype CommitSha = CommitSha
{ commitShaText :: Text -- ^ Render a full commit identifier.
}
deriving stock (Show)
deriving (Eq, Ord, ToJSON) via Text
-- | Parse exactly forty ASCII hexadecimal digits. Use 'parseRevision' for
-- abbreviated commit references, branches, or tags.
parseCommitSha :: Text -> Either HuggingFaceError CommitSha
parseCommitSha = parseValue "CommitSha requires forty hexadecimal digits" $
CommitSha . T.toLower <$> matched (replicateM_ 40 (P.satisfy (\c -> isAscii c && isHexDigit c)))
instance FromJSON CommitSha where parseJSON = parseTextJSON "CommitSha" parseCommitSha
-- | Account or organization namespace.
newtype AccountName = AccountName
{ accountNameText :: Text -- ^ Render an account or organization name.
}
deriving stock (Show)
deriving (Eq, Ord, ToJSON) via Text
-- | Parse a AccountName from external text.
parseAccountName :: Text -> Either HuggingFaceError AccountName
parseAccountName = parseValue "Expected a Hub account or organization name" (AccountName <$> nameParser)
instance FromJSON AccountName where parseJSON = parseTextJSON "AccountName" parseAccountName
-- | Hub repository kinds.
data RepoType = Model | Dataset | Space
deriving stock (Eq, Ord, Show, Enum, Bounded)
-- | Hub collection path component for a repository kind.
repoPlural :: RepoType -> Text
repoPlural Model = "models"
repoPlural Dataset = "datasets"
repoPlural Space = "spaces"
-- Hub name components start/end with a word character, cannot repeat '.' or
-- '-', and cannot end in '.git'. Length is bounded while consuming input.
nameParser :: TextParser Text
nameParser = matched $ word *> rest (95 :: Int)
where
word = P.satisfy (\c -> asciiAlphaNum c || c == '_')
nameChar = P.satisfy (\c -> asciiAlphaNum c || c == '_' || c == '-' || c == '.')
next = void word P.<|> do
P.notFollowedBy (P.try (P.string ".git" <* P.notFollowedBy nameChar))
punctuation <- P.char '-' P.<|> P.char '.'
P.notFollowedBy (P.char punctuation)
void (P.lookAhead nameChar)
rest 0 = P.notFollowedBy nameChar
rest n = P.optional (next *> rest (n - 1))
components :: TextParser (NonEmpty Text)
components = (:|) <$> component <*> P.many (P.char '/' *> component)
where
component = do
part <- takeWhile1P (\c -> c /= '/' && c /= '\\' && not (isControl c))
case part of
"." -> P.parserFail "Dot segment"
".." -> P.parserFail "Parent segment"
_ -> pure part