packages feed

pkgtreediff-0.6.0: src/Main.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE OverloadedStrings #-}

import Control.Applicative (
#if !MIN_VERSION_simple_cmd_args(0,1,3)
    (<|>),
#endif
#if !MIN_VERSION_base(4,8,0)
    (<$>), (<*>)
#endif
  )
import Control.Concurrent.Async (concurrently)
import Control.Monad
import Control.Monad.Extra (concatMapM)
import qualified Data.ByteString.Char8 as B
import Data.List
import Data.Maybe
import Data.RPM.NVRA
#if !MIN_VERSION_base(4,11,0)
import Data.Semigroup ((<>))
#endif
import qualified Data.Text as T
import qualified Data.Text.Lazy as TL
import Data.Text.Lazy.Encoding (decodeUtf8)
import Network.HTTP.Client (httpLbs, managerResponseTimeout, newManager,
                            parseRequest, responseBody, responseTimeoutMicro)
import Network.HTTP.Client.TLS (tlsManagerSettings)
import Network.HTTP.Directory
import Network.HTTP.Types (hContentType)
import SimpleCmdArgs
import System.Directory (doesDirectoryExist, listDirectory)
import System.FilePath ((</>))
import System.FilePath.Glob (compile, match)
#if !MIN_VERSION_simple_cmd(0,2,0)
-- for warning
import System.IO (hPutStrLn, stderr)
#endif

import SimpleCmd (cmd, error',
#if MIN_VERSION_simple_cmd(0,2,0)
                  warning
#endif
                 )

import qualified Distribution.Koji as Koji

import Distribution.RPM.PackageTreeDiff
import Paths_pkgtreediff (version)

data Summary = AutoSummary
             | NoSummary
             | ShowSummary
  deriving Eq

data Mode = Added
          | Deleted
          | Updated
          | Downgraded
  deriving Eq

main :: IO ()
main =
  simpleCmdArgs (Just version) "Package tree comparison tool"
  "pkgtreediff compares the packages in two OS trees or instances" $
  compareDirs
    <$> recursiveOpt
    <*> optional subdirOpt
    <*> ignoreVR
    <*> many modeOpt
    <*> summaryOpt
    <*> switchWith 'R' "rst" "Use ReSTructured Text format"
    <*> optional patternOpt
    <*> timeoutOpt
    <*> sourceArg "1"
    <*> sourceArg "2"
  where
    sourceArg :: String -> Parser String
    sourceArg pos = strArg ("URL|DIR|FILE|KOJITAG|CMD" <> pos)

    modeOpt :: Parser Mode
    modeOpt =
      flagWith' Added 'N' "new" "Show only added packages" <|>
      flagWith' Deleted 'D' "deleted" "Show only removed packages" <|>
      flagWith' Updated 'U' "updated" "Show only upgraded packages" <|>
      flagWith' Downgraded 'u' "downgraded" "Show only downgraded packages"

    summaryOpt =
      flagWith' ShowSummary 's' "show-summary" ("Show summary of changes (default when >" <> show summaryThreshold <> " changes)") <|>
      flagWith AutoSummary NoSummary 'S' "no-summary" "Do not display summary"

    -- ignoreArch :: Parser Bool
    -- ignoreArch = switchWith 'A' "ignore-arch" "Ignore arch differences"

    ignoreVR :: Parser Ignore
    ignoreVR =
      flagWith' IgnoreRelease 'R' "ignore-release" "Only show version changes (ignore release)" <|>
      flagWith IgnoreNone IgnoreVersion 'V' "ignore-version" "Only show package changes (ignore version-release)"

    recursiveOpt :: Parser Bool
    recursiveOpt = switchWith 'r' "recursive" "Recursive down into subdirectories"

    subdirOpt :: Parser String
    subdirOpt = strOptionWith 'd' "subdir" "SUBDIR" "Select specific subdir (eg x86_64 or source)"

    patternOpt :: Parser String
    patternOpt = strOptionWith 'p' "pattern" "PKGPATTERN" "Limit packages to glob matches"

    timeoutOpt :: Parser Int
    timeoutOpt = optionalWith auto 't' "timeout" "SECONDS" "Maximum seconds to wait for http response before timing out (default 30)" 30

-- | The threshold for the number of differences for compareDirs to auto-output a summary
summaryThreshold :: Int
summaryThreshold = 20

data SourceType = URL | Tag | Dir | File | Cmd
  deriving Eq

