packages feed

niv-0.3.0: src/Niv/Cli.hs

{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE ViewPatterns #-}

module Niv.Cli where

import Control.Applicative
import Control.Monad
import Control.Monad.Reader
import Data.Aeson ((.=))
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Key as K
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as B8
import qualified Data.ByteString.Lazy as BL
import Data.Char (isSpace)
import Data.Either (partitionEithers)
import Data.Functor ((<&>))
import qualified Data.HashMap.Strict as HMS
import Data.HashMap.Strict.Extended
import Data.List (find)
import Data.Maybe
import Data.String (fromString)
import Data.String.QQ (s)
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Data.Text.IO as T
import Data.Version (showVersion)
import GHC.IO.Encoding (utf8)
import qualified Network.HTTP.Simple as HTTP
import Niv.Cmd
import Niv.Git.Cmd hiding (abort)
import Niv.GitHub.Cmd
import Niv.Local.Cmd
import Niv.Logger hiding (Job)
import qualified Niv.Logger
import Niv.Sources
import Niv.Update
import qualified Options.Applicative as Opts
import qualified Options.Applicative.Help.Pretty as Opts
-- I died a little
import Paths_niv (version)
import qualified System.Directory as Dir
import System.Exit (exitFailure)
import System.FilePath (takeDirectory)
import System.IO (hSetEncoding)
import UnliftIO
import UnliftIO.Concurrent

-- | An IO Monad with some configuration:
-- * FindSourcesJson: how to find sources.json (known path, discover, etc)
-- * [Cmd]: the update types
newtype NIO a = NIO {runNIO :: ReaderT (FindSourcesJson, [Cmd]) IO a}
  deriving (Functor, Applicative, Monad, MonadIO, MonadFail, MonadReader (FindSourcesJson, [Cmd]))

type Job = Niv.Logger.Job NIO

instance MonadUnliftIO NIO where
  withRunInIO = wrappedWithRunInIO NIO runNIO

getFindSourcesJson :: NIO FindSourcesJson
getFindSourcesJson = fst <$> ask

getCmds :: NIO [Cmd]
getCmds = snd <$> ask

cli :: [String] -> IO ()
cli args = do
  ((fsj, colors), nio) <-
    pure args >>= Opts.handleParseResult . execParserPure' Opts.defaultPrefs opts
  setColors colors
  hSetEncoding stdout utf8 -- required for printing out unicode on some systems
  runReaderT (runNIO nio) (fsj, [gitCmd, localCmd, githubCmd])
  warnIfOutdated
  where
    execParserPure' pprefs pinfo [] =
      Opts.Failure $
        Opts.parserFailure pprefs pinfo (Opts.ShowHelpText Nothing) mempty
    execParserPure' pprefs pinfo as = Opts.execParserPure pprefs pinfo as
    opts = Opts.info ((,) <$> ((,) <$> parseFindSourcesJson <*> parseColors) <*> (parseCommand <**> Opts.helper <**> versionflag)) $ mconcat desc
    desc =
      [ Opts.headerDoc $
          Just $
            Opts.vcat
              [ "niv - dependency manager for Nix projects",
                "",
                "version:"
                  Opts.<+> Opts.pretty (showVersion version)
              ]
      ]
    parseFindSourcesJson =
      AtPath
        <$> Opts.strOption
          ( Opts.long "sources-file"
              <> Opts.short 's'
              <> Opts.metavar "FILE"
              <> Opts.help "Use FILE instead of nix/sources.json"
          )
        <|> pure Auto
    parseColors =
      (\case True -> Never; False -> Always)
        <$> Opts.switch
          ( Opts.long "no-colors"
              <> Opts.help "Don't use colors in output"
          )
    versionflag :: Opts.Parser (a -> a)
    versionflag =
      Opts.abortOption (Opts.InfoMsg (showVersion version)) $
        mconcat
          [Opts.long "version", Opts.hidden, Opts.help "Print version"]

parseCommand :: Opts.Parser (NIO ())
parseCommand =
  Opts.subparser
    ( Opts.command "init" parseCmdInit
        <> Opts.command "add" parseCmdAdd
        <> Opts.command "show" parseCmdShow
        <> Opts.command "update" parseCmdUpdate
        <> Opts.command "rename" parseCmdRename
        <> Opts.command "modify" parseCmdModify
        <> Opts.command "drop" parseCmdDrop
        <> Opts.command "version" parseCmdVersion
    )
    <|> Opts.subparser (Opts.internal <> Opts.command "debug" parseCmdDebug)

parsePackageName :: Opts.Parser PackageName
parsePackageName =
  PackageName
    <$> Opts.argument Opts.str (Opts.metavar "PACKAGE")

-------------------------------------------------------------------------------
-- INIT
-------------------------------------------------------------------------------

-- | Whether or not to fetch nixpkgs
data FetchNixpkgs
  = NoNixpkgs
  | NixpkgsFast -- Pull latest known nixpkgs
  | NixpkgsCustom T.Text Nixpkgs -- branch, nixpkgs
  deriving (Show)

data Nixpkgs = Nixpkgs T.Text T.Text -- owner, repo

instance Show Nixpkgs where
  show (Nixpkgs o r) = T.unpack o <> "/" <> T.unpack r

parseCmdInit :: Opts.ParserInfo (NIO ())
parseCmdInit = Opts.info (cmdInit <$> parseNixpkgs <**> Opts.helper) $ mconcat desc
  where
    desc =
      [ Opts.progDesc
          "Initialize a Nix project. Existing files won't be modified."
      ]

parseNixpkgs :: Opts.Parser FetchNixpkgs
parseNixpkgs = parseNixpkgsFast <|> parseNixpkgsLatest <|> parseNixpkgsCustom <|> parseNoNixpkgs <|> pure NixpkgsFast
  where
    parseNixpkgsFast =
      Opts.flag'
        NixpkgsFast
        ( Opts.long "fast"
            <> Opts.help "Use the latest nixpkgs cached at 'https://github.com/nmattia/niv/blob/master/data/nixpkgs.json'. This is the default."
        )
    parseNixpkgsLatest =
      Opts.flag'
        (NixpkgsCustom "master" (Nixpkgs "NixOS" "nixpkgs"))
        ( Opts.long "latest"
            <> Opts.help "Pull the latest unstable nixpkgs from NixOS/nixpkgs."
        )
    parseNixpkgsCustom =
      flip NixpkgsCustom
        <$> Opts.option
          customNixpkgsReader
          ( Opts.long "nixpkgs"
              <> Opts.showDefault
              <> Opts.help "Use a custom nixpkgs repository from GitHub."
              <> Opts.metavar "OWNER/REPO"
          )
        <*> Opts.strOption
          ( Opts.long "nixpkgs-branch"
              <> Opts.short 'b'
              <> Opts.help "The nixpkgs branch when using --nixpkgs ...."
              <> Opts.showDefault
          )
    parseNoNixpkgs =
      Opts.flag'
        NoNixpkgs
        ( Opts.long "no-nixpkgs"
            <> Opts.help "Don't add a nixpkgs entry to sources.json."
        )
    customNixpkgsReader = Opts.maybeReader $ \(T.pack -> repo) -> case T.splitOn "/" repo of
      [owner, reponame] -> Just (Nixpkgs owner reponame)
      _ -> Nothing

cmdInit :: FetchNixpkgs -> NIO ()
cmdInit nixpkgs = do
  fsj <- getFindSourcesJson

  -- Writes all the default files

  void $ job "sources.nix (file)" $ do
    let path = pathNixSourcesNix
    exists <- liftIO $ Dir.doesFileExist path
    if exists
      then do
        content <- liftIO $ B.readFile path
        when (shouldUpdateNixSourcesNix content) $ do
          say "updating sources.nix"
          liftIO $ B.writeFile path initNixSourcesNixContent
      else
        createFile path initNixSourcesNixContent
    case fsj of
      Auto -> pure ()
      AtPath fp -> noteUpdateSourcesNixForPath fp

  -- returns whether we should initialize nixpkgs
  sourcesJsonResult <- job "sources.json (file)" $ do
    let path = pathNixSourcesJson fsj
    exists <- liftIO $ Dir.doesFileExist path
    if exists
      then do
        say $ T.pack path <> " already exists"
        pure False
      else do
        createFile path initNixSourcesJsonContent
        pure True

  case (sourcesJsonResult, nixpkgs) of
    (Left (), _) -> liftIO exitFailure
    (Right False, _) -> pure () -- not initializing nixpkgs
    (_, NoNixpkgs) -> pure () -- not initializing nixpkgs
    (Right True, NixpkgsFast) -> do
      result <- job "nixpkgs" $ do
        say "Using known 'nixpkgs' ..."
        spec <- HTTP.getResponseBody <$> HTTP.httpJSON "https://raw.githubusercontent.com/nmattia/niv/master/data/nixpkgs.json"
        updatePackage (specToLockedAttrs spec)

      case result of
        Right spec' -> writeSourcesEntry (PackageName "nixpkgs") spec'
        Left () -> liftIO exitFailure
    (Right True, NixpkgsCustom branch (Nixpkgs owner repo)) -> do
      result <- job "nixpkgs" $ do
        say "Importing 'nixpkgs' ..."
        updatePackage $
          specToLockedAttrs $
            PackageSpec $
              KM.fromList
                [ "owner" .= owner,
                  "repo" .= repo,
                  "branch" .= branch
                ]

      case result of
        Right spec' -> writeSourcesEntry (PackageName "nixpkgs") spec'
        Left () -> liftIO exitFailure

createFile :: FilePath -> B.ByteString -> Job ()
createFile path content = do
  let dir = takeDirectory path
  liftIO $ Dir.createDirectoryIfMissing True dir
  say $ "Creating " <> T.pack path
  liftIO $ B.writeFile path content

-------------------------------------------------------------------------------
-- ADD
-------------------------------------------------------------------------------

-- | a string like 'nmattia/niv' that gets turned into a PackageName + PackageSpec
newtype PackageShortcut = PackageShortcut {unPackageShortcut :: T.Text}

parseCmdAdd :: Opts.ParserInfo (NIO ())
parseCmdAdd =
  Opts.info
    ((cmdAdd <$> parsePackageShortcut <*> Opts.optional parsePackageNameOverride <*> Opts.optional parsePackageSpec) <**> Opts.helper)
    $ description
  where
    parsePackageShortcut = PackageShortcut <$> Opts.argument Opts.str (Opts.metavar "PACKAGE")
    parsePackageNameOverride = PackageName <$> Opts.strOption (Opts.long "name" <> Opts.short 'n' <> Opts.metavar "NAME")
    description =
      mconcat
        [ Opts.progDesc "Add a package",
          Opts.headerDoc $
            Just $
              Opts.vcat
                [ "Examples:",
                  "",
                  "  niv add stedolan/jq" Opts.<+> "# add a GitHub repo",
                  "  niv add NixOS/nixpkgs -b nixpkgs-unstable" Opts.<+> "# use non-default branch",
                  "  niv add neovim/neovim --name vim" Opts.<+> "# override inferred name",
                  "  niv add https://gcc.gnu.org/git/gcc.git" Opts.<+> "# add an arbitrary Git repo"
                ]
        ]

-- Try to expand the shortcut with all commands and return successfully if exactly one matches
expandShortcut :: [Cmd] -> PackageShortcut -> NIO (PackageName, PackageSpec)
expandShortcut cmds (unPackageShortcut -> shortcut) = do
  let expanded = mapMaybe (\cmd -> parseCmdShortcut cmd shortcut) cmds

  case expanded of
    -- no match: create a dummy package with name <shortcut> and hope the user provides enough attributes
    -- via `--attribute ...`
    [] -> pure (PackageName shortcut, mempty)
    -- exactly one match: use this
    [x] -> pure x
    -- 1+ match: this is a bug
    _ -> abortManyCommandsForShortcut (PackageShortcut shortcut)

cmdAdd :: PackageShortcut -> Maybe PackageName -> Maybe ParsedPackageSpec -> NIO ()
cmdAdd shortcut mPackageName mParsed = do
  -- fully realize the spec passed as `--attribute ...`, if any
  cliSpecAttrs <- case mParsed of
    Nothing -> pure $ PackageSpec KM.empty
    Just parsed -> checkParsedSpec parsed

  -- try to expand the shortcut
  (packageName', cliSpecShortcut) <- getCmds >>= \cmds -> expandShortcut cmds shortcut

  let packageName = case mPackageName of
        Just packageName'' -> packageName''
        Nothing -> packageName'

  -- merge the specs: in case of conflict, the `--attribute ...` takes precedence
  let cliSpec = cliSpecAttrs <> cliSpecShortcut -- left biased via Data.Aeson.KeyMap
  sources <- unSources <$> readSources
  when (HMS.member packageName sources) $ abortCannotAddPackageExists packageName

  result <- job (unPackageName packageName) $ do
    say "updating new package..."
    result <- updatePackage (specToLockedAttrs cliSpec)
    say "package updated"
    pure result

  case result of
    Right spec' -> writeSourcesEntry packageName spec'
    Left () -> liftIO exitFailure

-------------------------------------------------------------------------------
-- SHOW
-------------------------------------------------------------------------------

parseCmdShow :: Opts.ParserInfo (NIO ())
parseCmdShow =
  Opts.info
    ((cmdShow <$> Opts.optional parsePackagePattern) <**> Opts.helper)
    mempty

cmdShow :: Maybe PackagePattern -> NIO ()
cmdShow mPat = do
  toShow <- readSources <&> \sources -> filterPackages sources mPat

  when (HMS.null $ unSources toShow) $ do
    case mPat of
      Just (PackagePattern pat) -> abort $ "no package matching: " <> "'" <> pat <> "'"
      Nothing -> abort "nothing to show"

  forWithKeyM_ (unSources toShow) showPackage

showPackage :: (MonadIO io) => PackageName -> PackageSpec -> io ()
showPackage (PackageName pname) (PackageSpec spec) = do
  liftIO $ T.putStrLn $ tbold pname
  forM_ (KM.toList spec) $ \(attrName, attrValValue) -> do
    let attrValue = case attrValValue of
          -- if a string, show the string
          Aeson.String str -> str
          -- otherwise, show the raw encoding
          v -> tfaint $ T.decodeUtf8Lenient $ BL.toStrict $ Aeson.encode v
    liftIO $ T.putStrLn $ "  " <> K.toText attrName <> ": " <> attrValue

-------------------------------------------------------------------------------
-- UPDATE
-------------------------------------------------------------------------------

-- | a pattern to match on package names
newtype PackagePattern = PackagePattern {unPackagePattern :: T.Text}

parsePackagePattern :: Opts.Parser PackagePattern
parsePackagePattern = PackagePattern <$> Opts.argument Opts.str (Opts.metavar "PACKAGE" <> Opts.help "Matches a single package by exact name; otherwise matches all packages names starting with this prefix.")

-- | filter out packages based on a pattern
filterPackages :: Sources -> Maybe PackagePattern -> Sources
filterPackages (unSources -> sources) mPat = Sources $ case mPat of
  -- no pattern: return all packages
  Nothing -> sources
  -- pattern (filter) provided: return an exact match; if no exact match, match by prefix
  Just (PackagePattern pat) -> case HMS.lookup (PackageName pat) sources of
    Just exact -> HMS.singleton (PackageName pat) exact
    Nothing ->
      HMS.filterWithKey (\k _ -> pat `T.isPrefixOf` (unPackageName k)) sources

parseCmdUpdate :: Opts.ParserInfo (NIO ())
parseCmdUpdate =
  Opts.info
    ((cmdUpdate <$> Opts.optional parsePackagePattern <*> Opts.optional parsePackageSpec) <**> Opts.helper)
    $ mconcat desc
  where
    desc =
      [ Opts.progDesc "Update dependencies",
        Opts.headerDoc $
          Just $
            Opts.nest 2 $
              Opts.vcat
                [ "Examples:",
                  "",
                  Opts.fill 30 "niv update" Opts.<+> "# update all packages",
                  Opts.fill 30 "niv update nixpkgs" Opts.<+> "# update nixpkgs",
                  Opts.fill 30 "niv update my-package -v beta-0.2" Opts.<+> "# update my-package to version \"beta-0.2\""
                ]
      ]

specToFreeAttrs :: PackageSpec -> Attrs
specToFreeAttrs = KM.toHashMapText . fmap (Free,) . unPackageSpec

specToLockedAttrs :: PackageSpec -> Attrs
specToLockedAttrs = KM.toHashMapText . fmap (Locked,) . unPackageSpec

-- | find a matching Cmd for the PackageSpec
inferCmd :: [Cmd] -> PackageSpec -> Maybe Cmd
inferCmd cmds spec = do
  find (\cmd -> acceptsCmd cmd spec) cmds

-- update the attributes and return the updated spec
updatePackage :: Attrs -> Job PackageSpec
updatePackage attrs = do
  cmds <- lift getCmds

  -- infer what command (git, github, etc) to use to update the package
  cmd <- case inferCmd cmds (attrsToSpec attrs) of
    Just cmd -> pure cmd
    Nothing -> abortNoSuitableCommand

  say "updating..."
  result <- attrsToSpec <$> doUpdate attrs cmd
  say "package updated"
  pure result

-- | Update many packages.
-- For each package, the package name, attrs-to-update as well as original state are given.
-- For each package, the package name and final state are returned.
updatePackages :: [(PackageName, PackageSpec, Maybe PackageSpec)] -> NIO [Either () ()]
updatePackages packageUpdates = do
  -- prepare some padding for package names so that the output is aligned
  let maxNameLength = maximum $ (\(p, _, _) -> T.length $ unPackageName p) <$> packageUpdates
      padName (PackageName p) = p <> T.replicate (maxNameLength - T.length p) " "

  -- update all the packages, writing the new spec after each (successful) update and
  -- finally returning a list of all successes & failures statuses
  forM packageUpdates $ \(packageName, spec, mCliSpec) -> do
    let defAttrs = specToFreeAttrs spec
        attrs = maybe defAttrs (\cliSpec -> specToLockedAttrs cliSpec <> defAttrs) mCliSpec
    result <- job (padName packageName) $ updatePackage attrs
    case result of
      Right spec' -> do
        writeSourcesEntry packageName spec'
        pure $ Right ()
      Left _ -> pure $ Left ()

cmdUpdate :: Maybe PackagePattern -> Maybe ParsedPackageSpec -> NIO ()
cmdUpdate mPat mParsed = do
  cliSpec <- case mParsed of
    Nothing -> pure Nothing
    Just parsed -> Just <$> checkParsedSpec parsed

  toUpdate <- readSources <&> \sources -> filterPackages sources mPat

  when (HMS.null $ unSources toUpdate) $ do
    case mPat of
      Just (PackagePattern pat) -> abort $ "no package matching: " <> "'" <> pat <> "'"
      Nothing -> abort "nothing to update"

  let packageUpdates = (\(pName, spec) -> (pName, spec, cliSpec)) <$> HMS.toList (unSources toUpdate)

  liftIO $ T.putStrLn $ T.pack (show (length packageUpdates)) <> " package(s) to update"

  -- update all packages and separate failures from successes
  (errs, successes) <- partitionEithers <$> updatePackages packageUpdates

  -- print a short summary
  liftIO $ T.putStrLn ""
  unless (null successes) $ do
    liftIO $ T.putStrLn $ T.pack (show (length successes)) <> " package(s) updated successfully"
  unless (null errs) $ do
    liftIO $ T.putStrLn ""
    liftIO $ T.putStrLn $ T.pack (show (length errs)) <> " package(s) failed to update"
    liftIO exitFailure

-- | A package spec as parsed (may be malformed and contain the same attributes multiple times)
newtype ParsedPackageSpec = ParsedPackageSpec {unParsedPackageSpec :: [(T.Text, Aeson.Value)]}

-- | Collapse the parsed spec into something usable, potentially erroring out
checkParsedSpec :: ParsedPackageSpec -> NIO PackageSpec
checkParsedSpec (unParsedPackageSpec -> parsed) = do
  -- count how many times an attribute is seen, and then filter on the "offending" ones which have been seen
  -- more than once
  let counts =
        foldl'
          (\acc (k, _) -> HMS.alter (\case Nothing -> Just (1 :: Int); Just n -> Just (n + 1)) k acc)
          HMS.empty
          parsed
      offending = HMS.filter (\n -> n > 1) counts

  when (not $ HMS.null offending) $ do
    abortAttributeRepeated (HMS.keys offending)

  pure $ PackageSpec $ KM.fromList $ (\(k, v) -> (fromString $ T.unpack k, v)) <$> parsed

-- Parse a package spec, where any attribute can be specified at most once.
parsePackageSpec :: Opts.Parser ParsedPackageSpec
parsePackageSpec = groupOptions "ATTRIBUTES" $ ParsedPackageSpec <$> Opts.some (jsonAttribute <|> stringAttribute <|> knownAttribute)
  where
    -- this (with `Opts.hidden` set on all options) groups the options below instead of showing them all with the command
    -- https://github.com/pcapriotti/optparse-applicative/issues/523
    groupOptions :: String -> Opts.Parser a -> Opts.Parser a
    groupOptions mv x = Opts.option empty (Opts.metavar mv) <|> Opts.parserOptionGroup mv x

    -- shortcuts for many known attributes
    knownAttribute :: Opts.Parser (T.Text, Aeson.Value)
    knownAttribute =
      attrOption
        "owner"
        ( Opts.long "owner"
            <> Opts.short 'o'
            <> Opts.metavar "OWNER"
            <> Opts.help "Set the repository owner (for github)"
        )
        <|> attrOption
          "repo"
          ( Opts.long "repo"
              <> Opts.metavar "REPO"
              <> Opts.help "Set the repository name (for github)"
          )
        <|> attrOption
          "branch"
          ( Opts.long "branch"
              <> Opts.short 'b'
              <> Opts.metavar "BRANCH"
              <> Opts.help "Set the branch (for git/github)"
          )
        <|> attrOption
          "rev"
          ( Opts.long "rev"
              <> Opts.short 'r'
              <> Opts.metavar "REV"
              <> Opts.help "Set the revision/commit (for git/github)"
          )
        <|> attrOption
          "version"
          ( Opts.long "version"
              <> Opts.short 'v'
              <> Opts.metavar "VERSION"
              <> Opts.help "Set the version"
          )
        <|> attrOption
          "url_template"
          ( Opts.long "template"
              <> Opts.short 't'
              <> Opts.metavar "URL"
              <> Opts.help "Used during 'update' when building URL. Occurrences of <foo> are replaced with attribute 'foo'."
          )
        <|> attrOption
          "type"
          ( Opts.long "type"
              <> Opts.short 'T'
              <> Opts.metavar "TYPE"
              <> Opts.help "The type of the URL target. The value can be either 'file' or 'tarball'. If not set, the value is inferred from the suffix of the URL."
          )

    attrOption key mods = (\v -> (key, v)) <$> Opts.strOption (Opts.hidden <> mods)

    -- parse any json value as `--attribute 'foo={"hello": "world"}'`
    -- NOTE: if the string fails to parse as JSON we assume it's a string (a string itself like 'foo' won't
    -- successfully parse as a JSON string, only '"foo"' would)
    jsonAttribute :: Opts.Parser (T.Text, Aeson.Value)
    jsonAttribute =
      Opts.option
        (kvMaybe >>= \(k, v) -> case Aeson.decodeStrict (B8.pack (T.unpack v)) of Just v' -> pure (k, v'); Nothing -> pure (k, Aeson.toJSON v))
        ( Opts.long "attribute"
            <> Opts.short 'a'
            <> Opts.metavar "KEY=VAL"
            <> Opts.help "Set the package spec attribute <KEY> to <VAL>, where <VAL> may be JSON."
            <> Opts.hidden
        )

    -- same as above but force parsing as string
    stringAttribute :: Opts.Parser (T.Text, Aeson.Value)
    stringAttribute =
      Opts.option
        ((\(k, v) -> (k, Aeson.String v)) <$> kvMaybe)
        ( Opts.long "string-attribute"
            <> Opts.short 's'
            <> Opts.metavar "KEY=VAL"
            <> Opts.help "Set the package spec attribute <KEY> to <VAL>."
            <> Opts.hidden
        )

    -- try to turn "foo=bar" into ("foo", "bar")
    kvMaybe :: Opts.ReadM (T.Text, T.Text)
    kvMaybe = Opts.maybeReader $ \str -> case span (/= '=') str of
      (k, '=' : v) -> Just (T.pack k, T.pack v)
      _ -> Nothing

