packages feed

git-checklist-1.0.0.0: src/Main.hs

{-# LANGUAGE ScopedTypeVariables #-}
module Main where

import Control.Exception (IOException, catch)
import Control.Monad (when)

import Data.List (intersperse, intercalate)
import Data.Monoid (mconcat)

import Options.Applicative
import qualified Text.Parsec as P
import qualified Text.Parsec.String

import System.Environment (getArgs)
import System.Exit (exitFailure)
import System.FilePath ((</>))
import System.Directory (createDirectoryIfMissing, doesFileExist, getDirectoryContents, removeFile)
import System.Process (readProcess)

import Text.PrettyPrint hiding ((<>), empty)
import qualified Text.PrettyPrint as Pretty

import qualified Paths_git_checklist as V
import Data.Version (showVersion)

-- Git stuff. Find the branch we're on and the .git directory.

git :: String -> IO [String]
git args = lines <$> readProcess "git" (words args) []
            `catch` \(_ :: IOException) -> do
                             putStrLn "Something went wrong, maybe you're not in a Git repository?"
                             exitFailure

getBranch, getGitDir :: IO String
getBranch = head <$> git "symbolic-ref --short HEAD"
getGitDir = head <$> git "rev-parse --show-cdup" >>= \move -> return $ move ++ ".git"

listBranches :: IO [String]
listBranches = do
    root <- getGitDir
    filter (`notElem` [".",".."]) <$> getDirectoryContents (root </> "checklist")
        `catch` \(_ :: IOException) -> return []

-- Data structures!

data ToDo = ToDo { description :: String
                 , complete    :: Bool
                 } deriving (Eq, Read, Show)

data Checklist = Checklist { branchRef :: String
                           , todos     :: [ToDo]
                           } deriving (Eq,Show)

-- Loading and saving data (by cheating).

putChecklist :: Checklist -> IO ()
putChecklist checklist = do
    root <- getGitDir
    let listdir = root </> "checklist"
        output  = todos checklist
    createDirectoryIfMissing False listdir
    if null output
        then removeFile (listdir </> branchRef checklist) `catch`
                (\(_ :: IOError) -> putStrLn "Error cleaning up checklist file in .git/checklist/")
        else writeFile (listdir </> branchRef checklist) (printChecklist checklist)
                `catch` \(_ :: IOException) -> putStrLn "Error writing checklist file."

getChecklist :: String -> IO Checklist
getChecklist branch = do
    root <- getGitDir
    let path = root </> "checklist" </> branch
    exists <- doesFileExist path
    if exists
        then do contents <- readFile path
                case P.parse parseChecklist path contents of
                    Left err -> error $ show err
                    Right todolist -> return $ Checklist branch todolist
        else return (Checklist branch [])


-- TODO
-- quickcheck property: roundtrip
--   parseChecklist . printChecklist (Checklist _ x) == x

parseChecklist :: P.Parsec String () [ToDo]
parseChecklist = parseTodo `P.sepEndBy` P.newline
    where checkmark = P.char '[' *> P.anyChar <* P.char ']' <* P.space
          remaining = P.many1 (P.noneOf "\n")
          parseTodo = do done <- ('x'==) <$> checkmark
                         what <- remaining
                         return $ ToDo { description = what, complete = done }

printChecklist :: Checklist -> String
printChecklist = renderStyle noBreaks . vcat . map prettyTodo . todos
    where noBreaks = style { mode = LeftMode }

upgradeFileFormat :: IO ()
upgradeFileFormat = do
    branchNames <- listBranches
    putStrLn $ "Processing " ++ (show (length branchNames)) ++ " files..."
    mapM_ upgradeOldFormat branchNames
  where
    upgradeOldFormat branch = do
        root <- getGitDir
        let path = root </> "checklist" </> branch
        exists <- doesFileExist path
        if exists
            then do contents <- readFile path
                    putChecklist $ Checklist branch $ read contents
            else putStrLn $ "Failed reading branch " ++ branch

-- Operations: Adding, removing, marking done and not done.

change :: Act -> Checklist -> Checklist
change (Add desc) (Checklist b ts) = Checklist b (ToDo desc False:ts)
change (Remove n) (Checklist b ts) = let del = map snd . filter (\i -> n /= fst i)
                                     in Checklist b (del `withNumbered` ts)
change (Done   n) (Checklist b ts) = Checklist b (mark True n `withNumbered` ts)
change (Undo   n) (Checklist b ts) = Checklist b (mark False n `withNumbered` ts)

withNumbered :: ([(Int,a)] -> [a]) -> [a] -> [a]
f `withNumbered` as = f $ zip [1..] as

mark :: Bool -> Int -> [(Int,ToDo)] -> [ToDo]
mark v n = map f
    where f (i,t) | i == n    = t { complete = v }
                  | otherwise = t

-- Pretty printing! Copes with up to 99 items :-)

