diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
 # Changelog
 
+## 0.7.7 (2021-04-06)
+- add the new F34 i3 spin
+- shorten mate_compiz to mate
+- convert tests to Haskell
+
 ## 0.7.6 (2021-01-21)
 - improve help text for releases related for Beta and RCs (#1)
 - if ~/Downloads/iso/ exists then download to it otherwise ~/Downloads/
diff --git a/Main.hs b/Main.hs
deleted file mode 100644
--- a/Main.hs
+++ /dev/null
@@ -1,442 +0,0 @@
-{-# LANGUAGE CPP #-}
-
-#if !MIN_VERSION_base(4,13,0)
-import Control.Applicative ((<|>)
-#if !MIN_VERSION_base(4,8,0)
-  , (<$>), (<*>)
-#endif
-  )
-import Data.Semigroup ((<>))
-#endif
-
-import Control.Monad.Extra
-
-import qualified Data.ByteString.Char8 as B
-import Data.Char (isDigit, toLower, toUpper)
-import Data.List
-import Data.Maybe
-import qualified Data.Text as T
-import Data.Time.LocalTime (utcToLocalZonedTime)
-
-import Network.HTTP.Directory
-
-import Options.Applicative (fullDesc, header, progDescDoc)
-import qualified Options.Applicative.Help.Pretty as P
-
-import Paths_dl_fedora (version)
-
-import SimpleCmd (cmd_, cmdN, error', grep_, pipe_, pipeBool, pipeFile_,
-                  removePrefix)
-import SimpleCmdArgs
-
-import System.Directory (createDirectory, createDirectoryIfMissing,
-                         doesDirectoryExist, doesFileExist, findExecutable,
-                         getHomeDirectory, getPermissions, listDirectory,
-                         removeFile, setCurrentDirectory, withCurrentDirectory,
-                         writable)
-import System.Environment.XDG.UserDir (getUserDir)
-import System.FilePath (dropFileName, isRelative , joinPath, makeRelative,
-                        takeExtension, takeFileName, (<.>))
-import System.Posix.Files (createSymbolicLink, fileSize, getFileStatus,
-                           readSymbolicLink)
-
-import Text.Read
-import qualified Text.ParserCombinators.ReadP as R
-import qualified Text.ParserCombinators.ReadPrec as RP
-import Text.Regex.Posix
-
-{-# ANN module "HLint: ignore Use camelCase" #-}
-data FedoraEdition = Cloud
-                   | Container
-                   | Everything
-                   | Server
-                   | Silverblue
-                   | Workstation
-                   | Cinnamon
-                   | KDE
-                   | LXDE
-                   | LXQt
-                   | MATE_Compiz
-                   | Soas
-                   | Xfce
- deriving (Show, Enum, Bounded, Eq)
-
-instance Read FedoraEdition where
-  readPrec = do
-    s <- look
-    let e = map toLower s
-        editionMap =
-          map (\ ed -> (map toLower (show ed), ed)) [minBound..maxBound]
-        res = lookup e editionMap
-    case res of
-      Nothing -> error' "unknown edition" >> RP.pfail
-      Just ed -> RP.lift (R.string e) >> return ed
-
-type URL = String
-
-fedoraSpins :: [FedoraEdition]
-fedoraSpins = [Cinnamon ..]
-
-data CheckSum = AutoCheckSum | NoCheckSum | CheckSum
-  deriving Eq
-
-dlFpo, downloadFpo, kojiPkgs, odcsFpo :: String
-dlFpo = "https://dl.fedoraproject.org/pub"
-downloadFpo = "https://download.fedoraproject.org/pub"
-kojiPkgs = "https://kojipkgs.fedoraproject.org/compose"
-odcsFpo = "https://odcs.fedoraproject.org/composes"
-
-main :: IO ()
-main = do
-  let pdoc = Just $ P.vcat
-             [ P.text "Tool for downloading Fedora iso file images.",
-               P.text ("RELEASE = " <> intercalate ", " ["release number", "respin", "rawhide", "test (Beta)", "stage (RC)", "eln", "or koji"]),
-               P.text "EDITION = " <> P.lbrace <> P.align (P.fillCat (P.punctuate P.comma (map (P.text . map toLower . show) [(minBound :: FedoraEdition)..maxBound])) <> P.rbrace),
-               P.text "",
-               P.text "See <https://fedoraproject.org/wiki/Infrastructure/MirrorManager>",
-               P.text "and also <https://fedoramagazine.org/verify-fedora-iso-file>."
-             ]
-  simpleCmdArgsWithMods (Just version) (fullDesc <> header "Fedora iso downloader" <> progDescDoc pdoc) $
-    program
-    <$> switchWith 'g' "gpg-keys" "Import Fedora GPG keys for verifying checksum file"
-    <*> checkSumOpts
-    <*> switchWith 'n' "dry-run" "Don't actually download anything"
-    <*> switchWith 'r' "run" "Boot image in Qemu"
-    <*> switchWith 'R' "replace" "Delete old image after downloading new one"
-    <*> optional mirrorOpt
-    <*> strOptionalWith 'a' "arch" "ARCH" "Architecture [default: x86_64]" "x86_64"
-    <*> optionalWith auto 'e' "edition" "EDITION" "Fedora edition [default: workstation]" Workstation
-    <*> strArg "RELEASE"
-  where
-    mirrorOpt :: Parser String
-    mirrorOpt =
-      flagWith' dlFpo 'd' "dl" "Use dl.fedoraproject.org" <|>
-      strOptionWith 'm' "mirror" "HOST" ("Mirror url for /pub [default " ++ downloadFpo ++ "]")
-
-    checkSumOpts :: Parser CheckSum
-    checkSumOpts =
-      flagWith' NoCheckSum 'C' "no-checksum" "Do not check checksum" <|>
-      flagWith AutoCheckSum CheckSum 'c' "checksum" "Do checksum even if already downloaded"
-
-program :: Bool -> CheckSum -> Bool -> Bool -> Bool -> Maybe String -> String -> FedoraEdition -> String -> IO ()
-program gpg checksum dryrun run removeold mmirror arch edition tgtrel = do
-  let mirror =
-        case mmirror of
-          Nothing | tgtrel == "koji" -> kojiPkgs
-          Nothing | tgtrel == "eln" -> odcsFpo
-          Nothing -> downloadFpo
-          Just _ | tgtrel == "koji" -> error' "Cannot specify mirror for koji"
-          Just m -> m
-  home <- getHomeDirectory
-  dlDir <- setDownloadDir home
-  mgr <- httpManager
-  let showdestdir =
-        let path = makeRelative home dlDir in
-          if isRelative path then "~" </> path else path
-  (fileurl, filenamePrefix, (masterUrl,masterSize), mchecksum, done) <- findURL mgr mirror showdestdir
-  downloadFile done mgr fileurl (masterUrl,masterSize) >>= fileChecksum mgr mchecksum showdestdir
-  unless dryrun $ do
-    let localfile = takeFileName fileurl
-        symlink = filenamePrefix <> (if tgtrel == "eln" then "-" <> arch else "") <> "-latest" <.> takeExtension fileurl
-    updateSymlink localfile symlink showdestdir
-    when run $ bootImage localfile showdestdir
-  where
-    setDownloadDir home = do
-      dlDir <- getUserDir "DOWNLOAD"
-      dirExists <- doesDirectoryExist dlDir
-      unless (dryrun || dirExists) $
-        when (home == dlDir) $
-          error' "HOME directory does not exist!"
-      dlIsoDir <- let isodir = dlDir </> "iso" in
-        ifM (doesDirectoryExist isodir) (return isodir) $ do
-        unless (dirExists || dryrun) $ createDirectoryIfMissing True dlDir
-        return dlDir
-      setCurrentDirectory dlIsoDir
-      return dlIsoDir
-
-    -- urlpath, fileprefix, (master,size), checksum, downloaded
-    findURL :: Manager -> String -> String -> IO (URL, String, (URL,Maybe Integer), Maybe String, Bool)
-    findURL mgr mirror showdestdir = do
-      (path,mrelease) <- urlPathMRel mgr
-      -- use http-directory trailingSlash (0.1.7)
-      let masterDir =
-            (case tgtrel of
-               "koji" -> kojiPkgs
-               "eln" -> odcsFpo
-               _ -> dlFpo) </> path <> "/"
-      hrefs <- httpDirectory mgr masterDir
-      let prefixPat = makeFilePrefix mrelease
-          selector = if '*' `elem` prefixPat then (=~ prefixPat) else (prefixPat `isPrefixOf`)
-          mfile = find selector $ map T.unpack hrefs
-          mchecksum = find ((if tgtrel == "respin" then T.isPrefixOf else T.isSuffixOf) (T.pack "CHECKSUM")) hrefs
-      case mfile of
-        Nothing ->
-          error' $ "no match for " <> prefixPat <> " in " <> masterDir
-        Just file -> do
-          let prefix = if '*' `elem` prefixPat
-                       then (file =~ prefixPat) ++ if arch `isInfixOf` prefixPat then "" else arch
-                       else prefixPat
-              masterUrl = masterDir </> file
-          masterSize <- httpFileSize mgr masterUrl
-          (finalurl, already) <- do
-            let localfile = takeFileName masterUrl
-            exists <- doesFileExist localfile
-            if exists
-              then do
-              done <- checkLocalFileSize localfile masterSize showdestdir
-              if done
-                then return (masterUrl,True)
-                else do
-                unlessM (writable <$> getPermissions localfile) $
-                  error' $ localfile <> " does have write permission, aborting!"
-                findMirror masterUrl path file
-              else findMirror masterUrl path file
-          mlocaltime <- httpTimestamp masterUrl
-          unless (run && already) $
-            maybe (return ()) putStrLn $ showMSize masterSize <> showMDate mlocaltime
-          let finalDir = dropFileName finalurl
-          return (finalurl, prefix, (masterUrl,masterSize), (finalDir </>) . T.unpack <$> mchecksum, already)
-        where
-          httpTimestamp url = do
-            mUtc <- httpLastModified mgr url
-            case mUtc of
-              Nothing -> return Nothing
-              Just u -> Just <$> utcToLocalZonedTime u
-
-          showMSize = fmap (\ s -> "size " <> show s <> " ")
-          showMDate = fmap (\ s -> "(" <> show s <> ")")
-
-          findMirror masterUrl path file = do
-            url <-
-              if mirror `elem` [dlFpo,kojiPkgs,odcsFpo] then return masterUrl
-                else
-                if mirror /= downloadFpo then return $ mirror </> path
-                else do
-                  redir <- httpRedirect mgr $ mirror </> path </> file
-                  case redir of
-                    Nothing -> error' $ mirror </> path </> file <> " redirect failed"
-                    Just u -> do
-                      let url = B.unpack u
-                      exists <- httpExists mgr url
-                      if exists then return url
-                        else return masterUrl
-            return (url,False)
-
-    checkLocalFileSize localfile masterSize showdestdir = do
-      localsize <- toInteger . fileSize <$> getFileStatus localfile
-      if Just localsize == masterSize
-        then do
-        when (not run && takeExtension localfile == ".iso") $
-          putStrLn $ showdestdir </> localfile
-        return True
-        else do
-        let showsize =
-              case masterSize of
-                Nothing -> show localsize
-                Just ms -> show (100 * localsize `div` ms) <> "%"
-        putStrLn $ "File " <> showsize <> " downloaded"
-        return False
-
-    urlPathMRel :: Manager -> IO (FilePath, Maybe String)
-    urlPathMRel mgr = do
-      let subdir =
-            if edition `elem` fedoraSpins
-            then joinPath ["Spins", arch, "iso"]
-            else joinPath [show edition, arch, editionMedia edition]
-      case tgtrel of
-        "respin" -> return ("alt/live-respins", Nothing)
-        "rawhide" -> return ("fedora/linux/development/rawhide" </> subdir, Just "Rawhide")
-        "test" -> testRelease mgr subdir
-        "stage" -> stageRelease mgr subdir
-        "eln" -> return ("production/latest-Fedora-ELN/compose" </> "Everything" </> arch </> "iso", Nothing)
-        "koji" -> kojiCompose mgr subdir
-        rel | all isDigit rel -> released mgr rel subdir
-        _ -> error' "Unknown release"
-
-    testRelease :: Manager -> FilePath -> IO (FilePath, Maybe String)
-    testRelease mgr subdir = do
-      let path = "fedora/linux" </> "releases/test"
-          url = dlFpo </> path
-      -- use http-directory-0.1.7 noTrailingSlash
-      rels <- map (T.unpack . T.dropWhileEnd (== '/')) <$> httpDirectory mgr url
-      let mrel = listToMaybe rels
-      return (path </> fromMaybe (error' ("test release not found in " <> url)) mrel </> subdir, mrel)
-
-    stageRelease :: Manager -> FilePath -> IO (FilePath, Maybe String)
-    stageRelease mgr subdir = do
-      let path = "alt/stage"
-          url = dlFpo </> path
-      -- use http-directory-0.1.7 noTrailingSlash
-      rels <- reverse . map (T.unpack . T.dropWhileEnd (== '/')) <$> httpDirectory mgr url
-      let mrel = listToMaybe rels
-      return (path </> fromMaybe (error' ("staged release not found in " <> url)) mrel </> subdir, takeWhile (/= '_') <$> mrel)
-
-    kojiCompose :: Manager -> FilePath -> IO (FilePath, Maybe String)
-    kojiCompose mgr subdir = do
-      let path = "branched"
-          url = kojiPkgs </> path
-          prefix = "latest-Fedora-"
-      latest <- filter (prefix `isPrefixOf`) . map T.unpack <$> httpDirectory mgr url
-      let mlatest = listToMaybe latest
-      return (path </> fromMaybe (error' ("koji branched latest dir not not found in " <> url)) mlatest </> "compose" </> subdir, removePrefix prefix <$> mlatest)
-
-    -- use https://admin.fedoraproject.org/pkgdb/api/collections ?
-    released :: Manager -> FilePath -> FilePath -> IO (FilePath, Maybe String)
-    released mgr rel subdir = do
-      let dir = "fedora/linux/releases"
-          url = dlFpo </> dir
-      exists <- httpExists mgr $ url </> rel
-      if exists then return (dir </> rel </> subdir, Just rel)
-        else do
-        let dir' = "fedora/linux/development"
-            url' = dlFpo </> dir'
-        exists' <- httpExists mgr $ url' </> rel
-        if exists' then return (dir' </> rel </> subdir, Just rel)
-          else error' "release not found in releases/ or development/"
-
-    makeFilePrefix :: Maybe String -> String
-    makeFilePrefix mrelease =
-      case tgtrel of
-        "respin" -> "F[1-9][0-9]*-" <> liveRespin edition <> "-x86_64" <> "-LIVE"
-        "eln" -> "Fedora-ELN-Rawhide"
-        _ ->
-          let showRel r = if last r == '/' then init r else r
-              rel = maybeToList (showRel <$> mrelease)
-              middle =
-                if edition `elem` [Cloud, Container]
-                then rel ++ [".*" <> arch]
-                else arch : rel
-          in
-            intercalate "-" (["Fedora", show edition, editionType edition] ++ middle)
-
-    downloadFile :: Bool -> Manager -> URL -> (URL, Maybe Integer) -> IO Bool
-    downloadFile done mgr url (masterUrl,masterSize) =
-      if done
-        then return False
-        else do
-        when (url /= masterUrl) $ do
-          mirrorSize <- httpFileSize mgr url
-          unless (mirrorSize == masterSize) $
-            putStrLn "Warning!  Mirror filesize differs from master file"
-        putStrLn url
-        if dryrun then return False
-          else do
-          cmd_ "curl" ["-C", "-", "-O", url]
-          return True
-
-    fileChecksum :: Manager -> Maybe URL -> String -> Bool -> IO ()
-    fileChecksum _ Nothing _ _ = return ()
-    fileChecksum mgr (Just url) showdestdir needChecksum =
-      when ((needChecksum && checksum /= NoCheckSum) || checksum == CheckSum) $ do
-        let checksumdir = ".dl-fedora-checksums"
-            checksumfile = checksumdir </> takeFileName url
-        exists <- do
-          dirExists <- doesDirectoryExist checksumdir
-          if dirExists then checkChecksumfile mgr url checksumfile showdestdir
-            else createDirectory checksumdir >> return False
-        putStrLn ""
-        unless exists $
-          whenM (httpExists mgr url) $
-            withCurrentDirectory checksumdir $
-            cmd_ "curl" ["-C", "-", "-s", "-S", "-O", url]
-        haveChksum <- doesFileExist checksumfile
-        if not haveChksum
-          then putStrLn "No checksum file found"
-          else do
-          pgp <- grep_ "PGP" checksumfile
-          when (gpg && pgp) $ do
-            havekey <- checkForFedoraKeys
-            unless havekey $ do
-              putStrLn "Importing Fedora GPG keys:\n"
-              -- https://fedoramagazine.org/verify-fedora-iso-file/
-              pipe_ ("curl",["-s", "-S", "https://getfedora.org/static/fedora.gpg"]) ("gpg",["--import"])
-              putStrLn ""
-          chkgpg <- if pgp
-            then checkForFedoraKeys
-            else return False
-          let shasum = if "CHECKSUM512" `isPrefixOf` takeFileName checksumfile
-                       then "sha512sum" else "sha256sum"
-          if chkgpg then do
-            putStrLn $ "Running gpg verify and " <> shasum <> ":"
-            pipeFile_ checksumfile ("gpg",["-q"]) (shasum, ["-c", "--ignore-missing"])
-            else do
-            putStrLn $ "Running " <> shasum <> ":"
-            cmd_ shasum ["-c", "--ignore-missing", checksumfile]
-
-    checkChecksumfile :: Manager -> URL -> FilePath -> String -> IO Bool
-    checkChecksumfile mgr url checksumfile showdestdir = do
-      exists <- doesFileExist checksumfile
-      if not exists then return False
-        else do
-        masterSize <- httpFileSize mgr url
-        ok <- checkLocalFileSize checksumfile masterSize showdestdir
-        unless ok $ error' "Checksum file filesize mismatch"
-        return ok
-
-    checkForFedoraKeys :: IO Bool
-    checkForFedoraKeys =
-      pipeBool ("gpg",["--list-keys"]) ("grep", ["-q", " Fedora .*(" <> tgtrel <> ").*@fedoraproject.org>"])
-
-    updateSymlink :: FilePath -> FilePath -> FilePath -> IO ()
-    updateSymlink target symlink showdestdir = do
-      mmsymlinkTarget <- do
-        havefile <- doesFileExist symlink
-        if havefile
-          then Just . Just <$> readSymbolicLink symlink
-          else do
-          -- check for broken symlink
-          dirfiles <- listDirectory "."
-          return $ if symlink `elem` dirfiles then Just Nothing else Nothing
-      case mmsymlinkTarget of
-        Nothing -> makeSymlink
-        Just Nothing -> do
-          removeFile symlink
-          makeSymlink
-        Just (Just symlinktarget) -> do
-          when (symlinktarget /= target) $ do
-            when removeold $ removeFile symlinktarget
-            removeFile symlink
-            makeSymlink
-      where
-        makeSymlink = do
-          putStrLn ""
-          createSymbolicLink target symlink
-          putStrLn $ unwords [showdestdir </> symlink, "->", target]
-
-editionType :: FedoraEdition -> String
-editionType Server = "dvd"
-editionType Silverblue = "ostree"
-editionType Everything = "netinst"
-editionType Cloud = "Base"
-editionType Container = "Base"
-editionType _ = "Live"
-
-editionMedia :: FedoraEdition -> String
-editionMedia Cloud = "images"
-editionMedia Container = "images"
-editionMedia _ = "iso"
-
-liveRespin :: FedoraEdition -> String
-liveRespin = take 4 . map toUpper . show
-
-infixr 5 </>
-(</>) :: String -> String -> String
-"" </> s = s
-s </> "" = s
-s </> t | last s == '/' = init s </> t
-        | head t == '/' = s </> tail t
-s </> t = s <> "/" <> t
-
-bootImage :: FilePath -> String -> IO ()
-bootImage img showdir = do
-  let fileopts =
-        case takeExtension img of
-          ".iso" -> ["-boot", "d", "-cdrom"]
-          _ -> []
-  mQemu <- findExecutable "qemu-kvm"
-  case mQemu of
-    Just qemu -> do
-      let args = ["-m", "2048", "-rtc", "base=localtime"] ++ fileopts
-      cmdN qemu (args ++ [showdir </> img])
-      cmd_ qemu (args ++ [img])
-    Nothing -> error' "Need qemu to run image"
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -13,17 +13,20 @@
 
 `dl-fedora rawhide` : downloads the latest Fedora Rawhide Workstation Live iso
 
-`dl-fedora -e silverblue 33` : downloads the Fedora Silverblue iso
+`dl-fedora -e silverblue 34` : downloads the Fedora Silverblue iso
 
 `dl-fedora -e kde respin` : downloads the latest KDE Live respin
 
-`dl-fedora --edition server --arch aarch64 32` : will bring down the F32 Server iso
+`dl-fedora --edition server --arch aarch64 33` : will bring down the F33 Server iso for armv8
 
-`dl-fedora --run 33` : will download Fedora 33 Workstation and boot the Live image with qemu-kvm.
+`dl-fedora --run 34` : will download Fedora 34 Workstation and boot the Live image with qemu-kvm.
 
-If the image is already in the Downloads/ directory
+By default dl-fedora downloads to `~/Downloads/`, but if you create
+`~/Downloads/iso/` it will use that directory instead.
+
+If the image is already found to be downloaded
 it will not be downloaded again of course.
-Curl is used to do the downloading.
+Curl is used to do the downloading: partial downloads will continue.
 
 A symlink to the latest iso is also created:
 eg for rawhide it might be `"Fedora-Workstation-Live-x86_64-Rawhide-latest.iso"`.
diff --git a/dl-fedora.cabal b/dl-fedora.cabal
--- a/dl-fedora.cabal
+++ b/dl-fedora.cabal
@@ -1,6 +1,6 @@
 cabal-version:       1.18
 name:                dl-fedora
-version:             0.7.6
+version:             0.7.7
 synopsis:            Fedora image download tool
 description:         Tool to download Fedora iso and image files
 -- can change to GPL-3.0-or-later with Cabal-2.2
@@ -25,7 +25,7 @@
   location: https://github.com/juhp/dl-fedora.git
 
 executable dl-fedora
-  main-is:             Main.hs
+  main-is:             src/Main.hs
   other-modules:       Paths_dl_fedora
 
   build-depends:       base < 5,
@@ -50,3 +50,15 @@
                        -Wall
 
   default-language:    Haskell2010
+
+test-suite test
+    main-is: tests.hs
+    type: exitcode-stdio-1.0
+    hs-source-dirs: test
+
+    default-language: Haskell2010
+
+    ghc-options:   -Wall
+    build-depends: base >= 4 && < 5
+                 , simple-cmd
+    build-tools:   dl-fedora
diff --git a/src/Main.hs b/src/Main.hs
new file mode 100644
--- /dev/null
+++ b/src/Main.hs
@@ -0,0 +1,451 @@
+{-# LANGUAGE CPP #-}
+
+#if !MIN_VERSION_base(4,13,0)
+import Control.Applicative ((<|>)
+#if !MIN_VERSION_base(4,8,0)
+  , (<$>), (<*>)
+#endif
+  )
+import Data.Semigroup ((<>))
+#endif
+
+import Control.Monad.Extra
+
+import qualified Data.ByteString.Char8 as B
+import Data.Char (isDigit)
+import Data.List.Extra
+import Data.Maybe
+import qualified Data.Text as T
+import Data.Time.LocalTime (utcToLocalZonedTime)
+
+import Network.HTTP.Directory
+
+import Options.Applicative (fullDesc, header, progDescDoc)
+import qualified Options.Applicative.Help.Pretty as P
+
+import Paths_dl_fedora (version)
+
+import SimpleCmd (cmd_, cmdN, error', grep_, pipe_, pipeBool, pipeFile_,
+                  removePrefix)
+import SimpleCmdArgs
+
+import System.Directory (createDirectory, createDirectoryIfMissing,
+                         doesDirectoryExist, doesFileExist, findExecutable,
+                         getHomeDirectory, getPermissions, listDirectory,
+                         removeFile, setCurrentDirectory, withCurrentDirectory,
+                         writable)
+import System.Environment.XDG.UserDir (getUserDir)
+import System.FilePath (dropFileName, isRelative , joinPath, makeRelative,
+                        takeExtension, takeFileName, (<.>))
+import System.Posix.Files (createSymbolicLink, fileSize, getFileStatus,
+                           readSymbolicLink)
+
+import Text.Read
+import qualified Text.ParserCombinators.ReadP as R
+import qualified Text.ParserCombinators.ReadPrec as RP
+import Text.Regex.Posix
+
+{-# ANN module "HLint: ignore Use camelCase" #-}
+data FedoraEdition = Cloud
+                   | Container
+                   | Everything
+                   | Server
+                   | Silverblue
+                   | Workstation
+                   | Cinnamon
+                   | KDE
+                   | LXDE
+                   | LXQt
+                   | MATE
+                   | Soas
+                   | Xfce
+                   | I3
+ deriving (Show, Enum, Bounded, Eq)
+
+showEdition :: FedoraEdition -> String
+showEdition MATE = "MATE_Compiz"
+showEdition I3 = "i3"
+showEdition e = show e
+
+lowerEdition :: FedoraEdition -> String
+lowerEdition = lower . show
+
+instance Read FedoraEdition where
+  readPrec = do
+    s <- look
+    let e = lower s
+        editionMap =
+          map (\ ed -> (lowerEdition ed, ed)) [minBound..maxBound]
+        res = lookup e editionMap
+    case res of
+      Nothing -> error' "unknown edition" >> RP.pfail
+      Just ed -> RP.lift (R.string e) >> return ed
+
+type URL = String
+
+fedoraSpins :: [FedoraEdition]
+fedoraSpins = [Cinnamon ..]
+
+data CheckSum = AutoCheckSum | NoCheckSum | CheckSum
+  deriving Eq
+
+dlFpo, downloadFpo, kojiPkgs, odcsFpo :: String
+dlFpo = "https://dl.fedoraproject.org/pub"
+downloadFpo = "https://download.fedoraproject.org/pub"
+kojiPkgs = "https://kojipkgs.fedoraproject.org/compose"
+odcsFpo = "https://odcs.fedoraproject.org/composes"
+
+main :: IO ()
+main = do
+  let pdoc = Just $ P.vcat
+             [ P.text "Tool for downloading Fedora iso file images.",
+               P.text ("RELEASE = " <> intercalate ", " ["release number", "respin", "rawhide", "test (Beta)", "stage (RC)", "eln", "or koji"]),
+               P.text "EDITION = " <> P.lbrace <> P.align (P.fillCat (P.punctuate P.comma (map (P.text . lowerEdition) [(minBound :: FedoraEdition)..maxBound])) <> P.rbrace),
+               P.text "",
+               P.text "See <https://fedoraproject.org/wiki/Infrastructure/MirrorManager>",
+               P.text "and also <https://fedoramagazine.org/verify-fedora-iso-file>."
+             ]
+  simpleCmdArgsWithMods (Just version) (fullDesc <> header "Fedora iso downloader" <> progDescDoc pdoc) $
+    program
+    <$> switchWith 'g' "gpg-keys" "Import Fedora GPG keys for verifying checksum file"
+    <*> checkSumOpts
+    <*> switchWith 'n' "dry-run" "Don't actually download anything"
+    <*> switchWith 'r' "run" "Boot image in Qemu"
+    <*> switchWith 'R' "replace" "Delete old image after downloading new one"
+    <*> optional mirrorOpt
+    <*> strOptionalWith 'a' "arch" "ARCH" "Architecture [default: x86_64]" "x86_64"
+    <*> optionalWith auto 'e' "edition" "EDITION" "Fedora edition [default: workstation]" Workstation
+    <*> strArg "RELEASE"
+  where
+    mirrorOpt :: Parser String
+    mirrorOpt =
+      flagWith' dlFpo 'd' "dl" "Use dl.fedoraproject.org" <|>
+      strOptionWith 'm' "mirror" "HOST" ("Mirror url for /pub [default " ++ downloadFpo ++ "]")
+
+    checkSumOpts :: Parser CheckSum
+    checkSumOpts =
+      flagWith' NoCheckSum 'C' "no-checksum" "Do not check checksum" <|>
+      flagWith AutoCheckSum CheckSum 'c' "checksum" "Do checksum even if already downloaded"
+
+program :: Bool -> CheckSum -> Bool -> Bool -> Bool -> Maybe String -> String -> FedoraEdition -> String -> IO ()
+program gpg checksum dryrun run removeold mmirror arch edition tgtrel = do
+  let mirror =
+        case mmirror of
+          Nothing | tgtrel == "koji" -> kojiPkgs
+          Nothing | tgtrel == "eln" -> odcsFpo
+          Nothing -> downloadFpo
+          Just _ | tgtrel == "koji" -> error' "Cannot specify mirror for koji"
+          Just m -> m
+  home <- getHomeDirectory
+  dlDir <- setDownloadDir home
+  mgr <- httpManager
+  let showdestdir =
+        let path = makeRelative home dlDir in
+          if isRelative path then "~" </> path else path
+  (fileurl, filenamePrefix, (masterUrl,masterSize), mchecksum, done) <- findURL mgr mirror showdestdir
+  downloadFile done mgr fileurl (masterUrl,masterSize) >>= fileChecksum mgr mchecksum showdestdir
+  unless dryrun $ do
+    let localfile = takeFileName fileurl
+        symlink = filenamePrefix <> (if tgtrel == "eln" then "-" <> arch else "") <> "-latest" <.> takeExtension fileurl
+    updateSymlink localfile symlink showdestdir
+    when run $ bootImage localfile showdestdir
+  where
+    setDownloadDir home = do
+      dlDir <- getUserDir "DOWNLOAD"
+      dirExists <- doesDirectoryExist dlDir
+      unless (dryrun || dirExists) $
+        when (home == dlDir) $
+          error' "HOME directory does not exist!"
+      dlIsoDir <- let isodir = dlDir </> "iso" in
+        ifM (doesDirectoryExist isodir) (return isodir) $ do
+        unless (dirExists || dryrun) $ createDirectoryIfMissing True dlDir
+        return dlDir
+      setCurrentDirectory dlIsoDir
+      return dlIsoDir
+
+    -- urlpath, fileprefix, (master,size), checksum, downloaded
+    findURL :: Manager -> String -> String -> IO (URL, String, (URL,Maybe Integer), Maybe String, Bool)
+    findURL mgr mirror showdestdir = do
+      (path,mrelease) <- urlPathMRel mgr
+      -- use http-directory trailingSlash (0.1.7)
+      let masterDir =
+            (case tgtrel of
+               "koji" -> kojiPkgs
+               "eln" -> odcsFpo
+               _ -> dlFpo) </> path <> "/"
+      hrefs <- httpDirectory mgr masterDir
+      let prefixPat = makeFilePrefix mrelease
+          selector = if '*' `elem` prefixPat then (=~ prefixPat) else (prefixPat `isPrefixOf`)
+          mfile = find selector $ map T.unpack hrefs
+          mchecksum = find ((if tgtrel == "respin" then T.isPrefixOf else T.isSuffixOf) (T.pack "CHECKSUM")) hrefs
+      case mfile of
+        Nothing ->
+          error' $ "no match for " <> prefixPat <> " in " <> masterDir
+        Just file -> do
+          let prefix = if '*' `elem` prefixPat
+                       then (file =~ prefixPat) ++ if arch `isInfixOf` prefixPat then "" else arch
+                       else prefixPat
+              masterUrl = masterDir </> file
+          masterSize <- httpFileSize mgr masterUrl
+          (finalurl, already) <- do
+            let localfile = takeFileName masterUrl
+            exists <- doesFileExist localfile
+            if exists
+              then do
+              done <- checkLocalFileSize localfile masterSize showdestdir
+              if done
+                then return (masterUrl,True)
+                else do
+                unlessM (writable <$> getPermissions localfile) $
+                  error' $ localfile <> " does have write permission, aborting!"
+                findMirror masterUrl path file
+              else findMirror masterUrl path file
+          mlocaltime <- httpTimestamp masterUrl
+          unless (run && already) $
+            maybe (return ()) putStrLn $ showMSize masterSize <> showMDate mlocaltime
+          let finalDir = dropFileName finalurl
+          return (finalurl, prefix, (masterUrl,masterSize), (finalDir </>) . T.unpack <$> mchecksum, already)
+        where
+          httpTimestamp url = do
+            mUtc <- httpLastModified mgr url
+            case mUtc of
+              Nothing -> return Nothing
+              Just u -> Just <$> utcToLocalZonedTime u
+
+          showMSize = fmap (\ s -> "size " <> show s <> " ")
+          showMDate = fmap (\ s -> "(" <> show s <> ")")
+
+          findMirror masterUrl path file = do
+            url <-
+              if mirror `elem` [dlFpo,kojiPkgs,odcsFpo] then return masterUrl
+                else
+                if mirror /= downloadFpo then return $ mirror </> path
+                else do
+                  redir <- httpRedirect mgr $ mirror </> path </> file
+                  case redir of
+                    Nothing -> error' $ mirror </> path </> file <> " redirect failed"
+                    Just u -> do
+                      let url = B.unpack u
+                      exists <- httpExists mgr url
+                      if exists then return url
+                        else return masterUrl
+            return (url,False)
+
+    checkLocalFileSize localfile masterSize showdestdir = do
+      localsize <- toInteger . fileSize <$> getFileStatus localfile
+      if Just localsize == masterSize
+        then do
+        when (not run && takeExtension localfile == ".iso") $
+          putStrLn $ showdestdir </> localfile
+        return True
+        else do
+        let showsize =
+              case masterSize of
+                Nothing -> show localsize
+                Just ms -> show (100 * localsize `div` ms) <> "%"
+        putStrLn $ "File " <> showsize <> " downloaded"
+        return False
+
+    urlPathMRel :: Manager -> IO (FilePath, Maybe String)
+    urlPathMRel mgr = do
+      let subdir =
+            if edition `elem` fedoraSpins
+            then joinPath ["Spins", arch, "iso"]
+            else joinPath [showEdition edition, arch, editionMedia edition]
+      case tgtrel of
+        "respin" -> return ("alt/live-respins", Nothing)
+        "rawhide" -> return ("fedora/linux/development/rawhide" </> subdir, Just "Rawhide")
+        "test" -> testRelease mgr subdir
+        "stage" -> stageRelease mgr subdir
+        "eln" -> return ("production/latest-Fedora-ELN/compose" </> "Everything" </> arch </> "iso", Nothing)
+        "koji" -> kojiCompose mgr subdir
+        rel | all isDigit rel -> released mgr rel subdir
+        _ -> error' "Unknown release"
+
+    testRelease :: Manager -> FilePath -> IO (FilePath, Maybe String)
+    testRelease mgr subdir = do
+      let path = "fedora/linux" </> "releases/test"
+          url = dlFpo </> path
+      -- use http-directory-0.1.7 noTrailingSlash
+      rels <- map (T.unpack . T.dropWhileEnd (== '/')) <$> httpDirectory mgr url
+      let mrel = listToMaybe rels
+      return (path </> fromMaybe (error' ("test release not found in " <> url)) mrel </> subdir, mrel)
+
+    stageRelease :: Manager -> FilePath -> IO (FilePath, Maybe String)
+    stageRelease mgr subdir = do
+      let path = "alt/stage"
+          url = dlFpo </> path
+      -- use http-directory-0.1.7 noTrailingSlash
+      rels <- reverse . map (T.unpack . T.dropWhileEnd (== '/')) <$> httpDirectory mgr url
+      let mrel = listToMaybe rels
+      return (path </> fromMaybe (error' ("staged release not found in " <> url)) mrel </> subdir, takeWhile (/= '_') <$> mrel)
+
+    kojiCompose :: Manager -> FilePath -> IO (FilePath, Maybe String)
+    kojiCompose mgr subdir = do
+      let path = "branched"
+          url = kojiPkgs </> path
+          prefix = "latest-Fedora-"
+      latest <- filter (prefix `isPrefixOf`) . map T.unpack <$> httpDirectory mgr url
+      let mlatest = listToMaybe latest
+      return (path </> fromMaybe (error' ("koji branched latest dir not not found in " <> url)) mlatest </> "compose" </> subdir, removePrefix prefix <$> mlatest)
+
+    -- use https://admin.fedoraproject.org/pkgdb/api/collections ?
+    released :: Manager -> FilePath -> FilePath -> IO (FilePath, Maybe String)
+    released mgr rel subdir = do
+      let dir = "fedora/linux/releases"
+          url = dlFpo </> dir
+      exists <- httpExists mgr $ url </> rel
+      if exists then return (dir </> rel </> subdir, Just rel)
+        else do
+        let dir' = "fedora/linux/development"
+            url' = dlFpo </> dir'
+        exists' <- httpExists mgr $ url' </> rel
+        if exists' then return (dir' </> rel </> subdir, Just rel)
+          else error' "release not found in releases/ or development/"
+
+    makeFilePrefix :: Maybe String -> String
+    makeFilePrefix mrelease =
+      case tgtrel of
+        "respin" -> "F[1-9][0-9]*-" <> liveRespin edition <> "-x86_64" <> "-LIVE"
+        "eln" -> "Fedora-ELN-Rawhide"
+        _ ->
+          let showRel r = if last r == '/' then init r else r
+              rel = maybeToList (showRel <$> mrelease)
+              middle =
+                if edition `elem` [Cloud, Container]
+                then rel ++ [".*" <> arch]
+                else arch : rel
+          in
+            intercalate "-" (["Fedora", showEdition edition, editionType edition] ++ middle)
+
+    downloadFile :: Bool -> Manager -> URL -> (URL, Maybe Integer) -> IO Bool
+    downloadFile done mgr url (masterUrl,masterSize) =
+      if done
+        then return False
+        else do
+        when (url /= masterUrl) $ do
+          mirrorSize <- httpFileSize mgr url
+          unless (mirrorSize == masterSize) $
+            putStrLn "Warning!  Mirror filesize differs from master file"
+        putStrLn url
+        if dryrun then return False
+          else do
+          cmd_ "curl" ["-C", "-", "-O", url]
+          return True
+
+    fileChecksum :: Manager -> Maybe URL -> String -> Bool -> IO ()
+    fileChecksum _ Nothing _ _ = return ()
+    fileChecksum mgr (Just url) showdestdir needChecksum =
+      when ((needChecksum && checksum /= NoCheckSum) || checksum == CheckSum) $ do
+        let checksumdir = ".dl-fedora-checksums"
+            checksumfile = checksumdir </> takeFileName url
+        exists <- do
+          dirExists <- doesDirectoryExist checksumdir
+          if dirExists then checkChecksumfile mgr url checksumfile showdestdir
+            else createDirectory checksumdir >> return False
+        putStrLn ""
+        unless exists $
+          whenM (httpExists mgr url) $
+            withCurrentDirectory checksumdir $
+            cmd_ "curl" ["-C", "-", "-s", "-S", "-O", url]
+        haveChksum <- doesFileExist checksumfile
+        if not haveChksum
+          then putStrLn "No checksum file found"
+          else do
+          pgp <- grep_ "PGP" checksumfile
+          when (gpg && pgp) $ do
+            havekey <- checkForFedoraKeys
+            unless havekey $ do
+              putStrLn "Importing Fedora GPG keys:\n"
+              -- https://fedoramagazine.org/verify-fedora-iso-file/
+              pipe_ ("curl",["-s", "-S", "https://getfedora.org/static/fedora.gpg"]) ("gpg",["--import"])
+              putStrLn ""
+          chkgpg <- if pgp
+            then checkForFedoraKeys
+            else return False
+          let shasum = if "CHECKSUM512" `isPrefixOf` takeFileName checksumfile
+                       then "sha512sum" else "sha256sum"
+          if chkgpg then do
+            putStrLn $ "Running gpg verify and " <> shasum <> ":"
+            pipeFile_ checksumfile ("gpg",["-q"]) (shasum, ["-c", "--ignore-missing"])
+            else do
+            putStrLn $ "Running " <> shasum <> ":"
+            cmd_ shasum ["-c", "--ignore-missing", checksumfile]
+
+    checkChecksumfile :: Manager -> URL -> FilePath -> String -> IO Bool
+    checkChecksumfile mgr url checksumfile showdestdir = do
+      exists <- doesFileExist checksumfile
+      if not exists then return False
+        else do
+        masterSize <- httpFileSize mgr url
+        ok <- checkLocalFileSize checksumfile masterSize showdestdir
+        unless ok $ error' "Checksum file filesize mismatch"
+        return ok
+
+    checkForFedoraKeys :: IO Bool
+    checkForFedoraKeys =
+      pipeBool ("gpg",["--list-keys"]) ("grep", ["-q", " Fedora .*(" <> tgtrel <> ").*@fedoraproject.org>"])
+
+    updateSymlink :: FilePath -> FilePath -> FilePath -> IO ()
+    updateSymlink target symlink showdestdir = do
+      mmsymlinkTarget <- do
+        havefile <- doesFileExist symlink
+        if havefile
+          then Just . Just <$> readSymbolicLink symlink
+          else do
+          -- check for broken symlink
+          dirfiles <- listDirectory "."
+          return $ if symlink `elem` dirfiles then Just Nothing else Nothing
+      case mmsymlinkTarget of
+        Nothing -> makeSymlink
+        Just Nothing -> do
+          removeFile symlink
+          makeSymlink
+        Just (Just symlinktarget) -> do
+          when (symlinktarget /= target) $ do
+            when removeold $ removeFile symlinktarget
+            removeFile symlink
+            makeSymlink
+      where
+        makeSymlink = do
+          putStrLn ""
+          createSymbolicLink target symlink
+          putStrLn $ unwords [showdestdir </> symlink, "->", target]
+
+editionType :: FedoraEdition -> String
+editionType Server = "dvd"
+editionType Silverblue = "ostree"
+editionType Everything = "netinst"
+editionType Cloud = "Base"
+editionType Container = "Base"
+editionType _ = "Live"
+
+editionMedia :: FedoraEdition -> String
+editionMedia Cloud = "images"
+editionMedia Container = "images"
+editionMedia _ = "iso"
+
+liveRespin :: FedoraEdition -> String
+liveRespin = take 4 . upper . showEdition
+
+infixr 5 </>
+(</>) :: String -> String -> String
+"" </> s = s
+s </> "" = s
+s </> t | last s == '/' = init s </> t
+        | head t == '/' = s </> tail t
+s </> t = s <> "/" <> t
+
+bootImage :: FilePath -> String -> IO ()
+bootImage img showdir = do
+  let fileopts =
+        case takeExtension img of
+          ".iso" -> ["-boot", "d", "-cdrom"]
+          _ -> []
+  mQemu <- findExecutable "qemu-kvm"
+  case mQemu of
+    Just qemu -> do
+      let args = ["-m", "2048", "-rtc", "base=localtime"] ++ fileopts
+      cmdN qemu (args ++ [showdir </> img])
+      cmd_ qemu (args ++ [img])
+    Nothing -> error' "Need qemu to run image"
diff --git a/test/tests.hs b/test/tests.hs
new file mode 100644
--- /dev/null
+++ b/test/tests.hs
@@ -0,0 +1,22 @@
+import SimpleCmd
+
+dlFedora :: [String] -> IO ()
+dlFedora args =
+  putStrLn "" >> cmdLog "dl-fedora" args
+
+tests :: [[String]]
+tests =
+  [["-n", "33", "-c"]
+  ,["-n", "rawhide", "-e", "silverblue"]
+  ,["-n", "34", "-e", "silverblue"]
+  ,["respin"]
+  ,["-n", "32", "-e", "kde"]
+  ,["-n", "33", "-e", "everything"]
+  ,["-n", "33", "-e", "server", "--arch", "aarch64"]
+  ,["-n", "34"]
+  ]
+
+main :: IO ()
+main = do
+  mapM_ dlFedora tests
+  putStrLn $ show (length tests) ++ " tests run"