-- | pretty much tryEvalUpdate but we might issue some warnings first
doUpdate :: Attrs -> Cmd -> Job Attrs
doUpdate attrs cmd = do
  forM_ (extraLogs cmd attrs) say
  result <- liftIO $ tryEvalUpdate attrs (updateCmd cmd)
  case result of
    Right attrs' -> pure attrs'
    Left e -> throwError $ T.show e

-------------------------------------------------------------------------------
-- RENAME
-------------------------------------------------------------------------------

parseCmdRename :: Opts.ParserInfo (NIO ())
parseCmdRename =
  Opts.info
    ((cmdRename <$> parsePackageNameOld <*> parsePackageNameNew) <**> Opts.helper)
    $ mconcat desc
  where
    desc =
      [ Opts.progDesc "Rename a package",
        Opts.headerDoc $
          Just $
            Opts.vcat
              [ "Examples:",
                "",
                "  niv rename nixpkgs nixpkgs-unstable"
              ]
      ]
    parsePackageNameOld = PackageName <$> Opts.argument Opts.str (Opts.metavar "OLD")
    parsePackageNameNew = PackageName <$> Opts.argument Opts.str (Opts.metavar "NEW")

cmdRename :: PackageName -> PackageName -> NIO ()
cmdRename oldName newName =
  modifySources $ \(unSources -> sources) -> do
    spec <- case HMS.lookup oldName sources of
      Nothing -> abortNoSuchPackage oldName
      Just spec -> pure spec

    pure $ Sources $ HMS.insert newName spec $ HMS.delete oldName sources