view :: Observe -> Checklist -> Doc
view List  checklist = prettyChecklist checklist
view Stats (Checklist _ []) = text "No tasks defined yet"
view Stats (Checklist _ ts) = tasksToDo <+> parens (int (length ts) <+> text "in total")
    where tasksToDo  = case length $ filter (not . complete) ts of
                            0 -> text "Nothing to do!"
                            1 -> text "1 task left!"
                            n -> int n <+> text "tasks to do"

prettyTodo :: ToDo -> Doc
prettyTodo t = brackets xmark <+> text (description t)
    where xmark = if complete t then char 'x' else space

prettyChecklist :: Checklist -> Doc
prettyChecklist = vcat . zipWith rightAlign [1..] . map prettyTodo . todos
    where rightAlign i doc = nest (shiftInt i) $ hcat [int i, colon, space, doc]
          shiftInt n = if n < 10 then 1 else 0 -- Over 100 TODOs is silly.

-- Overall control actions

updateWith :: Act -> [Checklist] -> IO [Checklist]
updateWith a [c] = let c' = change a c
                   in when (c' /= c) (putChecklist c') >> return [c']

viewWith :: Observe -> [Checklist] -> Doc
viewWith o []  = Pretty.empty
viewWith o [c] = view o c
viewWith o cs  = vcat $ vsep $ map (\c -> text (branchRef c) $+$ view o c) cs
    where vsep = punctuate (char '\n') -- join list with blank lines

usingArgs :: Option -> IO ()
usingArgs (Option loc behaviour) = loc2checklist loc >>= operation
    where
    operation :: [Checklist] -> IO ()
    operation = either (\o cs -> print (viewWith o cs))
                       (\a cs -> updateWith a cs >>= print . viewWith List)
                       behaviour

    loc2checklist :: Location -> IO [Checklist]
    loc2checklist All       = listBranches >>= mapM getChecklist
    loc2checklist Head      = getBranch >>= getChecklist >>= \c -> return [c]
    loc2checklist (Named b) = mapM getChecklist [b]

-- Define command line flags and options

data Option = Option Location (Either Observe Act) deriving Show

data Location = Head | Named String | All deriving Show
data Observe = List | Stats deriving Show
data Act = Add String | Remove Int | Done Int | Undo Int deriving Show

cli_observers :: Mod CommandFields Option
cli_observers =
  command "show"
    (info (Option <$> common <*> show_list)
          (progDesc "Show current TODOs"))
  <> command "stats"
    (info (Option <$> common <*> show_stats)
          (progDesc "Summary statistics of checklist"))
    where
        common = location True -- can have apply to many branches
        show_list = pure (Left List)
        show_stats = pure (Left Stats)

cli_actors :: Mod CommandFields Option
cli_actors = mconcat
            [ command "add" (info (Option <$> common <*> add)
                                  (progDesc "Add a TODO"))
            , command "done" (info (Option <$> common <*> done)
                                   (progDesc "Mark a TODO as done."))
            , command "undo" (info (Option <$> common <*> undo)
                                   (progDesc "Item needs redone!"))
            , command "remove" (info (Option <$> common <*> remove)
                                     (progDesc "Remove a TODO (can't be undone)"))
            ]
    where add    = Right . Add . unwords <$> arguments str (metavar "DESCRIPTION")
          remove = Right . Remove <$> argument auto (metavar "N")
          done   = Right . Done <$> argument auto (metavar "N")
          undo   = Right . Undo <$> argument auto (metavar "N")
          common = location False -- cannot apply to many branches

location :: Bool -> Parser Location
location many = nullOption (value Head <> internal)
              <|> Named <$> strOption (long "branch" <> short 'b' <> metavar "BRANCH")
              <|> if many then allbranches else empty
    where allbranches = flag' All (long "all" <> short 'a' <> metavar "")

argParser :: ParserInfo Option
argParser = info (helper <*> version <*> cli)
                    (progDesc "Per-branch TODO list for Git repositories")
    where blank :: Parser Option -- user enters no arguments
          blank = nullOption (value (Option Head (Left List)) <> internal)
          cli = blank <|> hsubparser (cli_observers <> cli_actors)
          version = flip abortOption (long "version" <> short 'v' <> help "Show version")
                        (InfoMsg $ "git-checklist v" ++ showVersion V.version)

main :: IO ()
main = do
    args <- getArgs
    case args of
        ["--upgrade"] -> upgradeFileFormat --secret option
        _             -> execParser argParser >>= usingArgs