From 33c932e23c083a42a46f5b2a04dfd265818b2945 Mon Sep 17 00:00:00 2001
From: Henrik Tramberend <henrik@tramberend.de>
Date: Sat, 17 Jun 2017 07:13:18 +0200
Subject: [PATCH] Working on resource resolution

---
 .vscode/launch.json  |  21 +++++++
 .vscode/phoityne.log |  16 +++++
 app/decker.hs        |  25 ++++----
 app/examiner.hs      |  10 +--
 slides.cabal         |   4 +-
 src/common.hs        |  31 ++++++++++
 src/context.hs       |  52 +++++-----------
 src/project.hs       | 141 ++++++++++++++++++++++++++++++-------------
 src/utilities.hs     | 106 +++++++++++++-------------------
 test/Spec.hs         | 102 ++++++++++++++++++++++++-------
 10 files changed, 323 insertions(+), 185 deletions(-)
 create mode 100644 .vscode/launch.json
 create mode 100644 .vscode/phoityne.log
 create mode 100644 src/common.hs

diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..50d43ca
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,21 @@
+{
+    "version": "0.2.0",
+    "configurations": [
+        {
+            "type": "ghc",
+            "name": "ghci debug viewer Phoityne",
+            "request": "launch",
+            "internalConsoleOptions": "openOnSessionStart",
+            "workspace": "${workspaceRoot}",
+            "startup": "${workspaceRoot}/test/Spec.hs",
+            "logFile": "${workspaceRoot}/.vscode/phoityne.log",
+            "logLevel": "WARNING",
+            "ghciPrompt": "H>>= ",
+            "ghciCmd": "stack ghci --test --no-load --no-build --main-is TARGET",
+            "ghciEnv": {},
+            "stopOnEntry": true,
+            "hackageVersion": "0.0.14.0",
+            "mainArgs": ""
+        }
+    ]
+}
\ No newline at end of file
diff --git a/.vscode/phoityne.log b/.vscode/phoityne.log
new file mode 100644
index 0000000..50416a3
--- /dev/null
+++ b/.vscode/phoityne.log
@@ -0,0 +1,16 @@
+2017-06-16 14:34:18 [ThreadId 11] ERROR phoityne - [deleteBreakPointOnGHCi] invalid delete break point. BreakPointData {nameBreakPointData = "Spec", srcPosBreakPointData = SourcePosition {filePathSourcePosition = "/Users/henrik/workspace/decker/test/Spec.hs", startLineNoSourcePosition = 97, startColNoSourcePosition = -1, endLineNoSourcePosition = -1, endColNoSourcePosition = -1}, breakNoBreakPointData = Nothing, conditionBreakPointData = Nothing, hitConditionBreakPointData = Nothing, hitCountBreakPointData = 0}
+2017-06-16 14:34:49 [ThreadId 16] ERROR phoityne - load file fail.[/Users/henrik/workspace/decker/test/Spec.hs] file load error. '/Users/henrik/workspace/decker/test/Spec.hs'
+2017-06-16 14:36:15 [ThreadId 11] ERROR phoityne - "error: Ambiguous occurrence \8216doesFileExist\8217 It could refer to either \8216Development.Shake.doesFileExist\8217, imported from \8216Development.Shake\8217 at /Users/henrik/workspace/decker/src/Context.hs:10:1-24 (and originally defined in \8216shake-0.15.11:Development.Shake.Rules.Directory\8217) or \8216System.Directory.doesFileExist\8217, imported from \8216System.Directory\8217 at /Users/henrik/workspace/decker/src/Filter.hs:23:1-23"
+2017-06-16 14:38:28 [ThreadId 11] ERROR phoityne - "\"extractSourcePosition\" (line 26, column 6):\nunexpected end of input\nexpecting \"Stopped at \" or \"Stopped in \" [INPUT]  Resolves a file path to a concrete verified file system path. FAILED [2]\ncacheRemoteFile\n  Stores the data behind a URL locally, if possible. Return the local path to the cached file.\ncacheRemoteImages\n  Replaces all remote images in the pandoc document with locally caches copies.\nparseStudentData\n  Parses student data in YAML format into a nifty data structure.\n\nFailures:\n\n  /Users/henrik/workspace/decker/test/Spec.hs:107: \n  1) copyResource Copies an existing resource to the public dir and returns the public URL.\n       expected: True\n        but got: False\n\n  /Users/henrik/workspace/decker/test/Spec.hs:112: \n  2) provisionResource Resolves a file path to a concrete verified file system path.\n       expected: True\n        but got: False\n\nRandomized with seed 1732182263\n\nFinished in 107.8106 seconds\n10 examples, 2 failures\n*** Exception: ExitFailure 1\nH>>= "
+2017-06-16 14:39:38 [ThreadId 11] ERROR phoityne - "error: Variable not in scope: aexists"
+2017-06-16 14:39:44 [ThreadId 11] ERROR phoityne - "error: Variable not in scope: aexists"
+2017-06-16 14:39:58 [ThreadId 11] ERROR phoityne - "error: Variable not in scope: aexists"
+2017-06-16 14:40:20 [ThreadId 11] ERROR phoityne - "error: Variable not in scope: b"
+2017-06-16 14:40:23 [ThreadId 11] ERROR phoityne - "error: Variable not in scope: a"
+2017-06-16 14:40:32 [ThreadId 11] ERROR phoityne - "error: Variable not in scope: a"
+2017-06-16 14:40:32 [ThreadId 11] ERROR phoityne - "error: Variable not in scope: b"
+2017-06-16 14:41:01 [ThreadId 11] ERROR phoityne - "error: Ambiguous occurrence \8216doesFileExist\8217 It could refer to either \8216Development.Shake.doesFileExist\8217, imported from \8216Development.Shake\8217 at /Users/henrik/workspace/decker/src/Context.hs:10:1-24 (and originally defined in \8216shake-0.15.11:Development.Shake.Rules.Directory\8217) or \8216System.Directory.doesFileExist\8217, imported from \8216System.Directory\8217 at /Users/henrik/workspace/decker/src/Filter.hs:23:1-23"
+2017-06-16 15:24:07 [ThreadId 11] ERROR phoityne - "error: Variable not in scope: a"
+2017-06-16 15:24:07 [ThreadId 11] ERROR phoityne - "error: Variable not in scope: b"
+2017-06-16 15:26:22 [ThreadId 11] ERROR phoityne - "error: parse error on input \8216then\8217"
+2017-06-16 15:26:23 [ThreadId 11] ERROR phoityne - "error: \8226 Variable not in scope: bt \8226 Perhaps you meant one of these: \8216Ghci6.b\8217 (imported from Ghci6), \8216Ghci7.b\8217 (imported from Ghci7), \8216b\8217 (<no location info>)"
diff --git a/app/decker.hs b/app/decker.hs
index 0aaed11..3527dfe 100644
--- a/app/decker.hs
+++ b/app/decker.hs
@@ -18,15 +18,16 @@ import qualified Text.Mustache as M ()
 import Text.Pandoc ()
 import Text.Printf ()
 import Utilities