-------------------------------------------------------------------------------
-- MODIFY
-------------------------------------------------------------------------------

parseCmdModify :: Opts.ParserInfo (NIO ())
parseCmdModify =
  Opts.info
    ((cmdModify <$> parsePackageName <*> parsePackageSpec) <**> Opts.helper)
    $ mconcat desc
  where
    desc =
      [ Opts.progDesc "Modify dependency attributes without performing an update",
        Opts.headerDoc $
          Just $
            Opts.vcat
              [ "Examples:",
                "",
                "  niv modify nixpkgs -v beta-0.2",
                "  niv modify nixpkgs -a branch=nixpkgs-unstable"
              ]
      ]

cmdModify :: PackageName -> ParsedPackageSpec -> NIO ()
cmdModify packageName parsed = do
  cliSpec <- checkParsedSpec parsed
  modifySources $ \(unSources -> sources) -> do
    spec <- case HMS.lookup packageName sources of
      Nothing -> abortNoSuchPackage packageName
      Just spec -> pure spec

    let spec' = attrsToSpec (specToLockedAttrs cliSpec <> specToFreeAttrs spec)
    pure $ Sources $ HMS.insert packageName spec' sources

-------------------------------------------------------------------------------
-- DROP
-------------------------------------------------------------------------------

parseCmdDrop :: Opts.ParserInfo (NIO ())
parseCmdDrop =
  Opts.info
    ( (cmdDrop <$> parsePackageName <*> parseDropAttributes)
        <**> Opts.helper
    )
    $ mconcat desc
  where
    desc =
      [ Opts.progDesc "Drop dependency",
        Opts.headerDoc $
          Just $
            Opts.vcat
              [ "Examples:",
                "",
                "  niv drop jq",
                "  niv drop my-package version"
              ]
      ]
    parseDropAttributes :: Opts.Parser [T.Text]
    parseDropAttributes =
      many $
        Opts.argument Opts.str (Opts.metavar "ATTRIBUTE")

