5 Not overwriting a manually edited R package

As described in the previous section, the function setup() will only overwrite a directory if it is the unedited output from using litr. The basic idea is that the function litr::render() when creating a new package finishes by adding a hash to the DESCRIPTION file. (And likewise when rmarkdown::render() is used with a litr output format, as described here.) This hash is a function of everything in the package, so if anything about the package changes (any file is modified, added, or removed) then the function check_unedited() will be able to detect that by recomputing the hash and seeing that it doesn’t match the hash in the DESCRIPTION file.

Let’s start by defining the function hash_package_directory() that does the hashing. The hash is a function of everything in the outputted package except for that special line in the DESCRIPTION file with the hash. We use tools::md5sum() and digest::digest() to do the hashing.

#' Hash package directory
#' 
#' Gets an identifier that can be used to uniquely (whp) identify the current 
#' state of the package. This is formed by ignoring the `LitrId` field of the
#' DESCRIPTION file, which is the location where the output of this function is 
#' stored when `litr::render` generates the package.
#' 
#' @param package_dir Path to package
#' @keywords internal
hash_package_directory <- function(package_dir) {
  pkg_files <- fs::dir_ls(package_dir, recurse = TRUE, all = TRUE, type = "file")
  pkg_files <- stringr::str_subset(pkg_files, ".DS_Store$", negate = TRUE)
  pkg_files <- normalizePath(pkg_files)
  descr_file <- normalizePath(file.path(package_dir, "DESCRIPTION"))
  i_descr <- which(pkg_files == descr_file)
  if (length(i_descr) == 0) stop("Cannot find DESCRIPTION file.")
  txt_descr <- readLines(pkg_files[i_descr])
  txt_descr_mod <- stringr::str_subset(
    txt_descr, 
    stringr::str_glue("{description_litr_hash_field_name()}: .+$"),
    negate = TRUE)
  hashes <- as.character(tools::md5sum(pkg_files[-i_descr]))
  digest::digest(c(hashes, list(txt_descr_mod)))
}

We used digest, so let’s import it:

usethis::use_package("digest")
## ✔ Adding 'digest' to Imports field in DESCRIPTION
## • Refer to functions with `digest::fun()`

We will store this hash in a special field within the DESCRIPTION file. Let’s call this field LitrId. However, in case we ever decide to change the name of this field, it’s better that we only define it in one place. So we do this with the following function:

#' Generate litr hash field name for DESCRIPTION file
#' @keywords internal
description_litr_hash_field_name <- function() return("LitrId")

Ok, now let’s write the function that litr::render() will call that will take the generated R package and add a line that puts the hash in the DESCRIPTION file under that special litr field:

#' Write the hash of the package to the DESCRIPTION file
#' 
#' @param package_dir Path to package
#' @keywords internal
write_hash_to_description <- function(package_dir) {
  desc_file <- file.path(package_dir, "DESCRIPTION")
  if (!file.exists(desc_file)) file.create(desc_file)
  hash <- hash_package_directory(package_dir)
  desc::desc_set(description_litr_hash_field_name(), hash, file = desc_file)
}

Let’s include the desc package, which helps us manipulate DESCRIPTION files.

usethis::use_package("desc")
## ✔ Adding 'desc' to Imports field in DESCRIPTION
## • Refer to functions with `desc::fun()`

And of course we’ll need a function that can read the value of that field as well:

#' Get the hash of the package from the DESCRIPTION file
#' 
#' @param package_dir Path to package
#' @keywords internal
read_hash_from_description <- function(package_dir) {
  descr <- file.path(package_dir, "DESCRIPTION")
  if (!file.exists(descr)) stop("Cannot find DESCRIPTION file.")
  txt <- stringr::str_subset(
    readLines(descr), 
    stringr::str_glue("{description_litr_hash_field_name()}: .+$"))
  if (length(txt) > 1) stop("More than one hash found in DESCRIPTION.")
  if (length(txt) == 0) stop("No hash found in DESCRIPTION.")
  stringr::str_extract(txt, "\\S+$")
}

With all this hash functionality in place, the function check_unedited() is actually quite simple to define:

#' Check if package directory is the unedited output of litr::render()
#' 
#' Uses hash stored in a special `litr` field of DESCRIPTION file to check that 
#' the current state of the R package directory is identical to its state at the
#' time that it was created by `litr::render()`.
#' 
#' @param package_dir Path to package
#' @keywords internal
check_unedited <- function(package_dir) {
  hash <- hash_package_directory(package_dir)
  hash == read_hash_from_description(package_dir)
}

It simply computes the hash of the current package and checks whether that hash is the same as what was originally written to the DESCRIPTION file by litr::render().