+import Project
 
 version = "0.1.0"
 
 main :: IO ()
 main = do
-  projectDir <- calcProjectDirectory
-  let publicDir = projectDir </> "public"
-  let cacheDir = projectDir </> "cache"
-  let supportDir = publicDir </> "support"
+  dirs <- projectDirectories
+  let projectDir = (project dirs)
+  let publicDir = (public dirs)
+  let supportDir = (support dirs)
   -- Find sources. These are formulated as actions in the Action mondad, such
   -- that each new iteration rescans all possible source files.
   let deckSourcesA = globA "**/*-deck.md"
@@ -42,16 +43,16 @@ main = do
   let handoutsPdfA = deckSourcesA >>= calcTargets "-deck.md" "-handout.pdf"
   let pagesA = pageSourcesA >>= calcTargets ".md" ".html"
   let pagesPdfA = pageSourcesA >>= calcTargets ".md" ".pdf"
-  let indexSource = projectDir </> "index.md"
+  let indexSource = (project dirs) </> "index.md"
   let index = publicDir </> "index.html"
   let indexA = return [index] :: Action [FilePath]
   let everythingA = decksA <++> handoutsA <++> pagesA
   let everythingPdfA = decksPdfA <++> handoutsPdfA <++> pagesPdfA
   let cruft =
         map
-          (combine projectDir)
+          (combine (project dirs))
           ["index.md.generated", "server.log", "//.shake"]
-  context <- makeActionContext projectDir publicDir cacheDir supportDir
+  context <- makeActionContext dirs
   runShakeInContext context options $
   --
    do
@@ -185,22 +186,20 @@ replaceSuffix srcSuffix targetSuffix filename =
 -- | Calculates the target pathes from a list of source files.
 calcTargets :: String -> String -> [FilePath] -> Action [FilePath]
 calcTargets srcSuffix targetSuffix sources = do
-  projectDir <- getProjectDir
-  publicDir <- getPublicDir
+  dirs <- getProjectDirs
   return $
     map
       (replaceSuffix srcSuffix targetSuffix .
-       combine publicDir . makeRelative projectDir)
+       combine (public dirs) . makeRelative (project dirs))
       sources
 
 -- | Calculate the source file from the target path. Calls need.
 calcSource :: String -> String -> FilePath -> Action FilePath
 calcSource targetSuffix srcSuffix target = do
-  projectDir <- getProjectDir
-  publicDir <- getPublicDir
+  dirs <- getProjectDirs
   let src =
         (replaceSuffix targetSuffix srcSuffix .
-         combine projectDir . makeRelative publicDir)
+         combine (project dirs) . makeRelative (public dirs))
           target
   need [src]
   return src
diff --git a/app/examiner.hs b/app/examiner.hs
index 078eaf4..d5f54c5 100644
--- a/app/examiner.hs
+++ b/app/examiner.hs
@@ -36,13 +36,15 @@ import Text.Pandoc
 import Text.Pandoc.PDF
 import Text.Pandoc.Walk
 import Utilities
+import Project
 
 replaceSuffix srcSuffix targetSuffix filename =
   dropSuffix srcSuffix filename ++ targetSuffix
 
 main :: IO ()
 main = do
-  projectDir <- calcProjectDirectory
+  dirs <- projectDirectories
+  let projectDir = (project dirs)
   let privateDir = projectDir </> "private"
   -- Find questions
   questionSources <- glob "**/*-quest.yaml"
@@ -65,7 +67,7 @@ main = do
   -- Prepare Mustache templates
   let templates = compileTesterTemplates
   --- 