cmdDrop :: PackageName -> [T.Text] -> NIO ()
cmdDrop packageName = \case
  [] -> modifySources $ \(unSources -> sources) -> do
    unless (HMS.member packageName sources) $
      abortNoSuchPackage packageName
    pure $ Sources $ HMS.delete packageName sources
  attrs -> do
    liftIO $ T.putStrLn $ "Dropping attributes: " <> T.intercalate " " attrs
    liftIO $ T.putStrLn $ "In package: " <> unPackageName packageName
    sources <- unSources <$> readSources
    spec <- case HMS.lookup packageName sources of
      Nothing -> abortNoSuchPackage packageName
      Just spec -> pure spec
    let spec' = PackageSpec $ KM.mapMaybeWithKey (\k v -> if K.toText k `elem` attrs then Nothing else Just v) (unPackageSpec spec)
    writeSourcesEntry packageName spec'

-------------------------------------------------------------------------------
-- VERSION
-------------------------------------------------------------------------------

parseCmdVersion :: Opts.ParserInfo (NIO ())
parseCmdVersion =
  Opts.info
    ( pure (liftIO $ T.putStrLn $ T.pack $ showVersion version)
        <**> Opts.helper
    )
    $ mconcat desc
  where
    desc =
      [ Opts.progDesc "Print version"
      ]