-- >>> kojiUrlTag "koji://tag@fedora"
-- Just ("tag", "https://koji.fedoraproject.org/kojihub")
kojiUrlTag :: String -> Maybe (String, String)
kojiUrlTag s = if not (isKojiScheme s)
               then Nothing
               else case elemIndex '@' s of
                      Just pos -> Just (drop (length kojiScheme) $ take pos s, hubUrl $ drop (pos+1) s)
                      Nothing -> Nothing
  where
    kojiScheme = "koji://"
    isKojiScheme loc = kojiScheme `isPrefixOf` loc
    hubUrl "fedora" = Koji.fedoraKojiHub
    hubUrl "centos" = Koji.centosKojiHub
    hubUrl loc = loc

sourceType :: String -> IO SourceType
sourceType s
  | isHttp s  = return URL
  | isKoji s  = return Tag
  | otherwise = do
      dir <- doesDirectoryExist s
      if dir then return Dir
        else if ' ' `elem` s then return Cmd
             else return File
  where
    isKoji :: String -> Bool
    isKoji loc = isJust (kojiUrlTag loc)
    isHttp :: String -> Bool
    isHttp loc = "http:" `isPrefixOf` loc || "https:" `isPrefixOf` loc

-- | Frontend for the pkgtreediff tool
compareDirs :: Bool -> Maybe String -> Ignore -> [Mode] -> Summary -> Bool
            -> Maybe String -> Int -> String -> String -> IO ()
compareDirs recursive msubdir ignore modes summary rst mpattern timeout tree1 tree2 = do
  (ps1,ps2) <- getTrees tree1 tree2
  let diff = diffPkgs ignore ps1 ps2
  if rst
    then printRST diff
    else mapM_ putStrLn . mapMaybe (showPkgDiff modes) $ diff
  when (summary /= NoSummary && null modes) $
    when (summary == ShowSummary || length diff > summaryThreshold) $ do
    putStrLn ""
    (if rst then printRSTHeader else putStrLn) "Summary"
    let diffsum = changesummary diff
    putStrLn $ "Updated: " <> show (updateSum diffsum)
    putStrLn $ "Downgraded: " <> show (downgradeSum diffsum)
    putStrLn $ "Added: " <> show (newSum diffsum)
    putStrLn $ "Deleted: " <> show (delSum diffsum)
    putStrLn $ "Arch changed: " <> show (archSum diffsum)
    putStrLn $ "Total packages: " <> show (length ps1) <> " -> " <> show (length ps2)
  where
    printRSTHeader name = do
      putStrLn ""
      putStrLn name
      putStrLn $ replicate (length name) '~'
      putStrLn ""
    printRSTElem = putStrLn . mappend "- "
    printRSTDiffElem = printRSTElem . drop 2
    printRST diff = do
      printRSTHeader "Updated"
      mapM_ printRSTElem $ mapMaybe (showPkgDiff modes) [x | x@(PkgUpdate _ _) <- diff]
      printRSTHeader "Downgraded"
      mapM_ printRSTElem $ mapMaybe (showPkgDiff modes) [x | x@(PkgDowngrade _ _) <- diff]
      printRSTHeader "Added"
      mapM_ printRSTDiffElem $ mapMaybe (showPkgDiff modes) [x | x@(PkgAdd _) <- diff]
      printRSTHeader "Removed"
      mapM_ printRSTDiffElem $ mapMaybe (showPkgDiff modes) [x | x@(PkgDel _) <- diff]

    getTrees :: String -> String -> IO ([NVRA],[NVRA])
    getTrees t1 t2 = do
      when (t1 == t2) $ warning "Comparing the same tree!"
      src1 <- sourceType t1
      src2 <- sourceType t2
      mmgr <- if src1 == URL || src2 == URL
        then do
        let ms = responseTimeoutMicro $ timeout * 1000000
        Just <$> newManager tlsManagerSettings {managerResponseTimeout = ms}
        else return Nothing
      let act1 = readPackages src1 mmgr t1
          act2 = readPackages src2 mmgr t2
      if (src1,src2) == (Cmd,Cmd)
        then do
        ps1 <- act1
        ps2 <- act2
        return (ps1,ps2)
        else concurrently act1 act2

    readPackages :: SourceType -> Maybe Manager -> String -> IO [NVRA]
    readPackages source mmgr loc = do
      fs <- case source of
              URL -> httpPackages True (fromJust mmgr) loc
              Tag -> kojiPackages (fromJust (kojiUrlTag loc))
              Dir -> dirPackages True loc
              File -> filePackages loc
              Cmd -> cmdPackages $ words loc
      let ps = map readNVRA $ maybe id (filter . (match . compile)) mpattern fs
      return $ sort ps

    httpPackages :: Bool -> Manager -> String -> IO [String]
    httpPackages recurse mgr url = do
      exists <- httpExists mgr url
      fs <-
        if exists
        then do
          mcontenttype <- lookup hContentType <$> httpFileHeaders mgr url
          if mcontenttype == Just (B.pack "text/plain; charset=UTF-8")
            then do
            request <- parseRequest url
            body <- responseBody <$> httpLbs request mgr
            return $ (filter (".rpm" `isSuffixOf`) . map TL.unpack . TL.lines . decodeUtf8) body
            else map T.unpack . filter (\f -> "/" `T.isSuffixOf` f || ".rpm" `T.isSuffixOf` f) <$> httpDirectory mgr url
        else error' $ "Could not get " <> url
      if (recurse || recursive) && all isDir fs
        then concatMapM (httpPackages False mgr) (map (url </>) (filterSubdir fs))
        else return $ filter (not . isDir) fs

    filterSubdir :: [String] -> [String]
    filterSubdir fs =
      case msubdir of
        Just subdir | (subdir <> "/") `elem` fs -> [subdir]
        _ -> fs

    dirPackages recurse dir = do
      fs <- map (dir </>) . sort <$> listDirectory dir
      alldirs <- mapM doesDirectoryExist fs
      if (recurse || recursive) && and alldirs
        then concatMapM (dirPackages False) (filterSubdir fs)
        -- FIXME strictly should limit to files
        else return $ filter (".rpm" `isSuffixOf`) fs

    isDir = ("/" `isSuffixOf`)

    filePackages file =
      filter (not . isPrefixOf "gpg-pubkey-") . words <$> readFile file

    cmdPackages [] = error' "No command prefix given"
    cmdPackages (c:args) =
      -- use words since container seems to append '\r'
      filter (not . isPrefixOf "gpg-pubkey-") . words <$> cmd c args

    kojiPackages (tag, kojiUrl) = map Koji.kbNvr <$> Koji.kojiListTaggedBuilds kojiUrl True tag