-  context <- makeActionContext projectDir privateDir "" ""
+  context <- makeActionContext (ProjectDirs projectDir privateDir "" "")
   runShakeInContext context shakeOptions $ do
     want ["catalog"]
        --
@@ -205,8 +207,8 @@ compileTemplates disposition = do
 
 compileProjectTemplate :: FilePath -> FilePath -> Action MT.Template
 compileProjectTemplate disposition name = do
-  projectDir <- getProjectDir
-  let filename = projectDir </> "exams" </> "templates" </> disposition </> name
+  dirs <- getProjectDirs
+  let filename = (project dirs) </> "exams" </> "templates" </> disposition </> name
   need [filename]
   text <- liftIO $ T.readFile filename
   let result = M.compileTemplate name (fixMustacheMarkupText text)
diff --git a/slides.cabal b/slides.cabal
index ff7e077..eac9957 100644
--- a/slides.cabal
+++ b/slides.cabal
@@ -15,7 +15,7 @@ cabal-version:       >=1.10
 
 library
   hs-source-dirs:      src
-  exposed-modules:     Watch, Test, Embed, Context, Utilities, Filter, Student, Shuffle, Project
+  exposed-modules:     Watch, Test, Embed, Context, Utilities, Filter, Student, Shuffle, Project, Common
   build-depends:       base
                      , aeson
                      , random
@@ -53,6 +53,7 @@ library
                      , vector
                      , scientific
                      , transformers
+                     , unix
   default-language:    Haskell2010
 
 executable decker
@@ -191,6 +192,7 @@ test-suite slides-test
                      , text
                      , neat-interpolation
                      , bytestring
+                     , directory
   ghc-options:         -threaded -rtsopts -with-rtsopts=-N
   default-language:    Haskell2010
 
diff --git a/src/common.hs b/src/common.hs
new file mode 100644
index 0000000..114e086
--- /dev/null
+++ b/src/common.hs
@@ -0,0 +1,31 @@
+module Common
+  ( DeckerException(..)
+  ) where
+
+import Control.Exception
+import Data.Typeable
+
+-- | Tool specific exceptions
+data DeckerException
+  = MustacheException String
+  | ResourceException String
+  | GitException String
+  | PandocException String
+  | YamlException String
+  | HttpException String
+  | RsyncUrlException
+  | DecktapeException String
+  deriving (Typeable)
+
+instance Exception DeckerException
+
+instance Show DeckerException where
+  show (MustacheException e) = e
+  show (ResourceException e) = e
+  show (GitException e) = e
+  show (HttpException e) = e
+  show (PandocException e) = e
+  show (YamlException e) = e
+  show (DecktapeException e) = "decktape.sh failed for reason: " ++ e
+  show RsyncUrlException =
+    "attributes 'destinationRsyncHost' or 'destinationRsyncPath' not defined in meta data"
diff --git a/src/context.hs b/src/context.hs
index 44f2a11..7640499 100644
--- a/src/context.hs
+++ b/src/context.hs
@@ -2,8 +2,8 @@
 
 module Context
        (ActionContext(..), makeActionContext, setActionContext, getFilesToWatch,
-        setFilesToWatch, getServerHandle, setServerHandle, getProjectDir,
-        getPublicDir, getCacheDir, getSupportDir, actionContextKey, getActionContext)
+        setFilesToWatch, getServerHandle, setServerHandle, getProjectDirs,
+        actionContextKey, getActionContext)
        where
 
 import Control.Monad ()
@@ -15,43 +15,33 @@ import Data.Typeable ()
 import qualified Data.HashMap.Lazy as HashMap
 import System.Process
 import Text.Printf
+import Project
 
 data ActionContext =
   ActionContext {ctxFilesToWatch :: IORef [FilePath]
                 ,ctxServerHandle :: IORef (Maybe ProcessHandle)
-                ,ctxProjectDir :: FilePath
-                ,ctxPublicDir :: FilePath
-                ,ctxCacheDir :: FilePath
-                ,ctxSupportDir :: FilePath}
-  deriving (Typeable)
+                ,ctxDirs :: ProjectDirs}
+  deriving (Typeable, Show)
 
-instance Show ActionContext where
-  show ctx =
-    printf "ActionContext {ctxProjectDir = '%s', ctxPublicDir = '%s', ctxCacheDir = '%s', ctxSupportDir = '%s'}"
-           (ctxProjectDir ctx)
-           (ctxPublicDir ctx)
-           (ctxCacheDir ctx)
-           (ctxSupportDir ctx)
+instance Show (IORef a) where
+  show _ = "IORef"
 
 defaultActionContext :: IO ActionContext
 defaultActionContext = do
   files <- newIORef []
   server <- newIORef Nothing
-  return $ ActionContext files server "" "" "" ""
+  return $ ActionContext files server (ProjectDirs "" "" "" "")
 
 actionContextKey :: IO TypeRep
 actionContextKey = do
   ctx <- liftIO $ defaultActionContext
   return $ typeOf ctx
 
-makeActionContext :: FilePath -> FilePath -> FilePath -> FilePath-> IO ActionContext
-makeActionContext projectDir publicDir cacheDir supportDir =
+makeActionContext :: ProjectDirs -> IO ActionContext
+makeActionContext dirs =
   do ctx <- defaultActionContext
      return $