-------------------------------------------------------------------------------
-- DEBUG: some debugging helpers (internal)
-------------------------------------------------------------------------------

-- | Collection of help, debug and test output for bug reports & tests
parseCmdDebug :: Opts.ParserInfo (NIO ())
parseCmdDebug =
  Opts.info
    ( Opts.subparser
        ( Opts.command "job-hello-world" (Opts.info (pure $ liftIO jobHelloWorld) mempty)
            <> Opts.command "job-note" (Opts.info (pure $ liftIO jobNote) mempty)
            <> Opts.command "job-note-multiline" (Opts.info (pure $ liftIO jobNoteMultiline) mempty)
            <> Opts.command "job-every-admonition" (Opts.info (pure $ liftIO jobEveryAdmonition) mempty)
            <> Opts.command "job-multi" (Opts.info (pure $ liftIO jobMulti) mempty)
        )
    )
    mempty

-- "hello world" inside a job.
jobHelloWorld :: IO ()
jobHelloWorld = void $ job "test" $ do
  threadDelay 600000
  say "hello"
  threadDelay 600000
  say "world"
  threadDelay 600000

-- simple note
jobNote :: IO ()
jobNote = void $ job "test-note" $ do
  threadDelay 600000
  note "hello"
  threadDelay 600000

-- multiline notes
jobNoteMultiline :: IO ()
jobNoteMultiline = void $ job "test-note-multiline" $ do
  note $ "this is the first note\nwhich is a " <> tbold "multiline" <> " note"
  note "this is another note"

