diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2022 Gautier DI FOLCO
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/app/Main.hs b/app/Main.hs
new file mode 100644
--- /dev/null
+++ b/app/Main.hs
@@ -0,0 +1,33 @@
+module Main where
+
+import qualified Dhall as Dhall
+import Librarian
+import Options.Applicative as Options
+
+main :: IO ()
+main = do
+  args <- parseArgs
+  rules <- Dhall.inputFile (Dhall.auto @[Rule]) $ rulesFile args
+  plan <- planMoves <$> fetchRulesOn "." rules
+  displayPlan plan
+  putStrLn "Move? (y/n)"
+  response <- getChar
+  case response of
+    'y' -> do
+      putStrLn "Proceeding"
+      runPlan plan >>= displayResult
+      putStrLn "Done."
+    _ -> putStrLn "Aborting"
+
+parseArgs :: IO Args
+parseArgs =
+  execParser $
+    info (argsParser <**> helper) (fullDesc <> header "Librarian: moving files, your rules!")
+  where
+    argsParser :: Parser Args
+    argsParser =
+      Args
+        <$> strOption (long "rules" <> short 'r' <> metavar "FILE_PATH" <> help "Rules file (.dhall)")
+
+newtype Args = Args {rulesFile :: FilePath}
+  deriving stock (Eq, Ord, Show)
diff --git a/librarian.cabal b/librarian.cabal
new file mode 100644
--- /dev/null
+++ b/librarian.cabal
@@ -0,0 +1,132 @@
+cabal-version:       3.0
+name:                librarian
+version:             0.1.0.0
+author:              Gautier DI FOLCO
+maintainer:          gautier.difolco@gmail.com
+category:            Data
+build-type:          Simple
+license:             ISC
+license-file:        LICENSE
+synopsis:            Move/rename according a set of rules
+description:         Move/rename according a set of rules.
+Homepage:            http://github.com/blackheaven/librarian
+tested-with:         GHC==9.2.2
+
+library
+  default-language:   Haskell2010
+  build-depends:
+      base == 4.*
+    , containers
+    , dhall
+    , directory
+    , easy-file
+    , Glob
+    , pretty-show
+    , regexpr
+  hs-source-dirs: src
+  exposed-modules:
+      Librarian
+  other-modules:
+      Paths_librarian
+  autogen-modules:
+      Paths_librarian
+  default-extensions:
+      DataKinds
+      DefaultSignatures
+      DeriveAnyClass
+      DeriveGeneric
+      DerivingStrategies
+      DerivingVia
+      DuplicateRecordFields
+      FlexibleContexts
+      GADTs
+      GeneralizedNewtypeDeriving
+      KindSignatures
+      LambdaCase
+      OverloadedLists
+      OverloadedStrings
+      RankNTypes
+      RecordWildCards
+      ScopedTypeVariables
+      TypeApplications
+      TypeFamilies
+      TypeOperators
+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints
+
+test-suite librarian-test
+  type: exitcode-stdio-1.0
+  hs-source-dirs: test
+  main-is: Spec.hs
+  other-modules:
+      LibrarianSpec
+      Paths_librarian
+  autogen-modules:
+      Paths_librarian
+  default-extensions:
+      DataKinds
+      DefaultSignatures
+      DeriveAnyClass
+      DeriveGeneric
+      DerivingStrategies
+      DerivingVia
+      DuplicateRecordFields
+      FlexibleContexts
+      GADTs
+      GeneralizedNewtypeDeriving
+      KindSignatures
+      LambdaCase
+      OverloadedLists
+      OverloadedStrings
+      RankNTypes
+      RecordWildCards
+      ScopedTypeVariables
+      TypeApplications
+      TypeFamilies
+      TypeOperators
+  ghc-options: -threaded -rtsopts -with-rtsopts=-N -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints
+  build-depends:
+      base
+    , librarian
+    , containers
+    , directory
+    , easy-file
+    , Glob
+    , hspec
+    , hspec-core
+    , hspec-discover
+    , temporary
+  default-language: Haskell2010
+
+executable librarian
+  -- type: exitcode-stdio-1.0
+  main-is: Main.hs
+  hs-source-dirs: app
+  default-extensions:
+      DataKinds
+      DefaultSignatures
+      DeriveAnyClass
+      DeriveGeneric
+      DerivingStrategies
+      DerivingVia
+      DuplicateRecordFields
+      FlexibleContexts
+      GADTs
+      GeneralizedNewtypeDeriving
+      KindSignatures
+      LambdaCase
+      OverloadedLists
+      OverloadedRecordDot
+      OverloadedStrings
+      RankNTypes
+      RecordWildCards
+      ScopedTypeVariables
+      TypeApplications
+      TypeFamilies
+      TypeOperators
+  ghc-options: -threaded -rtsopts -with-rtsopts=-N -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints
+  build-depends:
+      base
+    , librarian
+    , dhall
+    , optparse-applicative
+  default-language: Haskell2010
diff --git a/src/Librarian.hs b/src/Librarian.hs
new file mode 100644
--- /dev/null
+++ b/src/Librarian.hs
@@ -0,0 +1,146 @@
+module Librarian
+  ( Rule (..),
+    RuleName (..),
+
+    -- * Collecting
+    Matcher (..),
+    Mover (..),
+    CollectedFiles,
+    fetchRulesOn,
+
+    -- * Planning
+    Move (..),
+    planMoves,
+    displayPlan,
+
+    -- * Runner
+    Action (..),
+    ActionResult (..),
+    RunResult,
+    runPlan,
+    displayResult,
+  )
+where
+
+import Control.Exception (catch)
+import Control.Monad
+import Data.Function (on)
+import Data.Functor (($>))
+import Data.List (find, nubBy, sortOn)
+import qualified Data.Map.Strict as Map
+import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing)
+import Data.String (IsString)
+import Dhall (FromDhall)
+import GHC.Generics (Generic)
+import System.Directory (createDirectoryIfMissing, doesFileExist, renameFile)
+import System.EasyFile (splitFileName)
+import System.FilePath.Glob (compile, globDir)
+import Text.RegexPR
+import Text.Show.Pretty
+
+data Rule = Rule
+  { name :: RuleName,
+    match :: Matcher,
+    movers :: [Mover]
+  }
+  deriving stock (Eq, Show, Generic)
+
+instance FromDhall Rule
+
+newtype RuleName = RuleName {getRuleName :: String}
+  deriving stock (Generic)
+  deriving newtype (Eq, Ord, Show, IsString)
+  deriving (FromDhall) via String
+
+newtype Matcher = Matcher {matchPattern :: String}
+  deriving stock (Generic)
+  deriving newtype (Eq, Ord, Show, IsString)
+  deriving (FromDhall) via String
+
+data Mover = Mover
+  { inputPattern :: String,
+    newName :: String
+  }
+  deriving stock (Eq, Show, Generic)
+
+instance FromDhall Mover
+
+type CollectedFiles = Map.Map FilePath Rule
+
+fetchRulesOn :: FilePath -> [Rule] -> IO CollectedFiles
+fetchRulesOn root rules = do
+  matches <- globDir (compile . matchPattern . match <$> rules) root
+  let distributeRule :: [FilePath] -> Rule -> [(FilePath, Rule)]
+      distributeRule fs r = map (\f -> (f, r)) fs
+  files <- mapM (filterM doesFileExist) matches
+  return $ Map.unions $ map Map.fromList $ zipWith distributeRule files rules
+
+data Move = Move
+  { original :: FilePath,
+    new :: Maybe FilePath,
+    rule :: Rule
+  }
+  deriving stock (Eq, Show, Generic)
+
+planMoves :: CollectedFiles -> [Move]
+planMoves = map (uncurry planMove) . Map.toList
+  where
+    planMove :: FilePath -> Rule -> Move
+    planMove p rule =
+      Move
+        { original = p,
+          new = newPath p $ movers rule,
+          rule = rule
+        }
+    newPath :: FilePath -> [Mover] -> Maybe FilePath
+    newPath p =
+      fmap (\mover -> subRegexPR (inputPattern mover) (newName mover) p)
+        . find (isJust . flip matchRegexPR p . inputPattern)
+
+displayPlan :: [Move] -> IO ()
+displayPlan xs = do
+  forM_ xs $ \Move {..} ->
+    putStrLn $ "[" <> getRuleName (name rule) <> "] '" <> original <> "' -> '" <> fromMaybe "UNABLE TO REPLACE" new <> "'"
+  let unrenamed = nubBy ((==) `on` name) $ sortOn name $ map rule $ filter (isNothing . new) xs
+  unless (null unrenamed) $ do
+    putStrLn "Unable to generate a new name for:"
+    forM_ unrenamed pPrint
+
+data Action = Action
+  { from :: FilePath,
+    to :: FilePath
+  }
+  deriving stock (Eq, Show, Generic)
+
+data ActionResult
+  = Done
+  | Existing
+  | IOException IOError
+  deriving stock (Eq, Show, Generic)
+
+type RunResult = [(Action, ActionResult)]
+
+runPlan :: [Move] -> IO RunResult
+runPlan = fmap catMaybes . run
+  where
+    run =
+      mapM $ \Move {..} ->
+        case new of
+          Nothing -> return Nothing
+          Just newPath -> do
+            let action = Action original newPath
+            existing <- doesFileExist newPath
+            if existing
+              then return $ Just (action, Existing)
+              else do
+                prepareDirectory newPath
+                Just . (,) action <$> ((renameFile original newPath $> Done) `catch` (return . IOException))
+    prepareDirectory = createDirectoryIfMissing True . fst . splitFileName
+
+displayResult :: RunResult -> IO ()
+displayResult =
+  mapM_ $ \(Action {..}, result) ->
+    case result of
+      Done -> return ()
+      Existing -> putStrLn $ "'" <> from <> "' -> '" <> to <> "' ALREADY EXISTING"
+      IOException e -> putStrLn $ "'" <> from <> "' -> '" <> to <> "' IOError (" <> show e <> ")"
diff --git a/test/LibrarianSpec.hs b/test/LibrarianSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/LibrarianSpec.hs
@@ -0,0 +1,91 @@
+module LibrarianSpec
+  ( main,
+    spec,
+  )
+where
+
+import Control.Monad
+import qualified Data.Map.Strict as Map
+import Librarian
+import System.Directory
+import System.EasyFile
+import System.FilePath.Glob
+import System.IO.Temp
+import Test.Hspec
+
+main :: IO ()
+main = hspec spec
+
+spec :: Spec
+spec = do
+  describe "fetchRulesOn" $ do
+    it "Empty target directory should be empty" $
+      withFiles [] (fetchRulesOn "." rules)
+        `shouldReturn` mempty
+    it "Text files only should match only all rule" $
+      withFiles ["in/sub/0.txt", "in/1.txt"] (fetchRulesOn "." rules)
+        `shouldReturn` Map.fromList [("./in/sub/0.txt", rule1All), ("./in/1.txt", rule1All)]
+    it "Text/images files should match by priority" $
+      withFiles ["in/sub/0.jpg", "in/1.txt"] (fetchRulesOn "." rules)
+        `shouldReturn` Map.fromList [("./in/sub/0.jpg", rule0Jpg), ("./in/1.txt", rule1All)]
+  describe "planMoves" $ do
+    it "Images should be moved, texts should have their extension changed" $
+      planMoves (Map.fromList [("./in/sub/0.jpg", rule0Jpg), ("./in/1.txt", rule1All)])
+        `shouldBe` [ Move "./in/1.txt" (Just "./in/1.TXT") rule1All,
+                     Move "./in/sub/0.jpg" (Just "out/pics/0.jpg") rule0Jpg
+                   ]
+    it "Non-matching mover should be nothing" $
+      planMoves (Map.fromList [("./in/1.png", rule1All)])
+        `shouldBe` [Move "./in/1.png" Nothing rule1All]
+  describe "runPlan" $ do
+    let moveAll = fetchRulesOn "." [overridingRule] >>= runPlan . planMoves
+    it "Overriding paths should block the second move" $
+      withFiles ["in/0.txt", "in/sub/0.txt"] moveAll
+        `shouldReturn` [ (Action "./in/0.txt" "out/0.txt", Done),
+                         (Action "./in/sub/0.txt" "out/0.txt", Existing)
+                       ]
+    it "Overriding paths should keep the second file" $
+      withFiles ["in/0.txt", "in/sub/0.txt"] (moveAll >> listFiles)
+        `shouldReturn` ["./out/0.txt", "./in/sub/0.txt"]
+
+withFiles :: [FilePath] -> IO a -> IO a
+withFiles files act =
+  withSystemTempDirectory "librarian-tests" $ \d ->
+    withCurrentDirectory d $ do
+      mapM_ touch files
+      act
+
+touch :: FilePath -> IO ()
+touch target = do
+  createDirectoryIfMissing True $ fst $ splitFileName target
+  writeFile target "-"
+
+rules :: [Rule]
+rules = [rule0Jpg, rule1All]
+
+rule0Jpg :: Rule
+rule0Jpg =
+  Rule
+    { name = "Image files",
+      match = "**/*.jpg",
+      movers = [Mover "^.*/([^\\/]+)$" "out/pics/\\1"]
+    }
+
+rule1All :: Rule
+rule1All =
+  Rule
+    { name = "All files",
+      match = "**/*",
+      movers = [Mover "pdf$" "PDF", Mover "txt$" "TXT", Mover "txt$" "TxT"]
+    }
+
+overridingRule :: Rule
+overridingRule =
+  Rule
+    { name = "Text files",
+      match = "**/*.txt",
+      movers = [Mover "^.*/([^\\/]+)$" "out/\\1"]
+    }
+
+listFiles :: IO [FilePath]
+listFiles = glob "./**/*" >>= filterM doesFileExist
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,1 @@
+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
