;;;; bundle.lisp

(in-package #:ql-bundle)

;;; Bundling is taking a set of Quicklisp-provided systems and
;;; creating a directory structure and metadata in which those systems
;;; can be loaded without involving Quicklisp.
;;;
;;; This works for systems provided directly Quicklisp, or systems in
;;; the Quicklisp local-projects directories (if
;;; :include-local-projects is specified).

(defgeneric find-system (system bundle))
(defgeneric add-system (system bundle))
(defgeneric ensure-system (system bundle))

(defgeneric find-release (relase bundle))
(defgeneric add-release (release bundle))
(defgeneric ensure-release (release bundle))

(defgeneric write-loader-script (bundle stream))
(defgeneric write-system-index (bundle stream))

(defgeneric unpack-release (release target))
(defgeneric unpack-releases (bundle target))

(defgeneric write-bundle (bundle target))

(defvar *ignored-systems*
  (list "asdf")
  "Systems that might appear in depends-on lists in Quicklisp, but
  which can't be bundled.")

(defvar *bundle-progress-output*
  (make-synonym-stream '*trace-output*)
  "Informative output related to creating the bundle is sent to this
  stream.")

;;; Implementation

;;; Conditions

(define-condition bundle-error (error) ())

(define-condition object-not-found (bundle-error)
  ((name
    :initarg :name
    :reader object-not-found-name)
   (type
    :initarg :type
    :reader object-not-found-type))
  (:report
   (lambda (condition stream)
     (format stream "~A ~S not found"
             (object-not-found-type condition)
             (object-not-found-name condition))))
  (:default-initargs
   :type "Object"))

(define-condition system-not-found (object-not-found)
  ((name
    :reader system-not-found-system))
  (:default-initargs
   :type "System"))

(define-condition release-not-found (object-not-found)
  ()
  (:default-initargs
   :type "Release"))

(define-condition bundle-directory-exists (bundle-error)
  ((directory
    :initarg :directory
    :reader bundle-directory-exists-directory))
  (:report
   (lambda (condition stream)
     (format stream "Bundle directory ~A already exists"
             (bundle-directory-exists-directory condition)))))


(defun iso8601-time-stamp (&optional (time (get-universal-time)))
  (multiple-value-bind (second minute hour day month year)
      (decode-universal-time time 0)
     (format nil "~4,'0D-~2,'0D-~2,'0DT~
                  ~2,'0D:~2,'0D:~2,'0DZ"
             year month day
             hour minute second)))


(defclass bundle ()
  ((requested-systems
    :initarg :requested-systems
    :reader requested-systems
    :documentation "Names of the systems requested directly for
    bundling.")
   (creation-time
    :initarg :creation-time
    :reader creation-time)
   (release-table
    :initarg :release-table
    :reader release-table)
   (system-table
    :initarg :system-table
    :reader system-table))
  (:default-initargs
   :requested-systems nil
   :creation-time (iso8601-time-stamp)
   :release-table (make-hash-table :test 'equalp)
   :system-table (make-hash-table :test 'equalp)))

(defmethod print-object ((bundle bundle) stream)
  (print-unreadable-object (bundle stream :type t)
    (format stream "~D release~:P, ~D system~:P"
            (hash-table-count (release-table bundle))
            (hash-table-count (system-table bundle)))))

(defmethod provided-releases ((bundle bundle))
  (let ((releases '()))
    (maphash (lambda (name release)
               (declare (ignore name))
               (push release releases))
             (release-table bundle))
    (sort releases 'string< :key 'name)))

(defmethod provided-systems ((bundle bundle))
  (sort (mapcan #'provided-systems (provided-releases bundle))
        'string<
        :key 'name))

(defmethod find-system (name (bundle bundle))
  (values (gethash name (system-table bundle))))

(defmethod add-system (name (bundle bundle))
  (let ((system (ql-dist:find-system name)))
    (unless system
      (error 'system-not-found
             :name name))
    (ensure-release (name (release system)) bundle)
    system))

(defmethod ensure-system (name (bundle bundle))
  (or (find-system name bundle)
      (add-system name bundle)))

(defmethod find-release (name (bundle bundle))
  (values (gethash name (release-table bundle))))

(defmethod add-release (name (bundle bundle))
  (let ((release (ql-dist:find-release name)))
    (unless release
      (error 'release-not-found
             :name name))
    (setf (gethash (name release) (release-table bundle)) release)
    (let ((system-table (system-table bundle)))
      (dolist (system (provided-systems release))
        (setf (gethash (name system) system-table) system)))
    release))

(defmethod ensure-release (name (bundle bundle))
  (or (find-release name bundle)
      (add-release name bundle)))


(defun add-systems-recursively (names bundle)
  (with-consistent-dists
    (labels ((add-one (name)
               (unless (member name *ignored-systems* :test 'equalp)
                 (let ((system
                        (restart-case
                            (ensure-system name bundle)
                          (omit ()
                            :report "Ignore this system and omit it from the bundle."))))
                   (when system
                     (dolist (required-system-name (required-systems system))
                       (add-one required-system-name)))))))
      (map nil #'add-one names)))
  bundle)


(defmethod unpack-release (release target)
  (let ((*default-pathname-defaults* (truename
                                      (ensure-directories-exist target)))
        (archive (ensure-local-archive-file release))
        (temp-tar (ensure-directories-exist
                   (ql-setup:qmerge "tmp/bundle.tar"))))
    (ql-gunzipper:gunzip archive temp-tar)
    (ql-minitar:unpack-tarball temp-tar :directory "software/")
    (delete-file temp-tar)
    release))

(defmethod unpack-releases ((bundle bundle) target)
  (dolist (release (provided-releases bundle))
    (unpack-release release target))
  bundle)

(defmethod write-system-index ((bundle bundle) stream)
  (dolist (release (provided-releases bundle))
    ;; Working with strings, here, intentionally not with pathnames
    (let ((prefix (concatenate 'string "software/" (prefix release))))
      (dolist (system-file (system-files release))
        (format stream "~A/~A~%" prefix system-file)))))

(defmethod write-loader-script ((bundle bundle) stream)
  (let ((template-lines
         (load-time-value
          (with-open-file (stream #. (merge-pathnames "bundle-template"
                                                      (or *compile-file-truename*
                                                          *load-truename*)))
            (loop for line = (read-line stream nil)
                  while line collect line)))))
    (dolist (line template-lines)
      (write-line line stream))))

(defun coerce-to-directory (pathname)
  ;; Cribbed from quicklisp-bootstrap/quicklisp.lisp
  (let ((name (file-namestring pathname)))
    (if (or (null name)
            (equal name ""))
        pathname
        (make-pathname :defaults pathname
                       :name nil
                       :type nil
                       :directory (append (pathname-directory pathname)
                                          (list name))))))

(defun bundle-metadata-plist (bundle)
  (list :creation-time (creation-time bundle)
        :requested-systems (requested-systems bundle)
        :lisp-info (list :machine-instance (machine-instance)
                         :machine-type (machine-type)
                         :machine-version (machine-version)
                         :lisp-implementation-type (lisp-implementation-type)
                         :lisp-implementation-version (lisp-implementation-version))
        :quicklisp-info (list :home (namestring ql:*quicklisp-home*)
                              :local-project-directories
                              (mapcar 'namestring ql:*local-project-directories*)
                              :dists
                              (loop for dist in (enabled-dists)
                                    collect (list :name (name dist)
                                                  :dist-url
                                                  (canonical-distinfo-url dist)
                                                  :version (version dist))))))

(defmethod write-bundle ((bundle bundle) target)
  (unpack-releases bundle target)
  (let ((index-file (merge-pathnames "system-index.txt" target))
        (loader-file (merge-pathnames "bundle.lisp" target))
        (local-projects (merge-pathnames "local-projects/" target))
        (metadata-file (merge-pathnames "bundle-info.sexp" target)))
    (ensure-directories-exist local-projects)
    (with-open-file (stream index-file :direction :output
                            :if-exists :supersede)
      (write-system-index bundle stream))
    (with-open-file (stream loader-file :direction :output
                            :if-exists :supersede)
      (write-loader-script bundle stream))
    (with-open-file (stream metadata-file :direction :output
                            :if-exists :supersede)
      (with-standard-io-syntax
        (let ((*print-pretty* t))
          (prin1 (bundle-metadata-plist bundle) stream)
          (terpri stream))))
    (probe-file loader-file)))


(defun copy-file (from-file to-file)
  (with-open-file (from-stream from-file :element-type '(unsigned-byte 8)
                               :if-does-not-exist nil)
    (when from-stream
      (let ((buffer (make-array 10000 :element-type '(unsigned-byte 8))))
        (with-open-file (to-stream to-file
                                   :direction :output
                                   :if-exists :supersede
                                   :element-type '(unsigned-byte 8))
          (loop
            (let ((end-index (read-sequence buffer from-stream)))
              (when (zerop end-index)
                (return to-file))
              (write-sequence buffer to-stream :end end-index))))))))

(defun copy-directory-tree (from-directory to-directory)
  ;; Use the truename here to ensure that relative pathnames match up
  ;; properly. For example, on SBCL, "~/foo/bar/" entries are not
  ;; relative to "/home/baz/foo/bar/" entries.
  (setf from-directory (truename from-directory))
  (map-directory-tree
   from-directory
   (lambda (from-pathname)
     (when (probe-file from-pathname)
       (let* ((relative (enough-namestring from-pathname from-directory))
              (relative-directory (pathname-directory relative))
              (to-pathname (merge-pathnames relative to-directory)))
         (unless (or (null relative-directory)
                     (eql (first relative-directory)
                          :relative))
           (error "Expected relative pathname to copy from ~A ~
                   - bad symlink? - ~S"
                  from-pathname
                  relative))
         (ensure-directories-exist to-pathname)
         (copy-file from-pathname to-pathname))))))

(defun copy-local-projects-directories (local-projects-directories
                                        to-directory)
  "Copy the local-projects directories to TO-DIRECTORY. Each one gets
  a distinct subdirectory."
  (loop for prefix from 0
        for prefix-directory = (make-pathname :directory
                                              (list :relative
                                                    (format nil "~4,'0X" prefix)))
        for from-directory in local-projects-directories
        for real-to-directory = (merge-pathnames prefix-directory to-directory)
        do
        (format *bundle-progress-output*
                "~&; Copying ~A to bundle..." from-directory )
        (force-output *bundle-progress-output*)
        (ensure-directories-exist real-to-directory)
        (copy-directory-tree from-directory real-to-directory)
        (format *bundle-progress-output* "done.~%")
        (force-output *bundle-progress-output*)))


(defun ql:bundle-systems (system-names
                          &key include-local-projects to (overwrite t))
  "In the directory TO, construct a self-contained bundle of libraries
based on SYSTEM-NAMES. For each system named, and its recursive
required systems, unpack its release archive in TO/software/, and
write a system index, compatible with the output of
QL:WRITE-ASDF-MANIFEST-FILE, to TO/system-index.txt. Write a loader
script to TO/bundle.lisp that, when loaded via CL:LOAD, configures
ASDF to load systems from the bundle before any other system.

SYSTEM-NAMES must name systems provided directly by Quicklisp.

If INCLUDE-LOCAL-PROJECTS is true, each directory in
QL:*LOCAL-PROJECT-DIRECTORIES* is copied into the bundle and loaded
before any of the other bundled systems."
  (unless to
    (error "TO argument must be provided"))
  (let* ((bundle (make-instance 'bundle
                                :requested-systems system-names))
         (to (coerce-to-directory to))
         (software (merge-pathnames "software/" to)))
    (when (and (probe-directory to)
               (not overwrite))
      (cerror "Overwrite it"
              'bundle-directory-exists
              :directory to))
    (when (probe-directory software)
      (delete-directory-tree software))
    (add-systems-recursively system-names bundle)
    (let ((bundled-local-projects (merge-pathnames "bundled-local-projects/"
                                                   to)))
      (when include-local-projects
        (when (probe-directory bundled-local-projects)
          (delete-directory-tree bundled-local-projects))
        (copy-local-projects-directories ql:*local-project-directories*
                                         bundled-local-projects)
        (ensure-directories-exist bundled-local-projects)
        (ql::make-system-index bundled-local-projects)))
    (values (write-bundle bundle to)
            bundle)))