-- every admonition (note, warning, error)
jobEveryAdmonition :: IO ()
jobEveryAdmonition = void $ job "every-admonition" $ do
  warn "some warning"
  note "some note"
  throwError "some error"

-- multiple jobs
jobMulti :: IO ()
jobMulti = do
  void $ job "a" $ say "message"
  void $ job "ab" $ say "message"
  void $ job "abc-def" $ say "message"
  void $ job "hello" $ say "message"
  void $ job "world" $ say "message"
  void $ job "nothing" $ say "message"

-------------------------------------------------------------------------------
-- Files and their content
-------------------------------------------------------------------------------

-- Read the sources, throwing an IO error if there's an issue
getSources :: FindSourcesJson -> IO Sources
getSources fsj = do
  getSourcesEither fsj
    >>= either
      ( \case
          SourcesDoesntExist -> (abortSourcesDoesntExist fsj)
          SourceIsntJSON -> (abortSourcesIsntJSON fsj)
          SpecIsntAMap -> (abortSpecIsntAMap fsj)
      )
      pure

-- helper for modifying the sources file
modifySources :: (Sources -> NIO Sources) -> NIO ()
modifySources upd = do
  fsj <- getFindSourcesJson
  sources <- liftIO $ getSources fsj
  sources' <- upd sources
  liftIO $ setSources fsj sources'