-       ctx {ctxProjectDir = projectDir
-           ,ctxPublicDir = publicDir
-           ,ctxCacheDir = cacheDir
-           ,ctxSupportDir = supportDir}
+       ctx { ctxDirs = dirs }
 
 setActionContext :: ActionContext -> ShakeOptions -> IO ShakeOptions
 setActionContext ctx options =
@@ -91,22 +81,8 @@ setServerHandle handle = do
   ctx <- getActionContext
   liftIO $ writeIORef (ctxServerHandle ctx) handle
 
-getProjectDir :: Action FilePath
-getProjectDir =
+getProjectDirs :: Action ProjectDirs
+getProjectDirs =
   do ctx <- getActionContext
-     return $ ctxProjectDir ctx
+     return $ ctxDirs ctx
 
-getPublicDir :: Action FilePath
-getPublicDir =
-  do ctx <- getActionContext
-     return $ ctxPublicDir ctx
-
-getCacheDir :: Action FilePath
-getCacheDir =
-  do ctx <- getActionContext
-     return $ ctxCacheDir ctx
-
-getSupportDir :: Action FilePath
-getSupportDir =
-  do ctx <- getActionContext
-     return $ ctxSupportDir ctx
diff --git a/src/project.hs b/src/project.hs
index a787cd4..7e73368 100644
--- a/src/project.hs
+++ b/src/project.hs
@@ -1,32 +1,73 @@
 module Project
   ( provisionResource
+  , copyResource
+  , linkResource
+  , refResource
   , removeCommonPrefix
   , isPrefix
+  , makeRelativeTo
+  , findProjectDirectory
+  , projectDirectories
+  , resolve
+  , Resource(..)
+  , Provisioning(..)
+  , ProjectDirs(..)
   ) where
 
+import Common
 import Control.Monad
+import Control.Exception
 import Control.Monad.Trans.Class
 import Control.Monad.Trans.Maybe
 import Data.Maybe
+import Debug.Trace
+import Extra
 import Network.URI
 import System.Directory
 import System.FilePath
+import System.Posix.Files
 
 data Provisioning
   = Copy -- Copy to public and relative link
   | SymbolicLink -- Link to public and relative link
   | Reference -- Absolute local link
+  deriving (Eq, Show)
 
-data ResolvedUrl = ResolvedUrl
+data Resource = Resource
   { sourceFile :: FilePath -- Absolute Path to source file
   , publicFile :: FilePath -- Absolute path to file in public folder
   , publicUrl :: FilePath -- Relative URL to served file from base
-  }
+  } deriving (Eq, Show)
+
+copyResource :: Resource -> IO (FilePath)
+copyResource resource
+  -- copyFileIfNewer (sourceFile resource) (publicFile resource)
+ = do
+  createDirectoryIfMissing True (takeDirectory (publicFile resource))
+  copyFile (sourceFile resource) (publicFile resource)
+  return (publicUrl resource)
+
+linkResource :: Resource -> IO (FilePath)
+linkResource resource = do
+  whenM (doesFileExist (publicFile resource)) (removeFile (publicFile resource))
+  createSymbolicLink (sourceFile resource) (publicFile resource)
+  return (publicUrl resource)
+
+refResource :: Resource -> IO (FilePath)
+refResource resource = do
+  return $ show $ URI "file" Nothing (sourceFile resource) "" ""
+
+data ProjectDirs = ProjectDirs
+  { project :: FilePath
+  , public :: FilePath
+  , cache :: FilePath
+  , support :: FilePath
+  } deriving (Eq, Show)
 
 -- Find the project directory.  
 -- The project directory is the first upwards directory that contains a .git directory entry.
-calcProjectDirectory :: IO FilePath
-calcProjectDirectory = do
+findProjectDirectory :: IO FilePath
+findProjectDirectory = do
   cwd <- getCurrentDirectory
   searchGitRoot cwd
   where
@@ -40,25 +81,38 @@ calcProjectDirectory = do
             then makeAbsolute path
             else searchGitRoot $ takeDirectory path
 
+-- Calculate important absolute project directory pathes
+projectDirectories :: IO ProjectDirs
+projectDirectories = do
+  projectDir <- findProjectDirectory
+  let publicDir = projectDir </> "public"
+  let cacheDir = publicDir </> "cache"
+  let supportDir = publicDir </> "support"
+  return (ProjectDirs projectDir publicDir cacheDir supportDir)
+
 -- Resolves a file path to a concrete verified file system path, or
 -- returns Nothing if no file can be found.
-resolve :: FilePath -> FilePath -> FilePath -> IO (Maybe FilePath)
-resolve project base path = do
-  let pathes =
+resolve :: ProjectDirs -> FilePath -> FilePath -> IO (Maybe Resource)
+resolve dirs base path = do
+  let candidates =
         if isAbsolute path
-          then [project </> makeRelative "/" path, path]
-          else [base </> path, project </> path]
-  listToMaybe <$> filterM doesFileExist pathes
+          then [(project dirs) </> makeRelative "/" path, path]
+          else [base </> path, (project dirs) </> path]
+  (listToMaybe . map resolveResource) <$> filterM doesFileExist candidates
+  where
+    resolveResource absolute =
+      let relative = makeRelativeTo (project dirs) absolute
+      in Resource
+         { sourceFile = absolute
+         , publicFile = (public dirs) </> makeRelativeTo (project dirs) absolute
+         , publicUrl = makeRelativeTo base absolute
+         }
 
