structure Reruncheck :> sig
              datatype file_kind = INPUT | OUTPUT | AUXILIARY
              type file_info = { path : string
                               , abspath : string
                               , kind : file_kind
                               }
              type recorded
              val parseRecorderFile : { file : string, options : AppOptions.options } -> recorded
              val parseRecorderFileContinued : { file : string, options : AppOptions.options, previousResult : recorded } -> recorded
              val getFileInfo : recorded -> file_info list * file_info StringMap.map
              type aux_status = { mtime : Time.time option
                                , size : Position.int option
                                , md5sum : MD5.hash option
                                }
              val collectFileInfo : file_info list * aux_status StringMap.map -> aux_status StringMap.map
              val compareFileInfo : file_info list * aux_status StringMap.map -> bool * aux_status StringMap.map
              val compareFileTime : { srcAbs : string, dst : string, auxstatus : aux_status StringMap.map } -> bool
          end = struct
datatype file_kind = INPUT | OUTPUT | AUXILIARY
type file_info = { path : string
                 , abspath : string
                 , kind : file_kind
                 }
type recorded = file_info ref list * file_info ref StringMap.map
fun getFileInfo ((fileInfo, fileMap) : recorded) = (List.foldl (fn (x, xs) => !x :: xs) [] fileInfo, StringMap.map ! fileMap)
fun parseRecorderFileContinued { file, options : AppOptions.options, previousResult = (fileList, fileMap) }
    = let val ins = TextIO.openIn file
          fun go (fileList, fileMap)
              = case TextIO.inputLine ins of
                    NONE => (TextIO.closeIn ins; (fileList, fileMap))
                  | SOME line =>
                    let val (t, rest) = Substring.splitl Char.isAlphaNum (Substring.full line)
                        val path = Substring.string (Substring.dropl Char.isSpace (Substring.dropr Char.isSpace rest))
                    in case Substring.string t of
                           "PWD" => go (fileList, fileMap) (* ignore *)
                         | "INPUT" =>
                           let val abspath = PathUtil.abspath { path = path, cwd = NONE }
                           in case StringMap.find (fileMap, abspath) of
                                  SOME (r as ref { path = path', abspath = abspath', kind }) =>
                                  ( r := { path = if String.size path < String.size path' then
                                                      path
                                                  else
                                                      path'
                                         , abspath = abspath'
                                         , kind = if kind = OUTPUT then
                                                      (* The files listed in both INPUT and OUTPUT are considered to be auxiliary files. *)
                                                      AUXILIARY
                                                  else
                                                      kind
                                         }
                                  ; go (fileList, fileMap)
                                  )
                                | NONE =>
                                  if FSUtil.isFile path then
                                      let val r = ref { path = path
                                                      , abspath = abspath
                                                      , kind = if PathUtil.ext path = "bbl" then
                                                                   AUXILIARY
                                                               else
                                                                   INPUT
                                                      }
                                      in go (r :: fileList, StringMap.insert (fileMap, abspath, r))
                                      end
                                  else
                                      (* Maybe a command execution *)
                                      go (fileList, fileMap)
                           end
                         | "OUTPUT" =>
                           let val abspath = PathUtil.abspath { path = path, cwd = NONE }
                           in case StringMap.find (fileMap, abspath) of
                                  SOME (r as ref { path = path', abspath = abspath', kind }) =>
                                  ( r := { path = if String.size path < String.size path' then
                                                      path
                                                  else
                                                      path'
                                         , abspath = abspath'
                                         , kind = if kind = INPUT then
                                                      (* The files listed in both INPUT and OUTPUT are considered to be auxiliary files. *)
                                                      AUXILIARY
                                                  else
                                                      kind
                                         }
                                  ; go (fileList, fileMap)
                                  )
                                | NONE =>
                                  let val ext = PathUtil.ext path
                                      val r = ref { path = path
                                                  , abspath = abspath
                                                  , kind = if ext = "out" orelse (#makeindex options <> NONE andalso ext = "idx") orelse ext = "bcf" orelse ext = "glo" then
                                                               (* .out: hyperref bookmarks file
                                                                * .idx: input for makeindex
                                                                * .bcf: biber
                                                                * .glo: makeglossaries
                                                                *)
                                                               AUXILIARY
                                                           else
                                                               OUTPUT
                                                  }
                                  in go (r :: fileList, StringMap.insert (fileMap, abspath, r))
                                  end
                           end
                         | t => ( Message.warn ("Unrecognized line in recorder file '" ^ file ^ "': " ^ t)
                                ; go (fileList, fileMap)
                                )
                    end
      in go (fileList, fileMap)
      end
fun parseRecorderFile { file, options } = parseRecorderFileContinued { file = file, options = options, previousResult = ([], StringMap.empty) }

type aux_status = { mtime : Time.time option
                  , size : Position.int option
                  , md5sum : MD5.hash option
                  }

fun md5sumOfFile (path : string) : MD5.hash
    = let val ins = BinIO.openIn path
          val data = BinIO.inputAll ins before BinIO.closeIn ins
      in MD5.compute data
      end

fun collectFileInfo (fileList : file_info list, auxstatus : aux_status StringMap.map) : aux_status StringMap.map
    = let fun go ({ abspath, kind, ... } : file_info, auxstatus) : aux_status ref StringMap.map
              = if FSUtil.isFile abspath then
                    let val (status, auxstatus) = case StringMap.find (auxstatus, abspath) of
                                                      NONE => let val s = ref { mtime = NONE, size = NONE, md5sum = NONE }
                                                              in (s, StringMap.insert (auxstatus, abspath, s))
                                                              end
                                                    | SOME status => (status, auxstatus)
                    in case kind of
                           INPUT => (case status of
                                         ref (s as { mtime = NONE, ... }) => status := { s where mtime = SOME (OS.FileSys.modTime abspath) }
                                       | _ => ()
                                    )
                         | AUXILIARY => let val s = !status
                                            val s = case s of
                                                        { mtime = NONE, ... } => { s where mtime = SOME (OS.FileSys.modTime abspath) }
                                                      | _ => s
                                            val s = case s of
                                                        { size = NONE, ... } => { s where size = SOME (OS.FileSys.fileSize abspath) }
                                                      | _ => s
                                            val s = case s of
                                                        { md5sum = NONE, ... } => { s where md5sum = SOME (md5sumOfFile abspath) }
                                                      | _ => s
                                        in status := s
                                        end
                         | OUTPUT => ()
                     ; auxstatus
                    end
                else
                    auxstatus
      in StringMap.map ! (List.foldl go (StringMap.map ref auxstatus) fileList)
      end

fun compareFileInfo (fileList : file_info list, auxstatus : aux_status StringMap.map) : bool * aux_status StringMap.map
    = let fun go ([], newauxstatus) = (false, newauxstatus)
            | go ({ path = shortPath, abspath, kind } :: fileList, newauxstatus)
              = if FSUtil.isFile abspath then
                    let val (shouldRerun, newauxstatus)
                            = case kind of
                                  INPUT => (* Input file: User might have modified while running TeX. *)
                                  let val mtime = OS.FileSys.modTime abspath
                                  in case StringMap.find (auxstatus, abspath) of
                                         SOME { mtime = SOME mtime', ... } =>
                                         if Time.< (mtime', mtime) then
                                             (* Input file was updated during execution *)
                                             ( Message.info ("Input file '" ^ shortPath ^ "' was modified (by user, or some external commands).")
                                             ; (true, StringMap.insert (newauxstatus, abspath, ref { mtime = SOME mtime, size = NONE, md5sum = NONE }))
                                             )
                                         else
                                             (false, newauxstatus)
                                       | _ => (* New input file *)
                                         (false, newauxstatus)
                                  end
                                | AUXILIARY => (* Auxiliary file: Compare file contents. *)
                                  (case StringMap.find (auxstatus, abspath) of
                                       SOME s =>
                                       let val size = OS.FileSys.fileSize abspath
                                           val sizeIsDifferent = case #size s of
                                                                     SOME z => z <> size
                                                                   | NONE => true
                                           val (modifiedBecause, newauxstatus)
                                               = if sizeIsDifferent then
                                                     let val previousSize = case #size s of
                                                                                SOME z => Position.toString z
                                                                              | NONE => "(N/A)"
                                                     in (SOME ("size: " ^ previousSize ^ " -> " ^ Position.toString size), StringMap.insert (newauxstatus, abspath, ref { mtime = NONE, size = SOME size, md5sum = NONE }))
                                                     end
                                                 else
                                                     let val md5sum = md5sumOfFile abspath
                                                         val md5sumIsDifferent = case #md5sum s of
                                                                                     SOME h => h <> md5sum
                                                                                   | NONE => true
                                                     in if md5sumIsDifferent then
                                                            let val previousMd5sum = case #md5sum s of
                                                                                         SOME h => MD5.hashToLowerHexString h
                                                                                       | NONE => "(N/A)"
                                                            in (SOME ("md5: " ^ previousMd5sum ^ " -> " ^ MD5.hashToLowerHexString md5sum), StringMap.insert (newauxstatus, abspath, ref { mtime = NONE, size = SOME size, md5sum = SOME md5sum }))
                                                            end
                                                        else
                                                            (NONE, newauxstatus)
                                                     end
                                       in case modifiedBecause of
                                              SOME reason => ( Message.info ("File '" ^ shortPath ^ "' was modified (" ^ reason ^ ").")
                                                             ; (true, newauxstatus)
                                                             )
                                            | NONE => ( if Message.getVerbosity () >= 1 then
                                                            Message.info ("File '" ^ shortPath ^ "' unmodified (size and md5sum).")
                                                        else
                                                            ()
                                                      ; (false, newauxstatus)
                                                      )
                                       end
                                     | NONE => (* New file *)
                                       let val (shouldRerun, newauxstatus)
                                               = if String.isSuffix ".aux" abspath then
                                                     let val size = OS.FileSys.fileSize abspath
                                                     in if size = 8 then
                                                            let val ins = BinIO.openIn abspath
                                                                val contents = BinIO.inputAll ins before BinIO.closeIn ins
                                                                val isTrivial = Byte.bytesToString contents = "\\relax \n"
                                                                val newauxstatus = StringMap.insert (newauxstatus, abspath, ref { mtime = NONE, size = SOME size, md5sum = SOME (MD5.compute contents) })
                                                            in (not isTrivial, newauxstatus)
                                                            end
                                                        else
                                                            let val newauxstatus = StringMap.insert (newauxstatus, abspath, ref { mtime = NONE, size = SOME size, md5sum = NONE })
                                                            in (true, newauxstatus)
                                                            end
                                                     end
                                                 else
                                                     (true, newauxstatus)
                                       in if shouldRerun then
                                              Message.info ("New auxiliary file '" ^ shortPath ^ "'.")
                                          else if Message.getVerbosity () >= 1 then
                                              Message.info ("Ignoring almost-empty auxiliary file '" ^ shortPath ^ "'.")
                                          else
                                              ()
                                        ; (shouldRerun, newauxstatus)
                                       end
                                  )
                                | OUTPUT => (false, newauxstatus)
                    in if shouldRerun then
                           (true, newauxstatus)
                       else
                           go (fileList, newauxstatus)
                    end
                else
                    go (fileList, newauxstatus)
          val (shouldRerun, auxstatus) = go (fileList, StringMap.empty)
      in (shouldRerun, StringMap.map ! auxstatus)
      end

(* true if src is newer than dst *)
fun compareFileTime { srcAbs, dst, auxstatus : aux_status StringMap.map }
    = if not (FSUtil.isFile dst) then
          true
      else
          case StringMap.find (auxstatus, srcAbs) of
              SOME { mtime = SOME mtime, ... } => Time.> (mtime, OS.FileSys.modTime dst)
            | _ => false
end;