-- Read the sources in NIO
readSources :: NIO Sources
readSources = do
  fsj <- getFindSourcesJson
  liftIO $ getSources fsj

-- Update or insert a spec
writeSourcesEntry :: PackageName -> PackageSpec -> NIO ()
writeSourcesEntry packageName spec = do
  fsj <- getFindSourcesJson
  sources <- liftIO $ getSources fsj
  let sources' = Sources $ HMS.insert packageName spec (unSources sources)
  liftIO $ setSources fsj sources'

-- | Checks if content is different than default and if it does /not/ contain
-- a comment line with @niv: no_update@
shouldUpdateNixSourcesNix :: B.ByteString -> Bool
shouldUpdateNixSourcesNix content =
  content /= initNixSourcesNixContent
    && not (any lineForbids (B8.lines content))
  where
    lineForbids :: B8.ByteString -> Bool
    lineForbids str =
      case B8.uncons (B8.dropWhile isSpace str) of
        Just ('#', rest) -> case B8.stripPrefix "niv:" (B8.dropWhile isSpace rest) of
          Just rest' -> case B8.stripPrefix "no_update" (B8.dropWhile isSpace rest') of
            Just {} -> True
            _ -> False
          _ -> False
        _ -> False

-------------------------------------------------------------------------------
-- MISC
-------------------------------------------------------------------------------

