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;