-resolveUrl :: FilePath -> FilePath -> FilePath -> IO (Maybe ResolvedUrl)
-resolveUrl project base url = do
+resolveUrl :: ProjectDirs -> FilePath -> FilePath -> IO (Maybe Resource)
+resolveUrl dirs base url = do
   case parseURI url >>= fileOrRelativeUrl of
     Nothing -> return Nothing
-    Just path -> do
-      file <- resolve project base path
-      case file of
-        Nothing -> return Nothing
-        Just file -> return $ Just $ ResolvedUrl file "" ""
+    Just path -> resolve dirs base path
 
 fileOrRelativeUrl :: URI -> Maybe FilePath
 fileOrRelativeUrl (URI "file:" Nothing path _ _) = Just path
@@ -67,31 +121,29 @@ fileOrRelativeUrl _ = Nothing
 
 -- | Determines if a URL can be resolved to a local file. Absolute file URLs 
 -- are resolved against and copied or linked from 
---    - the project root 
---    - the local filesystem root 
+--    1. the project root 
+--    2. the local filesystem root 
 -- Relative file URLs are resolved against and copied or linked from 
---    - the directory path of the referencing file 
---    - the project root
+--    1. the directory path of the referencing file 
+--    2. the project root
 -- Copy and link operations target the public directory in the project root
 -- and recreate the source directory structure.
 provisionResource ::
-     Provisioning -> FilePath -> FilePath -> FilePath -> IO FilePath
-provisionResource provisioning project base path = do
-  let pathes =
-        if isAbsolute path
-          then [project </> makeRelative "/" path, path]
-          else [base </> path, project </> path]
-  resource <- listToMaybe <$> filterM doesFileExist pathes
-  case resource of
-    Nothing -> return path
-    Just resolved -> do
+     Provisioning -> ProjectDirs -> FilePath -> FilePath -> IO FilePath
+provisionResource provisioning dirs base path = do
+  resolved <- resolve dirs base path
+  case resolved of
+    Nothing -> throw $ ResourceException $ "Cannot find local resource: " ++ path
+    Just resource -> do
       case provisioning of
-        Copy -> return resolved
-        SymbolicLink -> return resolved
-        Reference -> return resolved
+        Copy -> copyResource resource
+        SymbolicLink -> linkResource resource
+        Reference -> refResource resource
 
-copyFileChanged :: FilePath -> FilePath -> IO ()
-copyFileChanged src dst = do
+-- | Copies the src to dst if src is newer or dst does not exist. 
+-- Creates missing directories while doing so.
+copyFileIfNewer :: FilePath -> FilePath -> IO ()
+copyFileIfNewer src dst = do
   newer <- fileIsNewer src dst
   if newer
     then do
@@ -100,13 +152,16 @@ copyFileChanged src dst = do
     else return ()
 
 fileIsNewer a b = do
-  exists <- doesFileExist b
-  if exists
-    then do
-      at <- getModificationTime a
-      bt <- getModificationTime b
-      return (at > bt)
-    else return True
+  aexists <- doesFileExist a
+  bexists <- doesFileExist b
+  if bexists
+    then if aexists
+           then do
+             at <- getModificationTime a
+             bt <- getModificationTime b
+             return ((traceShowId at) > (traceShowId bt))
+           else return True
+    else return False
 
 -- | Express the second path argument as relative to the first. 
 -- Both arguments are expected to be absolute pathes. 
diff --git a/src/utilities.hs b/src/utilities.hs
index b03a26b..e02144d 100644
--- a/src/utilities.hs
+++ b/src/utilities.hs
@@ -85,6 +85,8 @@ import Text.Pandoc
 import Text.Pandoc.PDF
 import Text.Pandoc.Walk
 import Watch
+import Project
+import Common
 
 -- Find the project directory and change current directory to there. 
 -- The project directory is the first upwards directory that contains a .git directory entry.
@@ -107,16 +109,16 @@ calcProjectDirectory = do
 -- Returns absolute pathes. 
 globA :: FilePattern -> Action [FilePath]
 globA pat = do
-  projectDir <- getProjectDir
-  liftIO $ globDir1 (compile pat) projectDir
+  dirs <- getProjectDirs
+  liftIO $ globDir1 (compile pat) (project dirs)
 
 -- | Globs for files under the project dir in the Action monad. 
 -- Returns pathes relative to the project directory. 
 globRelA :: FilePattern -> Action [FilePath]
 globRelA pat = do
-  projectDir <- getProjectDir
+  dirs <- getProjectDirs
   files <- globA pat
-  return $ map (makeRelative projectDir) files
+  return $ map (makeRelative (project dirs)) files
 
 -- Utility functions for shake based apps
 spawn :: String -> Action ProcessHandle
@@ -235,13 +237,13 @@ writeIndex out baseUrl decks handouts pages = do
   let decksLinks = map (makeRelative baseUrl) decks
   let handoutsLinks = map (makeRelative baseUrl) handouts
   let pagesLinks = map (makeRelative baseUrl) pages