noteUpdateSourcesNixForPath :: (MonadIO io) => FilePath -> Niv.Logger.Job io ()
noteUpdateSourcesNixForPath fp = do
  note $
    T.unlines
      [ "You are using a custom path for sources.json.",
        "You need to configure the sources.nix to use " <> tbold (T.pack fp) <> ":",
        "",
        tbold "      import sources.nix { sourcesFile = PATH ; }; ",
        "",
        T.unwords
          [ "  where",
            tbold "PATH",
            "is the relative path from sources.nix to",
            tbold (T.pack fp) <> "."
          ]
      ]

-------------------------------------------------------------------------------
-- Abort
-------------------------------------------------------------------------------

-- A job error if no update Cmd is suited to the package
abortNoSuitableCommand :: Job a
abortNoSuitableCommand =
  throwError "don't know how to update package"

-- proper aborts that exit niv (only used when there is no way to make
-- progress, like missing sources)

abort :: (MonadIO io) => T.Text -> io a
abort msg = do
  liftIO $ T.putStrLn $ T.unwords [tbold (tred "FATAL") <> ":", msg]
  liftIO exitFailure

-- error if the command to use is ambiguous
abortManyCommandsForShortcut :: PackageShortcut -> NIO a
abortManyCommandsForShortcut (unPackageShortcut -> shortcut) =
  abort $ bug $ "shortcut matched multiple commands: " <> shortcut

-- We don't allow attributes to be specified multiple times
abortAttributeRepeated :: [T.Text] -> NIO a
abortAttributeRepeated ks =
  abort $ "some attributes were specified multiple times: " <> T.intercalate "," ks

abortNoSuchPackage :: (MonadIO io) => PackageName -> io a
abortNoSuchPackage (unPackageName -> packageName) =
  abort $ packageName <> ": no such package"

abortSourcesDoesntExist :: FindSourcesJson -> IO a
abortSourcesDoesntExist fsj = abort $ T.unlines [line1, line2]
  where
    line1 = "Cannot use " <> T.pack (pathNixSourcesJson fsj)
    line2 =
      [s|
The sources file does not exist! You may need to run 'niv init'.
|]

abortSourcesIsntJSON :: FindSourcesJson -> IO a
abortSourcesIsntJSON fsj = abort $ T.unlines [line1, line2]
  where
    line1 = "Cannot use " <> T.pack (pathNixSourcesJson fsj)
    line2 = "The sources file should be JSON."

abortSpecIsntAMap :: FindSourcesJson -> IO a
abortSpecIsntAMap fsj = abort $ T.unlines [line1, line2]
  where
    line1 = "Cannot use " <> T.pack (pathNixSourcesJson fsj)
    line2 =
      [s|
The package specifications in the sources file should be JSON maps from
attribute name to attribute value, e.g.:
  { "nixpkgs": { "foo": "bar" } }
|]

abortCannotRenamePackageExists :: PackageName -> PackageName -> NIO a
abortCannotRenamePackageExists (PackageName from) (PackageName to) =
  abort $
    T.unlines
      [ "Cannot rename package " <> from <> " to " <> to <> ".",
        "Package " <> to <> " already exists."
      ]

abortCannotAddPackageExists :: PackageName -> NIO a
abortCannotAddPackageExists (PackageName n) =
  abort $
    T.unlines
      [ "Cannot add package " <> n <> ".",
        "The package already exists. Use",
        "  niv drop " <> n,
        "and then re-add the package. Alternatively use",
        "  niv update " <> n <> " --attribute foo=bar",
        "to update the package's attributes."
      ]