packages feed

darcs-graph-1.0: Graph.hs

--
-- Copyright (c) 2006 Don Stewart - http://www.cse.unsw.edu.au/~dons
-- GPL version 2 or later (see http://www.gnu.org/copyleft/gpl.html)
--

--
-- darcs-graph:
--  generate .png graphs of commit activity of a darcs repository,
--  including a 7 day sliding average
--
-- requires gnuplot
--

import Data.List
import Data.Maybe
import qualified Data.IntMap as I

import Data.ByteString.Char8 (ByteString,pack,unpack)
import qualified Data.ByteString.Char8 as C
import qualified Data.ByteString as B

import Control.Exception        (bracket,handle)
import Control.Monad            (when)

import System.Console.GetOpt
import System.Environment       (getArgs)
import System.IO
import System.IO.Unsafe
import System.Directory
import System.Process
import System.Exit
import Data.Time
import System.Locale

tmp :: FilePath
tmp = unsafePerformIO $ getTemporaryDirectory

data Flag = DateFlag String | YFlag Int | Help | OFile FilePath | PName String
    deriving (Show,Eq)

options :: [OptDescr Flag]
options = [
  Option ['f'] ["filter"] (ReqArg (\s -> DateFlag s) "DATE")
        "Don't display patches before date. Date is in format \"yyyymmdd\"",
  Option ['y'] ["y"]      (ReqArg (\s -> YFlag (read s)) "INT")
        "Vertical scale of graphs. Integer.",
  Option ['o'] ["output"] (ReqArg (\s -> OFile s) "FILE")
        "Filename to write output to.",
  Option ['n'] ["name"]   (ReqArg (\s -> PName s) "NAME")
        "Name of project used in generated graph.",
  Option ['h'] ["help"]   (NoArg Help)
        "Display this help"
  ]

usage :: String
usage = usageInfo "usage: darcs-graph [options] repo\n" options

readOptions :: (Monad m) => [String] -> m ([Flag], [String])
readOptions args =
  case getOpt Permute options args of
    (o, n, [])    -> return (o, n)
    (_, _, errs)  -> fail $ unlines errs

getFilter :: [Flag] -> Maybe Day--ByteString
getFilter l =
  let l' = [s | (DateFlag s) <- l]
  in if null l' then Nothing else parseTime defaultTimeLocale "%Y%m%d" (head l')

getYFlag :: [Flag] -> Maybe Int
getYFlag l =
  let l' = [i | (YFlag i) <- l]
  in if null l' then Nothing else Just . head $ l'

getOFile :: [Flag] -> String -> FilePath
getOFile flags proj = case [ s | OFile s <- flags ] of
    [] -> tmp </> proj ++ "-commits.png"
    (fn:_) -> fn

getPName :: [Flag] -> String -> String
getPName flags proj = case [ s | PName s <- flags ] of
    [] -> proj
    (fn:_) -> fn

data Range = Small | Big | Normal
    deriving Eq