showPkgDiff :: [Mode] -> RPMPkgDiff -> Maybe String
showPkgDiff modes diff =
  case diff of
    PkgAdd p -> maybeShowDiff Added "+ " $ showNVRA p
    PkgDel p -> maybeShowDiff Deleted "- " $ showNVRA p
    PkgUpdate p1 p2 -> maybeShowDiff Updated "" $ showPkgChange p1 p2
    PkgArch p1 p2 -> maybeShowDiff Updated "" $ showArchChange p1 p2
    PkgDowngrade p1 p2 -> maybeShowDiff Downgraded "~ " $ showPkgChange p1 p2
  where
    maybeShowDiff :: Mode -> String -> String -> Maybe String
    maybeShowDiff m prefix ds
      | null modes || (m `elem` modes && length modes > 1)
      = Just $ prefix ++ ds
      | modes == [m] = Just ds
      | otherwise = Nothing

    showPkgChange :: NVRA -> NVRA -> String
    showPkgChange p p' =
      showPkgIdent p <> ": " <> showPkgVerRel p <> " -> " <> showPkgVerRel p'

    showArchChange :: NVRA -> NVRA -> String
    showArchChange p p' =
      rpmName p <> ": " <> rpmDetails p <> " -> " <> rpmDetails p'
      where
        rpmDetails :: NVRA -> String
        rpmDetails pkg = showPkgVerRel pkg <> "." <> rpmArch pkg

data DiffSum = DS {updateSum, downgradeSum, newSum, delSum, archSum :: Int}

emptyDS :: DiffSum
emptyDS = DS 0 0 0 0 0

changesummary :: [RPMPkgDiff] -> DiffSum
changesummary =
  foldl' countDiff emptyDS
  where
    countDiff :: DiffSum -> RPMPkgDiff -> DiffSum
    countDiff ds pd =
      case pd of
        PkgUpdate {} -> ds {updateSum = updateSum ds + 1}
        PkgDowngrade {} -> ds {downgradeSum = downgradeSum ds + 1}
        PkgAdd _ -> ds {newSum = newSum ds + 1}
        PkgDel _ -> ds {delSum = delSum ds + 1}
        PkgArch {} -> ds {archSum = archSum ds + 1}

#if !MIN_VERSION_simple_cmd(0,2,0)
warning :: String -> IO ()
warning = hPutStrLn stderr
#endif