-  projectDir <- getProjectDir
+  dirs <- getProjectDirs
   liftIO $
     writeFile out $
     unlines
       [ "---"
       , "title: Generated Index"
-      , "subtitle: " ++ projectDir
+      , "subtitle: " ++ (project dirs)
       , "---"
       , "# Slide decks"
       , unlines $ map makeLink $ sort decksLinks
@@ -263,8 +265,8 @@ readMetaDataForDir :: FilePath -> Action Y.Value
 readMetaDataForDir dir = walkUpTo dir
   where
     walkUpTo dir = do
-      projectDir <- getProjectDir
-      if equalFilePath projectDir dir
+      dirs <- getProjectDirs
+      if equalFilePath (project dirs) dir
         then collectMeta dir
         else do
           fromAbove <- walkUpTo (takeDirectory dir)
@@ -308,11 +310,10 @@ substituteMetaData text metaData = do
 
 getRelativeSupportDir :: FilePath -> Action FilePath
 getRelativeSupportDir from = do
-  supportDir <- getSupportDir
-  publicDir <- getPublicDir
+  dirs <- getProjectDirs
   return $
-    invertPath (makeRelative publicDir (takeDirectory from)) </>
-    makeRelative publicDir supportDir
+    invertPath (makeRelative (public dirs) (takeDirectory from)) </>
+    makeRelative (public dirs) (support dirs)
 
 invertPath :: FilePath -> FilePath
 invertPath fp = joinPath $ map (const "..") $ filter ("." /=) $ splitPath fp
@@ -356,16 +357,16 @@ getPandocWriter format =
 -- template variables and calls need.
 readAndPreprocessMarkdown :: FilePath -> Action Pandoc
 readAndPreprocessMarkdown markdownFile = do
-  projectDir <- getProjectDir
+  dirs <- getProjectDirs
   let baseDir = takeDirectory markdownFile
-  readMetaMarkdown markdownFile >>= processIncludes projectDir baseDir
+  readMetaMarkdown markdownFile >>= processIncludes (project dirs) baseDir
   -- Disable automatic caching of remomte images for a while
   -- >>= populateCache
 
 populateCache :: Pandoc -> Action Pandoc
 populateCache pandoc = do
-  cacheDir <- getCacheDir
-  liftIO $ walkM (cacheRemoteImages cacheDir) pandoc
+  dirs <- getProjectDirs
+  liftIO $ walkM (cacheRemoteImages (cache dirs)) pandoc
 
 -- | Write a markdown file to a HTML file using the page template.
 markdownToHtmlPage :: FilePath -> FilePath -> Action ()
@@ -474,8 +475,8 @@ readMetaMarkdown markdownFile = do
   let (MetaMap m) = combinedMeta
   let pandoc = Pandoc (Meta m) blocks
   -- adjust image urls
-  projectDir <- getProjectDir
-  return $ walk (adjustImageUrls projectDir (takeDirectory markdownFile)) pandoc
+  dirs <- getProjectDirs
+  return $ walk (adjustImageUrls (project dirs) (takeDirectory markdownFile)) pandoc
   where
     readMarkdownOrThrow opts string =
       case readMarkdown opts string of
@@ -637,28 +638,28 @@ hashURI uri = show (md5 $ L8.pack uri) SF.<.> SF.takeExtension uri
 processPandocPage :: String -> Pandoc -> Action Pandoc
 processPandocPage format pandoc = do
   let f = Just (Format format)
-  cacheDir <- getCacheDir
+  dirs <- getProjectDirs
   processed <-
-    liftIO $ processCites' pandoc >>= walkM (useCachedImages cacheDir)
-  --  processed <- liftIO $ walkM (useCachedImages cacheDir) pandoc
+    liftIO $ processCites' pandoc >>= walkM (useCachedImages (cache dirs))
+  --  processed <- liftIO $ walkM (useCachedImages (cache dirs)) pandoc
   return $ expandMacros f processed
 
 processPandocDeck :: String -> Pandoc -> Action Pandoc
 processPandocDeck format pandoc = do
   let f = Just (Format format)
-  cacheDir <- getCacheDir
+  dirs <- getProjectDirs
   processed <-
-    liftIO $ processCites' pandoc >>= walkM (useCachedImages cacheDir)
-  -- processed <- liftIO $ walkM (useCachedImages cacheDir) pandoc
+    liftIO $ processCites' pandoc >>= walkM (useCachedImages (cache dirs))
+  -- processed <- liftIO $ walkM (useCachedImages cacheD(cache dirs)ir) pandoc
   return $ (makeSlides f . expandMacros f) processed
 
 processPandocHandout :: String -> Pandoc -> Action Pandoc
 processPandocHandout format pandoc = do
   let f = Just (Format format)
-  cacheDir <- getCacheDir
+  dirs <- getProjectDirs
   processed <-
-    liftIO $ processCites' pandoc >>= walkM (useCachedImages cacheDir)
-  -- processed <- liftIO $ walkM (useCachedImages cacheDir) pandoc
+    liftIO $ processCites' pandoc >>= walkM (useCachedImages (cache dirs))
+  -- processed <- liftIO $ walkM (useCachedImages (cache dirs)) pandoc
   return $ (expandMacros f . filterNotes f) processed
 
 type StringWriter = WriterOptions -> Pandoc -> String
@@ -672,10 +673,9 @@ writePandocString format options out pandoc = do
 
 copyImages :: FilePath -> Pandoc -> Action Pandoc
 copyImages baseDir pandoc = do
-  projectDir <- getProjectDir
-  publicDir <- getPublicDir
-  walkM (copyAndLinkInline projectDir publicDir) pandoc >>=
-    walkM (copyAndLinkBlock projectDir publicDir)
+  dirs <- getProjectDirs
+  walkM (copyAndLinkInline (project dirs) (public dirs)) pandoc >>=
+    walkM (copyAndLinkBlock (project dirs) (public dirs))
   where
     copyAndLinkInline project public (Image attr inlines (url, title)) = do
       relUrl <- copyAndLinkFile project public baseDir url
@@ -714,17 +714,17 @@ copyAndLinkFile project public base url = do
 
 -- | Express the second path argument as relative to the first. 
 -- Both arguments are expected to be absolute pathes. 
-makeRelativeTo :: FilePath -> FilePath -> FilePath
-makeRelativeTo dir file =
-  let (d, f) = removeCommonPrefix (splitDirectories dir) (splitDirectories file)
-  in normalise $ invertPath (joinPath d) </> joinPath f
-
-removeCommonPrefix :: [FilePath] -> [FilePath] -> ([FilePath], [FilePath])
-removeCommonPrefix al@(a:as) bl@(b:bs)
-  | a == b = removeCommonPrefix as bs
-  | otherwise = (al, bl)
-removeCommonPrefix a [] = (a, [])
-removeCommonPrefix [] b = ([], b)
+-- makeRelativeTo :: FilePath -> FilePath -> FilePath
+-- makeRelativeTo dir file =
+--   let (d, f) = removeCommonPrefix (splitDirectories dir) (splitDirectories file)
+--   in normalise $ invertPath (joinPath d) </> joinPath f
+
+-- removeCommonPrefix :: [FilePath] -> [FilePath] -> ([FilePath], [FilePath])
+-- removeCommonPrefix al@(a:as) bl@(b:bs)
+--  | a == b = removeCommonPrefix as bs
+--  | otherwise = (al, bl)
+-- removeCommonPrefix a [] = (a, [])
+-- removeCommonPrefix [] b = ([], b)
 
 writeExampleProject :: Action ()
 writeExampleProject = mapM_ writeOne deckerExampleDir
@@ -763,25 +763,3 @@ metaValueAsString key meta =
     lookup' (k:ks) (Just obj@(Y.Object _)) = lookup' ks (lookupValue k obj)
     lookup' _ _ = Nothing
 
--- | Tool specific exceptions
-data DeckerException
-  = MustacheException String
-  | GitException String
-  | PandocException String
-  | YamlException String
-  | HttpException String
-  | RsyncUrlException
-  | DecktapeException String
-  deriving (Typeable)
-
-instance Exception DeckerException
-
-instance Show DeckerException where
-  show (MustacheException e) = e
-  show (GitException e) = e
-  show (HttpException e) = e
-  show (PandocException e) = e
-  show (YamlException e) = e
-  show (DecktapeException e) = "decktape.sh failed for reason: " ++ e
-  show RsyncUrlException =
-    "attributes 'destinationRsyncHost' or 'destinationRsyncPath' not defined in meta data"
diff --git a/test/Spec.hs b/test/Spec.hs
index 187896e..18082a8 100644
--- a/test/Spec.hs
+++ b/test/Spec.hs
@@ -11,7 +11,9 @@ import Data.Text.Encoding
 import qualified Data.Yaml as Y
 import NeatInterpolation
 import Project as P
+import Project
 import Student
+import System.Directory
 import System.FilePath
 import System.FilePath.Glob
 import System.FilePath.Posix
@@ -19,15 +21,11 @@ import Text.Pandoc
 import Utilities
 
 main = do
-  projectDir <- calcProjectDirectory
+  dirs <- projectDirectories
   --
-  let publicDir = projectDir </> "public"
-  let cacheDir = publicDir </> "cache"
-  let supportDir = publicDir </> "support"
-  --
-  metaFiles <- globDir1 (compile "**/*-meta.yaml") projectDir
+  metaFiles <- globDir1 (compile "**/*-meta.yaml") (project dirs)
   print metaFiles
-  genStudentData projectDir
+  genStudentData (project dirs)
   --
   hspec $
   --
@@ -40,13 +38,14 @@ main = do
     describe "adjustLocalUrl" $
       it
         "adjusts URL to be relative to the project root or the provided base directory" $ do
-        adjustLocalUrl projectDir "base" "http://heise.de" `shouldBe`
+        adjustLocalUrl (project dirs) "base" "http://heise.de" `shouldBe`
           "http://heise.de"
                  --
-        adjustLocalUrl projectDir "base" "/some/where" `shouldBe` projectDir </>
+        adjustLocalUrl (project dirs) "base" "/some/where" `shouldBe`
+          (project dirs) </>
           "some/where"
                  --
-        adjustLocalUrl projectDir "base" "some/where" `shouldBe`
+        adjustLocalUrl (project dirs) "base" "some/where" `shouldBe`
           "base/some/where"
        --
     describe "makeRelativeTo" $
@@ -65,33 +64,92 @@ main = do
     describe "removeCommonPrefix" $
       it "Removes the common prefix from two pathes." $ do
         P.removeCommonPrefix ("", "") `shouldBe` ("", "")
-        P.removeCommonPrefix ("fasel/bla", "fasel/bla/lall") `shouldBe` ("", "lall")
-        P.removeCommonPrefix ("lurgel/hopp", "fasel/bla/lall") `shouldBe` ("lurgel/hopp", "fasel/bla/lall")
-        P.removeCommonPrefix ("/lurgel/hopp", "fasel/bla/lall") `shouldBe` ("/lurgel/hopp", "fasel/bla/lall")
-        P.removeCommonPrefix ("/lurgel/hopp", "/fasel/bla/lall") `shouldBe` ("lurgel/hopp", "fasel/bla/lall")
+        P.removeCommonPrefix ("fasel/bla", "fasel/bla/lall") `shouldBe`
+          ("", "lall")
+        P.removeCommonPrefix ("lurgel/hopp", "fasel/bla/lall") `shouldBe`
+          ("lurgel/hopp", "fasel/bla/lall")
+        P.removeCommonPrefix ("/lurgel/hopp", "fasel/bla/lall") `shouldBe`
+          ("/lurgel/hopp", "fasel/bla/lall")
+        P.removeCommonPrefix ("/lurgel/hopp", "/fasel/bla/lall") `shouldBe`
+          ("lurgel/hopp", "fasel/bla/lall")
+    --
+    describe "resolve" $
+      it "Resolves a file path to a concrete verified file system path." $ do
+        resolve dirs ((project dirs) </> "resource/example") "img/06-metal.png" `shouldReturn`
+          Just
+            (Resource
+               ((project dirs) </> "resource/example/img/06-metal.png")
+               ((public dirs) </> "resource/example/img/06-metal.png")
+               "img/06-metal.png")
+        resolve dirs ((project dirs) </> "resource/example") "img/06-metal.png" `shouldReturn`
+          Just
+            (Resource
+               ((project dirs) </> "resource/example/img/06-metal.png")
+               ((public dirs) </> "resource/example/img/06-metal.png")
+               "img/06-metal.png")
+    --
+    describe "copyResource" $
+      it
+        "Copies an existing resource to the public dir and returns the public URL." $ do
+        doesFileExist ((project dirs) </> "resource/example/img/06-metal.png") `shouldReturn`
+          True
+        copyResource
+          (Resource
+             ((project dirs) </> "resource/example/img/06-metal.png")
+             ((public dirs) </> "resource/example/img/06-metal.png")
+             "img/06-metal.png") `shouldReturn`
+          "img/06-metal.png"
+        doesFileExist ((public dirs) </> "resource/example/img/06-metal.png") `shouldReturn`
+          True
+    --
+    describe "linkResource" $
+      it
+        "Copies an existing resource to the public dir and returns the public URL." $ do
+        doesFileExist ((project dirs) </> "resource/example/img/06-metal.png") `shouldReturn`
+          True
+        linkResource
+          (Resource
+             ((project dirs) </> "resource/example/img/06-metal.png")
+             ((public dirs) </> "resource/example/img/06-metal.png")
+             "img/06-metal.png") `shouldReturn`
+          "img/06-metal.png"
+        pathIsSymbolicLink
+          ((public dirs) </> "resource/example/img/06-metal.png") `shouldReturn`
+          True
+    -- 
+    describe "provisionResource" $
+      it "Resolves a file path to a verified file system path." $ do
+        provisionResource
+          SymbolicLink
+          dirs
+          ((project dirs) </> "resource/example")
+          "img/06-metal.png" `shouldReturn`
+          "img/06-metal.png"
+        doesFileExist ((public dirs) </> "resource/example/img/06-metal.png") `shouldReturn`
+          True
     --
     describe "cacheRemoteFile" $
       it
         "Stores the data behind a URL locally, if possible. Return the local path to the cached file." $ do
         cacheRemoteFile
-          cacheDir
+          (cache dirs)
           "https://tramberend.beuth-hochschule.de/img/htr-beuth.jpg" `shouldReturn`
-          cacheDir </>
+          (cache dirs) </>
           "bc137c359488beadbb61589f7fe9e208.jpg"
         cacheRemoteFile
-          cacheDir
+          (cache dirs)
           "ftp://tramberend.beuth-hochschule.de/img/htr-beuth.jpg" `shouldReturn`
           "ftp://tramberend.beuth-hochschule.de/img/htr-beuth.jpg"
-        cacheRemoteFile cacheDir "/img/htr-beuth.jpg" `shouldReturn`
+        cacheRemoteFile (cache dirs) "/img/htr-beuth.jpg" `shouldReturn`
           "/img/htr-beuth.jpg"
-        cacheRemoteFile cacheDir "img/htr-beuth.jpg" `shouldReturn`
+        cacheRemoteFile (cache dirs) "img/htr-beuth.jpg" `shouldReturn`
           "img/htr-beuth.jpg"
        --
     describe "cacheRemoteImages" $
       it
         "Replaces all remote images in the pandoc document with locally caches copies." $
       cacheRemoteImages
-        cacheDir
+        (cache dirs)
         (Pandoc
            nullMeta
            [ Para
@@ -108,13 +166,13 @@ main = do
             [ Image
                 nullAttr
                 []
-                (cacheDir </> "bc137c359488beadbb61589f7fe9e208.jpg", "")
+                ((cache dirs) </> "bc137c359488beadbb61589f7fe9e208.jpg", "")
             ]
         ]
        --
     describe "parseStudentData" $
       it "Parses student data in YAML format into a nifty data structure." $
-      parseStudentData projectDir `shouldReturn` Just realData
+      parseStudentData (project dirs) `shouldReturn` Just realData
 
 mockData :: Students
 mockData =
-- 
GitLab