-- let's go
main :: IO ()
main = do
    args <- getArgs
    (flags, remaining) <- readOptions args
    when (null remaining || length remaining > 1 || Help `elem` flags) $ error usage
    let path    = pack . head $ remaining
        mfilter = getFilter flags
        mymax   = getYFlag flags

    -- aquire some temp files, make sure to clean them up afterwards
    bracket
        (do x <- openTempFile tmp "darcs-graph.data"
            y <- openTempFile tmp "darcs-graph-average.data"
            return (x,y))
        (\((f,_),(g,_)) -> removeFile f >> removeFile g) $ \((f,h),(g,i)) -> do

    changes <- darcsChanges (unpack path)

    let stats' = uniq $ sort $ parseChanges changes

    -- filter out stats earlier than 'yyyymmdd'
    let stats  | (Just date) <- mfilter = filter (\(x,_) -> x > date) stats'
               | otherwise              = stats'

    when (null stats) $ error $ "no patches found in: "++unpack path
    (C.hPutStr h . C.unlines . map fmt) stats >> hClose h

    -- now work out the sliding average, and write it out too
    let days = enumDates (fst . head $ stats) (fst . last $ stats)
        avrg = sliding (map readFst stats) (map toInt days)
    (C.hPutStr i . C.unlines . map fmt') avrg >> hClose i

    -- now run gnuplot on the result
    let proj   = (unpack . basename $ path)
        out    = getOFile flags proj
        script = gnuplotScript f    -- handle small repos
                    -- can't generate average with less than 7 points,
                    -- and the sliding line needs 3 points to plot csplines
                    (if length days >= window && length avrg >= 3
                        then Just g
                        else Nothing)

                    (if length days < 120
                        then Small
                        else if length days > 2500
                            then Big
                            else Normal) -- only print days or months if range is ok
                    (getPName flags proj)
                    mymax
                    out


    (ih,_,eh,pid) <- handle
        (\e -> error $ "Couldn't fork gnuplot: " ++ show e)
        (do mgnu <- findExecutable "gnuplot"
            case mgnu of
                Nothing      -> error "Cannot find gnuplot"
                Just gnuplot -> runInteractiveCommand gnuplot)

    hPutStrLn ih script >> hClose ih -- write into gnuplot
    hGetContents eh >>= putStr       -- dump any error output it produces
    waitForProcess pid

    putStrLn $ "Output written to: " ++ out

    where
      readFst (a,b) = (toInt a, b)
      uniq          = map (\n -> (head n, length $ n)) . group

      fmtCT c    = formatTime defaultTimeLocale "%Y%m%d" c
      fmt (s,n)  = joinWithSpace (fmtCT s)               (show n)
      fmt' (n,d) = joinWithSpace (show n) (show d)

      joinWithSpace s t = B.intercalate (B.singleton 32) [pack s,pack t]

parseChanges :: ByteString -> [Day]
parseChanges chs
  = worker (C.lines chs)
  where worker [] = []
        worker (c:cs) | Just utcTime <- parseTime defaultTimeLocale "%a %b %e %X %Z %Y" (unwords $ take 6 $ words $ C.unpack c)
          = utcTime:worker cs
        worker (_:cs) = worker cs

darcsChanges :: FilePath -> IO ByteString
darcsChanges path
  = do (inh,outh,errh,pid) <- runInteractiveProcess "darcs" ["changes","--repo="++path] Nothing Nothing
       hClose inh
       hClose errh
       out <- B.hGetContents outh
       code <- waitForProcess pid
       case code of
         ExitFailure c -> do hPutStrLn stderr "`darcs --changes` failed."
                             exitWith (ExitFailure c)
         ExitSuccess   -> return out

------------------------------------------------------------------------
-- Dealing with clock times

--
-- enumerate all the days, as ClockTimes, between the two dates
--
enumDates :: Day -> Day -> [Day]
enumDates d0 dn = map (\d -> addDays d d0) [0 .. days]
    where
        days    = diffDays dn d0

--
-- convert a ClockTime back to an Int in yyyymmdd format
--
toInt :: Day -> Int
toInt ct = read $ formatTime defaultTimeLocale "%Y%m%d" ct

-- sliding average window
window :: Int
window = 7

--
-- compute the 7-day sliding average, starting with a sparse sequence
--
sliding :: [(Int,Int)] -> [Int] -> [(Int,Double)]
sliding [] _    = []
sliding xs days = [ (i,slidingAv i) | i <- days' ]
  where
    m     = window `div` 2 -- 1/2 n

    days' = drop m . take (max 0 (length days - window+m+1)) $ days
    table = I.fromList xs :: I.IntMap Int -- to handle missing elems easily

    slidingAv :: Int -> Double
    slidingAv i = fromIntegral (sum seqN) / fromIntegral window
        where seqN = map (\j -> I.findWithDefault 0 j table) [i-m..i+(window-m-1)]

--
-- generate the gnuplot script. only generate the avergage line, if
-- there repo day range is larger than a window
--
gnuplotScript :: FilePath -> Maybe FilePath -> Range -> String -> Maybe Int -> FilePath -> String
gnuplotScript f mg range p mymax outFile = header ++ case mg of
    Nothing -> "plot \""++f++"\" using 1:2 with points pointsize 0.5 pointtype 2\n"
    Just g  -> "plot \"" ++
                    f++"\" using 1:2 with points pointsize 0.5 pointtype 2, \"" ++
                    g++"\" using 1:2 smooth csplines 2\n"

  where header = unlines
           ["set xdata time"
           ,"set timefmt \"%Y%m%d\""
           ,case range of
                Small  -> "set format x\"%y%m%d\""
                Normal -> "set format x\"%y%m\""
                Big    -> "set format x\"%y\""
           ,"set size 1,0.4"
           ,"set mxtics 0"
           ,"set xtics nomirror"
           ,"set ytics nomirror"
           ,"set border 3"
           ,"set yrange [0:" ++ (if isNothing mymax then "*" else show . fromJust $ mymax) ++ "]"
           ,"set title \""++p++" commits per day and 7-day sliding average\""
           ,"unset key"
           ,"set ylabel \"commits per day\""

-- To use a TTF font, set the GDFONTPATH=/home/dons/TTF, and uncomment
-- this line:
--         ,"set terminal png font Vera 9 xffffff x000000 x404040 xd0d0d0 x0000ff"

           ,"set terminal png small xffffff x000000 x404040 xd0d0d0 x0000ff"
           ,"set output \"" ++ outFile ++ "\""]

--
-- ByteString utilities
--

-- | Packed string version of basename
basename :: ByteString -> ByteString
basename fps = case C.elemIndexEnd '/' s of
    Nothing -> fps
    Just i  -> C.drop (i+1) s
    where s = C.reverse . C.dropWhile (=='/') . C.reverse $ fps

-- useful
infixr 6 </>

(</>) :: FilePath -> FilePath -> FilePath
[] </> b = b
a  </> b = a ++ "/" ++ b