R/options.R

Defines functions options.create options.fix options.print_default_dir options.help options.open options.modify options.load options.diff options.list options.delete options.copy options.validate options_create options_fix options_print_default_dir options_help options_open options_modify options_load options_diff options_list options_delete options_copy options_validate

Documented in options_copy options.copy options_create options.create options_delete options.delete options_diff options.diff options_fix options.fix options_help options.help options_list options.list options_load options.load options_modify options.modify options_open options.open options_print_default_dir options.print_default_dir options_validate options.validate

#' Validate a user options file against an options template.
#'
#' This function reads a YAML template and an options file, flattens both structures,
#' and then checks that:
#'  - Every option defined in the template is present in the options file.
#'  - The value for each option is of the correct type.
#'  - (Optionally) It warns about extra options in the file that are not defined in the template.
#'
#' For each problem found (missing option or type mismatch), an error message is printed.
#'
#' @param options_file_name *\[character\]* Name of the user options file to validate, including the suffix.
#' @param options_dir *\[character, optional\]* Full path to the folder that contains user options files. If not provided, the default folder is chosen. Defaults to `NULL`.
#' @param should_flag_redundant *\[logical, optional\]* If TRUE, warn the user about any extraneous options (i.e., options not defined in the options template, such as custom options that the user might have added). Defaults to FALSE.
#' @param template_path *\[character, optional\]* Full path to the options template file. Defaults to `NULL`.
#' @param failure_action *\[character\]* Action to take if validation fails. Can be one of: 'abort_verbose', 'abort_quiet', 'return_errors_verbose', 'return_errors_quiet'. Defaults to 'abort_verbose'.
#' `list` Invisibly returns a list of error messages (empty if no errors).
#' @return *\[list\]* The validation errors
#' @export
options_validate <- function(
  options_file_name = NULL,
  options_dir = NULL,
  should_flag_redundant = FALSE,
  template_path = NULL,
  failure_action = "abort_verbose"
) {
  box::use(
    artma / const[CONST],
    artma / options / ask[ask_for_existing_options_file_name],
    artma / options / files[
      options_file_path,
      read_options_file,
      resolve_options_dir,
      resolve_template_path
    ],
    artma / options / utils[
      get_expected_type,
      validate_option_value
    ],
    artma / options / template[flatten_template_options, flatten_user_options, read_template, collect_leaf_paths],
    artma / libs / core / utils[get_verbosity],
    artma / libs / core / validation[assert, validate]
  )

  template_path <- resolve_template_path(template_path)

  validate(is.logical(should_flag_redundant))
  assert(
    failure_action %in% CONST$OPTIONS$VALIDATION_ACTIONS,
    sprintf("Invalid failure action specified: %s. Must be one of: %s", failure_action, paste(CONST$OPTIONS$VALIDATION_ACTIONS, collapse = ", "))
  )

  options_dir <- resolve_options_dir(options_dir)

  options_file_name <- options_file_name %||% ask_for_existing_options_file_name(options_dir = options_dir, prompt = "Please select the user options file you wish to validate: ")
  options_path <- options_file_path(options_dir, options_file_name)

  assert(file.exists(options_path), sprintf("Options file '%s' does not exist.", options_path))

  if (get_verbosity() >= 4) {
    cli::cli_inform("Validating the user options file {.file {options_file_name}}...")
  }

  # Load the YAML files
  user_yaml <- read_options_file(options_path)
  template <- read_template(template_path)
  leaf_set <- collect_leaf_paths(template_path)

  template_defs <- flatten_template_options(template) # Flatten the template
  flat_options <- flatten_user_options(user_options = user_yaml, leaf_set = leaf_set) # Flatten the options

  errors <- list()

  # Validate that every expected option is provided and has the correct type.
  for (opt_def in template_defs) {
    opt_name <- opt_def$name
    allow_na <- opt_def$allow_na
    exp_type <- get_expected_type(opt_def)

    if (!(opt_name %in% names(flat_options))) {
      errors[[length(errors) + 1]] <- list(
        type = "missing_option",
        value = NULL,
        opt_def = opt_def,
        message = paste0("Missing option: '", opt_name, "'")
      )
    } else {
      # Option exists, validate its type/value
      value <- flat_options[[opt_name]]
      err_msg <- validate_option_value(value, exp_type, opt_name, allow_na)
      if (!is.null(err_msg)) {
        errors[[length(errors) + 1]] <- list(
          type = "type_mismatch",
          value = value,
          opt_def = opt_def,
          message = err_msg
        )
      }
    }
  }

  # Warn about extraneous options in the file.
  if (should_flag_redundant) {
    for (opt_name in names(flat_options)) {
      if (!any(vapply(template_defs, function(x) identical(x$name, opt_name), logical(1)))) {
        if (get_verbosity() >= 2) {
          cli::cli_alert_warning("Extraneous option: {CONST$STYLES$OPTIONS$NAME(opt_name)} is not defined in the template.")
        }
      }
    }
  }

  if (length(errors) > 0) {
    cli::cli_alert_danger("Validation failed.")

    if (get_verbosity() >= 2) {
      cli::cli_h1("Validation errors found:")
      for (err in errors) {
        cli::cli_alert_danger(err$message)
      }

      cli::cli_h3("Possible Resolutions:")
      cli::cli_ul()
      cli::cli_li("Run {.code artma::options_help(c('opt.name1', 'opt.name2', ...))} to view detailed descriptions of the specified options.")
      cli::cli_li("Run {.code artma::options_modify()} to manually modify the options file.")
      cli::cli_li("Run {.code artma::options_fix()} to automatically fix detected errors where possible.")
      cli::cli_end()
      cli::cat_line()
    }
  } else {
    if (get_verbosity() >= 3) {
      cli::cli_alert_success("The user options file {.path {options_file_name}} is valid.")
    }
  }

  if (grepl("abort", failure_action) && length(errors) > 0) {
    cli::cli_abort(sprintf("Validation failed for file %s.", options_file_name))
  }

  invisible(errors)
}

#' @title Copy user options
#' @description Provide a name of a user options file to copy from, and a name of a file to copy to, and copy from the 'from' file to the 'to' file.
#' @param options_file_name_from *\[character, optional\]* Name of the options file to copy from. If not provided, the user will be prompted. Defaults to \code{NULL}.
#' @param options_file_name_to *\[character, optional\]* Name of the options file to copy to. If not provided, the user will be prompted. Defaults to \code{NULL}.
#' @param options_dir *\[character, optional\]* Full path to the folder that contains user options files. If not provided, the default folder is chosen. Defaults to `NULL`.
#' @param should_overwrite *\[logical, optional\]* Whether to overwrite an existing file without asking. If `TRUE`, the file will be overwritten without prompting. If `FALSE`, the function will abort if the file already exists. If `NULL` (default), the user will be prompted.
#' @return `NULL`
#' @export
options_copy <- function(
  options_file_name_from = NULL,
  options_file_name_to = NULL,
  options_dir = NULL,
  should_overwrite = NULL
) {
  box::use(
    artma / options / ask[ask_for_options_file_name, ask_for_existing_options_file_name],
    artma / options / files[
      options_file_path,
      resolve_options_dir
    ],
    artma / libs / core / validation[assert],
    artma / interactive / ask[ask_for_overwrite_permission],
    artma / libs / core / utils[get_verbosity]
  )

  options_dir <- resolve_options_dir(options_dir)
  options_file_name_from <- options_file_name_from %||% ask_for_existing_options_file_name(options_dir = options_dir, prompt = "Please select the name of the user options file you wish to copy from: ")
  source_path <- options_file_path(options_dir, options_file_name_from)

  assert(file.exists(source_path), cli::format_inline("The source options file does not exist under the following path: {.path {source_path}}"))

  options_file_name_to <- options_file_name_to %||% ask_for_options_file_name()
  destination_path <- options_file_path(options_dir, options_file_name_to)

  ask_for_overwrite_permission(
    destination_path,
    action_name = "copying a user options file",
    should_overwrite = should_overwrite
  )
  file.copy(source_path, destination_path, overwrite = TRUE)

  if (get_verbosity() >= 3) {
    cli::cli_alert_success("The user options file {.path {options_file_name_from}} has been successfully copied over to {.path {options_file_name_to}}.")
  }
}

#' @title Delete user options
#' @description Provide a name of a user options file to delete, and delete that file. `options_remove()` is an alias.
#' @param options_file_name *\[character, optional\]* Name of the options file to delete. If not provided, the user will be prompted. Defaults to `NULL`.
#' @param options_dir *\[character, optional\]* Full path to the folder that contains user options files. If not provided, the default folder is chosen. Defaults to `NULL`.
#' @param skip_confirmation *\[boolean, optional\]* If passed as TRUE, the user will not be prompted for deletion confirmation. Defaults to FALSE.
#' @return `NULL`
#' @export
options_delete <- function(
  options_file_name = NULL,
  options_dir = NULL,
  skip_confirmation = FALSE
) {
  box::use(
    artma / interactive / input[ask_select],
    artma / options / ask[ask_for_existing_options_file_name],
    artma / options / files[
      options_file_path,
      resolve_options_dir
    ],
    artma / libs / core / validation[assert, validate],
    artma / libs / core / utils[get_verbosity]
  )

  options_dir <- resolve_options_dir(options_dir)
  options_file_names <- options_file_name %||% ask_for_existing_options_file_name(options_dir = options_dir, prompt = "Please select the user options files you wish to delete: ", multiple = TRUE)

  if (!skip_confirmation) {
    deletion_confirmed <- ask_select(
      question = cli::format_inline("Are you sure you wish to delete {.file {options_file_names}}?"),
      choices = c("Yes, I am sure" = "yes", "No, I did not mean to" = "no")
    )
    if (!identical(deletion_confirmed, "yes")) {
      cli::cli_abort("Aborting user option file deletion.")
    }
  }

  invisible(lapply(options_file_names, function(file_name) {
    target_path <- options_file_path(options_dir, file_name)

    validate(is.logical(skip_confirmation))
    if (!file.exists(target_path)) {
      if (get_verbosity() >= 2) {
        cli::cli_alert_warning("The user options file {.file {file_name}} does not exist. Skipping deletion.")
      }
      return(invisible(NULL))
    }

    base::file.remove(target_path)

    if (get_verbosity() >= 3) {
      cli::cli_alert_success("The user options file {.file {file_name}} has been deleted.")
    }
  }))
}

#' @rdname options_delete
#' @export
options_remove <- options_delete

#' @title List available user options
#' @description Retrieves the list of the existing options files and returns their names as a character vector. By default, this retrieves the names of the files including the yaml suffix, but can be modified to retrieve options verbose names instead.
#' @param options_dir *\[character, optional\]* Full path to the folder that contains user options files. If not provided, the default folder is chosen. Defaults to `NULL`.
#' @param should_return_verbose_names *\[logical, optional\]* If set to TRUE, the custom names of each of the options files are read and returned instead of file names. Defaults to FALSE.
#' @param details *\[logical, optional\]* If set to TRUE, a `data.frame` describing each options file is returned instead of a character vector: the file name, its resolved `data.source_path`, when the file was last modified, when it was last run, and how many of its options deviate from the template defaults. The last run time is read from the file's output directory, and is `NA` when the file has never produced any results. Defaults to FALSE.
#' @param template_path *\[character, optional\]* Full path to the options template file, used to resolve the defaults when `details = TRUE`. Defaults to `NULL`.
#' @return *\[vector, character | data.frame\]* A character vector with the names of the options available, or a `data.frame` when `details = TRUE`.
#' @export
options_list <- function(
  options_dir = NULL,
  should_return_verbose_names = FALSE,
  details = FALSE,
  template_path = NULL
) {
  box::use(
    artma / options / files[
      list_options_files,
      resolve_options_dir,
      resolve_template_path
    ],
    artma / options / inspect[describe_options_files],
    artma / libs / core / validation[validate]
  )

  validate(
    is.logical(should_return_verbose_names),
    is.logical(details)
  )

  options_dir <- resolve_options_dir(options_dir, must_exist = FALSE)

  if (isTRUE(details)) {
    return(describe_options_files(
      options_dir = options_dir,
      template_path = resolve_template_path(template_path)
    ))
  }

  list_options_files(options_dir, should_return_verbose_names)
}

#' @title Diff two user options files
#' @description Compares two user options files, printing the options whose
#'   values differ between them, followed by each file's deviations from the
#'   template defaults.
#' @details List-typed options (such as `data.columns`) are compared entry by
#'   entry, so the diff names the individual column mappings that differ rather
#'   than reporting the whole store as changed.
#' @param options_file_name_a *\[character, optional\]* Name of the first options file to compare. If not provided, the user will be prompted. Defaults to `NULL`.
#' @param options_file_name_b *\[character, optional\]* Name of the second options file to compare. If not provided, the user will be prompted. Defaults to `NULL`.
#' @param options_dir *\[character, optional\]* Full path to the folder that contains user options files. If not provided, the default folder is chosen. Defaults to `NULL`.
#' @param template_path *\[character, optional\]* Full path to the options template file. Defaults to `NULL`.
#' @return *\[list\]* Invisibly, a list with the compared `files`, a `differences` data frame, and a `deviations` list holding one data frame of template-default deviations per file.
#' @export
options_diff <- function(
  options_file_name_a = NULL,
  options_file_name_b = NULL,
  options_dir = NULL,
  template_path = NULL
) {
  box::use(
    artma / options / ask[ask_for_existing_options_file_name],
    artma / options / files[resolve_options_dir, resolve_template_path],
    artma / options / inspect[diff_options_files, print_options_diff],
    artma / options / utils[parse_options_file_name],
    artma / libs / core / utils[get_verbosity]
  )

  options_dir <- resolve_options_dir(options_dir)
  template_path <- resolve_template_path(template_path)

  options_file_name_a <- options_file_name_a %||% ask_for_existing_options_file_name(
    options_dir = options_dir,
    prompt = "Please select the first user options file to compare: "
  )
  options_file_name_b <- options_file_name_b %||% ask_for_existing_options_file_name(
    options_dir = options_dir,
    prompt = "Please select the second user options file to compare: "
  )

  diff <- diff_options_files(
    options_dir = options_dir,
    file_a = parse_options_file_name(options_file_name_a),
    file_b = parse_options_file_name(options_file_name_b),
    template_path = template_path
  )

  if (get_verbosity() >= 2) {
    print_options_diff(diff)
  }

  invisible(diff)
}

#' @title Load user options
#' @description Load user options by their name and return them as a list.
#' @details In case the options name is not passed, the function will attempt to load the current options configuration. If none is found, it will then attempt to load the default options. If that fails too, an error is raised.
#' @param options_file_name *\[character, optional\]* Name of the options to load. The `.yaml` suffix is appended automatically if missing. Defaults to `NULL`.
#' @param options_dir *\[character, optional\]* Path to the folder in which to look for user options files. Defaults to `NULL`.
#' @param create_options_if_null *\[logical, optional\]* If set to TRUE and the options file name is set to NULL, the function will prompt the user to create a new options file. Defaults to TRUE.
#' @param load_with_prefix *\[logical, optional\]* Whether the options should be loaded with the package prefix. Defaults to TRUE.
#' @param template_path *\[character, optional\]* Path to the template YAML file. Defaults to `NULL`.
#' @param should_validate *\[logical, optional\]* Whether the options should be validated after loading. Defaults to TRUE.
#' @param should_add_temp_options *\[logical, optional\]* Whether the options should be added to the temporary options. Defaults to FALSE.
#' @param should_return *\[logical, optional\]* Whether the function should return the list of options. Defaults to FALSE.
#' @return *\[list|NULL\]* The loaded options as a list or `NULL`.
#' @export
options_load <- function(
  options_file_name = NULL,
  options_dir = NULL,
  create_options_if_null = TRUE,
  load_with_prefix = TRUE,
  template_path = NULL,
  should_validate = TRUE,
  should_add_temp_options = FALSE,
  should_return = TRUE
) {
  box::use(
    artma / const[CONST],
    artma / interactive / input[ask_select],
    artma / options / files[
      options_file_path,
      read_options_file,
      resolve_options_dir,
      resolve_template_path
    ],
    artma / options / template[
      flatten_user_options,
      collect_leaf_paths,
      get_option_defs,
      get_template_defaults
    ],
    artma / options / utils[get_expected_type, validate_option_value, parse_options_file_name],
    artma / libs / core / validation[validate],
    artma / libs / core / utils[get_verbosity]
  )

  options_dir <- resolve_options_dir(options_dir, must_exist = FALSE)
  template_path <- resolve_template_path(template_path)

  validate(
    is.character(options_dir),
    is.logical(create_options_if_null),
    is.logical(load_with_prefix),
    is.logical(should_validate),
    is.logical(should_return)
  )

  if (is.null(options_file_name)) {
    existing_options_files <- options_list(options_dir = options_dir)

    if (rlang::is_empty(existing_options_files)) {
      if (!create_options_if_null) {
        cli::cli_abort("No user options file to load was provided. Exiting...")
      }
      # Automatically create a new options file when none exist
      options_file_name <- options_create(
        options_file_name = options_file_name,
        options_dir = options_dir
      )
    } else {
      if (!interactive()) {
        cli::cli_abort("No user options file to load was provided. Exiting...")
      }

      # Combine "Create new" option with existing files in a single menu
      create_new_option_text <- "Create a new options file"
      selected <- ask_select(
        question = "Select an options file to load, or create a new one",
        choices = c(create_new_option_text, existing_options_files)
      )

      if (identical(selected, create_new_option_text)) {
        options_file_name <- options_create(
          options_file_name = options_file_name,
          options_dir = options_dir
        )
      } else if (length(selected) == 1 && selected %in% existing_options_files) {
        options_file_name <- selected
      } else {
        # Also reached on a cancelled menu, which returns an empty selection.
        cli::cli_abort("No user options file was selected. Aborting...")
      }
    }
  }

  options_file_name <- parse_options_file_name(options_file_name)

  options_path <- options_file_path(options_dir, options_file_name)
  nested_options <- read_options_file(options_path)

  parent_key <- if (load_with_prefix) CONST$PACKAGE_NAME else NULL
  leaf_set <- collect_leaf_paths(template_path)
  # The flattened paths carry the parent prefix, so the leaf set must too:
  # otherwise list-typed leaves (e.g. data.columns) are descended into instead
  # of being taken whole.
  flatten_leaf_set <- if (is.null(parent_key)) {
    leaf_set
  } else {
    paste0(parent_key, ".", leaf_set)
  }
  user_values <- flatten_user_options(
    user_options = nested_options,
    leaf_set = flatten_leaf_set,
    parent = parent_key
  )

  # Loading is pure: merge the user's values over the template defaults. It never
  # writes to disk and never prompts. Fixing and migration live in options_fix().
  defaults <- get_template_defaults(template_path = template_path, prefix = parent_key)

  if (should_validate) {
    # Keep each user value only when it matches the template type; otherwise fall
    # back to the default. Required options with no default that are missing or
    # invalid abort the load, naming the offending options.
    resolved <- defaults
    unresolved <- character(0)
    substituted <- character(0)

    for (opt_def in get_option_defs(template_path = template_path)) {
      opt_name <- opt_def$name
      key <- if (is.null(parent_key)) opt_name else paste0(parent_key, ".", opt_name)

      user_ok <- (key %in% names(user_values)) &&
        is.null(validate_option_value(
          user_values[[key]], get_expected_type(opt_def), opt_name, opt_def$allow_na
        ))

      if (user_ok) {
        resolved[[key]] <- user_values[[key]]
      } else if (!(key %in% names(defaults))) {
        unresolved <- c(unresolved, opt_name)
      } else if (key %in% names(user_values)) {
        # The file holds a value that does not match the template type. Loading
        # stays resilient and falls back to the default, but say so: a later
        # options_modify() persists the resolved set and would overwrite the
        # user's value without trace.
        substituted <- c(substituted, opt_name)
      }
    }

    if (length(substituted) > 0 && get_verbosity() >= 2) {
      cli::cli_alert_warning(
        "Ignoring {length(substituted)} invalid value{?s} in {.file {options_file_name}} and using the template default{?s} instead: {.val {substituted}}. Run {.code artma::options_fix()} to write the defaults to the file."
      )
    }

    if (length(unresolved) > 0) {
      cli::cli_abort(c(
        "Cannot load options file {.file {options_file_name}}.",
        "x" = "Required option{?s} without a default value {?is/are} missing or invalid: {.val {unresolved}}.",
        "i" = "Run {.code artma::options_fix()} to repair the file."
      ))
    }

    prefixed_options <- resolved
  } else {
    prefixed_options <- utils::modifyList(defaults, user_values)
  }

  if (get_verbosity() >= 4) {
    cli::cli_inform("Loading options from the following user options file: {.file {options_file_name}}")
  }

  if (should_add_temp_options) {
    prefixed_options[["artma.temp.file_name"]] <- options_file_name
    prefixed_options[["artma.temp.dir_name"]] <- options_dir
  }

  if (should_return) {
    return(prefixed_options)
  }

  invisible(NULL)
}

#' @title Modify User Options
#' @description Modify an existing user options file with new values.
#'
#' @param options_file_name *\[character, optional\]* Name of the user options file to modify, including the suffix.
#' @param options_dir *\[character, optional\]* Full path to the folder that contains user options files. If not provided, the default folder is chosen. Defaults to `NULL`.
#' @param template_path *\[character, optional\]* Full path to the options template file. Defaults to `NULL`.
#' @param user_input *\[list, optional\]* A named list of user-supplied values for these options, using either flat dotted-path names (e.g. `list("data.source_path" = "...")`, matching what `options_load(load_with_prefix = FALSE)` returns) or nested lists that mirror the YAML structure (e.g. `list(data = list(source_path = "..."))`); both are flattened against the template before merging, so a partial edit to a list-type option (e.g. one entry of `data.columns`) is merged into the existing value instead of replacing it. If `NULL` or missing entries exist, the function will prompt the user via `readline()` (for required entries) or use defaults (for optional ones).
#' @param should_validate *\[logical, optional\]* If TRUE, validate the modified options file against the template. Defaults to TRUE.
#' @return `NULL`
#' @export
options_modify <- function(
  options_file_name = NULL,
  options_dir = NULL,
  template_path = NULL,
  user_input = list(),
  should_validate = TRUE
) {
  box::use(
    artma / options / ask[
      ask_for_existing_options_file_name,
      ask_for_options_to_modify
    ],
    artma / libs / core / validation[assert, validate],
    artma / options / files[resolve_template_path],
    artma / options / template[collect_leaf_paths, flatten_user_options],
    artma / options / utils[validate_user_input]
  )

  options_file_name <- options_file_name %||% ask_for_existing_options_file_name(options_dir = options_dir, prompt = "Please select the name of the user options file you wish to modify: ")

  validate(
    is.list(user_input),
    is.logical(should_validate)
  )

  current_options <- options_load(
    options_file_name = options_file_name,
    options_dir = options_dir,
    create_options_if_null = FALSE,
    load_with_prefix = FALSE,
    should_validate = TRUE,
    should_return = TRUE
  )

  if (length(user_input) == 0) {
    if (!interactive()) {
      cli::cli_abort("If you wish to modify a user options file, you must provide a list of options to modify, together with their values.")
    }

    user_input <- ask_for_options_to_modify()
  }

  validate_user_input(user_input)

  # current_options is keyed by flat dotted paths (options_load(load_with_prefix
  # = FALSE)). Flattening user_input against the same leaf set turns either a
  # nested user_input or an already-flat one into that representation, so
  # modifyList() merges into the matching key instead of adding a sibling
  # top-level key that later clobbers it (see flat_to_nested()).
  leaf_set <- collect_leaf_paths(resolve_template_path(template_path))
  flat_user_input <- flatten_user_options(user_options = user_input, leaf_set = leaf_set)

  new_options <- utils::modifyList(current_options, flat_user_input)

  options_create(
    options_file_name = options_file_name,
    options_dir = options_dir,
    template_path = template_path,
    user_input = new_options,
    should_validate = should_validate,
    should_overwrite = TRUE,
    action_name = "modifying"
  )

  invisible(NULL)
}

#' @title Options Open
#' @description
#' Open an options file for editing. Must be run interactively.
#' The editor is resolved from: (1) cli.editor option, (2) VISUAL/EDITOR env vars,
#' or (3) system default file handler.
#' @param options_file_name *\[character, optional\]* Name of the user options file to modify, including the suffix.
#' @param options_dir *\[character, optional\]* Full path to the folder that contains user options files. If not provided, the default folder is chosen. Defaults to `NULL`.
#' @return `NULL` Opens the file for editing
#' @export
options_open <- function(
  options_file_name = NULL,
  options_dir = NULL
) {
  box::use(
    artma / options / files[resolve_options_dir],
    artma / options / ask[ask_for_existing_options_file_name],
    artma / libs / core / utils[get_verbosity],
    editor_mod = artma / interactive / editor
  )

  if (!rlang::is_interactive()) {
    if (get_verbosity() >= 2) {
      cli::cli_alert_warning("Running in non-interactive mode. Cannot open options file.")
    }
    return(invisible(NULL))
  }

  options_dir <- resolve_options_dir(options_dir)
  options_file_name <- options_file_name %||% ask_for_existing_options_file_name(
    options_dir = options_dir,
    prompt = "Select the options file to open: "
  )

  file_path <- file.path(options_dir, options_file_name)

  editor_res <- editor_mod$resolve_cli_editor(options_file_path = file_path)
  editor_cmd <- editor_res$cmd

  if (!is.character(editor_cmd) || length(editor_cmd) != 1 ||
    is.na(editor_cmd) || !nzchar(trimws(editor_cmd))) {
    cli::cli_abort(c(
      "x" = "No suitable editor could be found.",
      "i" = "Set {.field cli.editor} in your options file (e.g., {.code \"nano\"}, {.code \"vim\"}).",
      "i" = "Or set the {.envvar VISUAL} or {.envvar EDITOR} environment variable."
    ))
  }

  editor_mod$open_with_cli(path = file_path, editor_cmd = editor_cmd)

  invisible(NULL)
}

#' @title Options Help
#' @description
#' Prints information for each requested option (or all options if `options` is `NULL`).
#'
#' @details Called without arguments, the whole option tree is printed, grouped by
#'   top-level section, one line per option carrying its name, type and default.
#'   Named options are printed in full, with their help text. A name that matches
#'   a group rather than a single option (e.g. `"methods.bma"`, or `"methods"`)
#'   expands to every option underneath it.
#'
#' @param options *\[character, optional\]* A single option name (dot-separated), a
#'   group name, or a character vector thereof. If `NULL`, prints **all** options
#'   from the template as a tree.
#' @param template_path *\[character, optional\]* Path to the template YAML file.
#'   Defaults to \code{PATHS$FILE_OPTIONS_TEMPLATE}.
#' @return Invisibly returns `NULL`, printing the requested information
#'   to the console.
#' @export
options_help <- function(
  options = NULL,
  template_path = NULL
) {
  box::use(
    artma / options / files[resolve_template_path],
    artma / options / inspect[expand_option_tokens, print_option_detail, print_option_tree],
    artma / options / template[flatten_template_options, read_template],
    artma / libs / core / validation[validate],
    artma / libs / core / utils[get_verbosity]
  )

  if (!is.null(options)) {
    validate(is.character(options))
  }

  if (get_verbosity() < 2) {
    return(invisible(NULL))
  }

  template_path <- resolve_template_path(template_path)

  template_raw <- read_template(template_path)
  template_defs <- flatten_template_options(template_raw)

  # Build a named lookup table: name -> option definition
  #    Each item typically has `name`, `type`, `default`, `help`, possibly others.
  template_map <- stats::setNames(template_defs, vapply(template_defs, `[[`, character(1), "name"))

  if (is.null(options)) {
    print_option_tree(template_defs)
    return(invisible(NULL))
  }

  if (length(options) == 0) {
    cli::cli_alert_warning("No options to explain.")
    return(invisible(NULL))
  }

  # A requested name is either an option or a group; a group expands to every
  # option underneath it, so the caller does not have to know the full path.
  expansion <- expand_option_tokens(options, names(template_map))

  if (length(expansion$not_found) > 0) {
    cli::cli_alert_warning("The following requested option(s) are not recognized: {.emph {expansion$not_found}}")
  }

  if (length(expansion$matched) == 0) {
    return(invisible(NULL))
  }

  cli::cli_h1("Options Help")
  cli::cat_line()

  for (opt_name in expansion$matched) {
    print_option_detail(template_map[[opt_name]])
  }

  invisible(NULL)
}

#' @title Print default user options directory
#' @description Prints the full path to the directory where user options are stored by default
#' @param ... *\[any\]* Additional arguments.
#' @return `NULL` Prints the default directory to console.
#' @export
options_print_default_dir <- function(...) {
  box::use(
    artma / paths[PATHS],
    artma / libs / core / utils[get_verbosity]
  )

  if (get_verbosity() >= 2) {
    cli::cli_h1("User option files default directory:")
    cli::cli_text("{.path {PATHS$DIR_USR_CONFIG}}")
  }
}

#' @title Fix user options file
#' @description Fix a user options file by setting the default values for missing options.
#' @details The function will attempt to load the user options file and validate it. If any errors are found, the function will attempt to fix them by setting the default values for the missing options.
#' @param options_file_name *\[character, optional\]* Name of the options file to fix, including the .yaml suffix. Defaults to `NULL`.
#' @param options_dir *\[character, optional\]* Path to the folder in which to look for user options files. Defaults to `NULL`.
#' @param template_path *\[character, optional\]* Path to the options template file. Defaults to `NULL`.
#' @param force_default_overwrites *\[logical, optional\]* If set to TRUE, the function will overwrite the existing options file with the default values. Defaults to TRUE.
#' @return `NULL` Fixes the user options file.
#' @export
options_fix <- function(
  options_file_name = NULL,
  options_dir = NULL,
  template_path = NULL,
  force_default_overwrites = TRUE
) {
  box::use(
    artma / const[CONST],
    artma / options / ask[ask_for_existing_options_file_name, ask_for_option_value],
    artma / options / files[
      options_file_path,
      resolve_options_dir,
      resolve_template_path
    ],
    artma / options / utils[parse_options_file_name],
    artma / libs / core / validation[validate],
    artma / libs / core / utils[get_verbosity]
  )

  validate(is.logical(force_default_overwrites))

  template_path <- resolve_template_path(template_path)

  options_file_name <- options_file_name %||% ask_for_existing_options_file_name(options_dir = options_dir, prompt = "Please select the name of the user options file you wish to fix: ")
  options_file_name <- parse_options_file_name(options_file_name)

  options_dir <- resolve_options_dir(options_dir)

  expected_path <- options_file_path(options_dir, options_file_name)
  if (!file.exists(expected_path)) {
    cli::cli_abort(cli::format_inline("The user options file {.file {options_file_name}} does not exist under path {.path {expected_path}}."))
  }

  # Fold any legacy dual-store column options into the unified data.columns
  # store before validating, so the fix pass works on the current format.
  box::use(artma / options / migrate[migrate_legacy_options])
  migrate_legacy_options(options_file_name = options_file_name, options_dir = options_dir)

  errors <- withr::with_options(
    list("artma.verbose" = min(get_verbosity(), 2)),
    options_validate(
      options_file_name = options_file_name,
      options_dir = options_dir,
      template_path = template_path,
      failure_action = "return_errors_quiet"
    )
  )

  if (length(errors) == 0) {
    if (get_verbosity() >= 3) {
      cli::cli_alert_success("No errors found in the user options file '{.file {options_file_name}}'.")
    }
    return(invisible(NULL))
  }

  cli::cli_h1("Fixing User Options File")
  if (get_verbosity() >= 2) {
    cli::cli_text("We have detected errors in the user options file: {.file {options_file_name}}.")
  }

  fixed_options <- list()
  proposed_changes <- list()
  has_printed_missing_message <- FALSE

  for (err in errors) {
    opt_def <- err$opt_def
    opt_name <- opt_def$name
    opt_value <- if (is.null(err$value)) "null" else err$value
    if (is.null(opt_def$default)) {
      if (!has_printed_missing_message) {
        cli::cli_h3("Missing Required Options:")
        cli::cli_ul()
        cli::cli_li("Some required options are missing and have no default values. Please provide the values for these options.")
        cli::cli_li("{.strong Syntax}: {CONST$STYLES$OPTIONS$NAME('<option_name>')}: {CONST$STYLES$OPTIONS$VALUE('<old_value>')} -> {CONST$STYLES$OPTIONS$VALUE('<new_value>')}")
        cli::cli_end()
        cli::cat_line()
        has_printed_missing_message <- TRUE
      }
      # A required option is missing and has no default value - ask the user for input
      fixed_value <- ask_for_option_value(
        option_name = opt_name,
        option_type = opt_def$type,
        allow_na = opt_def$allow_na
      )
    } else if (err$type == "missing_option" || force_default_overwrites) {
      fixed_value <- opt_def$default
    } else if (err$type == "type_mismatch") {
      fixed_value <- opt_def$default # Here, the user could be asked for input as well
    } else {
      cli::cli_abort("Unknown error type encountered while fixing the user options file.")
    }
    fixed_options[[opt_name]] <- fixed_value
    proposed_changes <- append(
      proposed_changes,
      paste0(CONST$STYLES$OPTIONS$NAME(opt_name), ": ", CONST$STYLES$OPTIONS$VALUE(opt_value), " -> ", CONST$STYLES$OPTIONS$VALUE(fixed_value))
    )
  }

  cli::cli_h3("Proposed Changes:")
  cli::cli_ul()
  for (change in proposed_changes) {
    cli::cli_li(change)
  }
  cli::cli_end()

  cli::cat_line()

  if (interactive()) {
    box::use(artma / interactive / input[ask_yes_no])
    if (!ask_yes_no("Do you wish to apply the proposed changes to the user options file?")) {
      cli::cli_abort("Aborting the fixing of a user options file.")
    }
  } else {
    if (get_verbosity() >= 2) {
      cli::cli_alert_info("Running in non-interactive mode. The proposed changes will be applied automatically.")
    }
  }

  if (get_verbosity() >= 4) {
    cli::cli_inform("Fixing the user options file {.path {options_file_name}}...")
  }

  options_create(
    options_file_name = options_file_name,
    options_dir = options_dir,
    template_path = template_path,
    user_input = fixed_options,
    should_validate = TRUE,
    should_overwrite = TRUE,
    action_name = "fixing"
  )

  invisible(NULL)
}

#' @title Create user options
#' @description Create a new user options file from an options template.
#' @param options_file_name *\[character\]* Name of the new user options file, including the suffix.
#' @param options_dir *\[character, optional\]* Full path to the folder that contains user options files. If not provided, the default folder is chosen. Defaults to `NULL`.
#' @param template_path *\[character, optional\]* Full path to the options template file.
#' @param user_input *\[list, optional\]* A named list of user-supplied values for these options, using either flat dotted-path names (e.g. `list("data.source_path" = "...")`) or nested lists that mirror the YAML structure (e.g. `list(data = list(source_path = "..."))`). When overwriting an existing file, a partial edit to a list-type option (e.g. one entry of `data.columns`) is merged into the existing value instead of replacing it. If `NULL` or missing entries exist, the function will prompt the user via `readline()` (for required entries) or use defaults (for optional ones).
#' @param should_validate *\[logical, optional\]* If TRUE, validate the new options file against the template. Defaults to TRUE.
#' @param should_overwrite *\[logical, optional\]* If TRUE, overwrite the file if it already exists. Defaults to FALSE, in which case the user is prompted to confirm the overwrite.
#' @param action_name *\[character, optional\]* A name for the action being performed. This is used for logging purposes. Defaults to "create".
#' `character` Name of the newly created user options file as a character.
#' @return `NULL`
#' @export
options_create <- function(
  options_file_name = NULL,
  options_dir = NULL,
  template_path = NULL,
  user_input = list(),
  should_validate = TRUE,
  should_overwrite = FALSE,
  action_name = "creating"
) {
  box::use(
    artma / options / ask[ask_for_options_file_name],
    artma / options / template[parse_options_from_template, flatten_user_options, collect_leaf_paths],
    artma / options / utils[
      flat_to_nested,
      parse_options_file_name
    ],
    artma / options / files[
      options_file_path,
      read_options_file,
      resolve_options_dir,
      resolve_template_path,
      write_options_file
    ],
    artma / libs / core / validation[assert, validate],
    artma / libs / core / utils[get_verbosity]
  )

  validate(is.character(action_name))

  template_path <- resolve_template_path(template_path)
  validate(
    is.character(template_path),
    is.list(user_input)
  )

  options_file_name <- options_file_name %||% ask_for_options_file_name()
  options_file_name <- parse_options_file_name(options_file_name)

  if (get_verbosity() >= 4) {
    cli::cli_inform("{tools::toTitleCase(action_name)} a user options file: {.path {options_file_name}}")
  }

  options_dir <- resolve_options_dir(options_dir, must_exist = FALSE)
  options_path <- options_file_path(options_dir, options_file_name)

  assert(
    is.character(options_path) && length(options_path) > 0,
    cli::format_inline("Invalid options file path: {.path {options_path}}")
  )

  leaf_set <- collect_leaf_paths(template_path)
  # Flatten user_input against the template leaf set so a nested user_input
  # (e.g. list(data = list(source_path = ...))) resolves to the same flat
  # dotted keys as list("data.source_path" = ...) on both branches below.
  user_input <- flatten_user_options(user_options = user_input, leaf_set = leaf_set)

  # Honor the "mock" data-source shortcut programmatically, not only in the
  # interactive prompt: generate the mock dataset and point the option at it.
  if (identical(user_input[["data.source_path"]], "mock")) {
    box::use(artma / data / mock[materialize_mock_data_path])
    user_input[["data.source_path"]] <- materialize_mock_data_path()
  }

  parsed_options <- if (!file.exists(options_path)) {
    parse_options_from_template(
      path         = template_path,
      user_input   = user_input,
      interactive  = TRUE,
      add_prefix   = FALSE
    )
  } else {
    file_exists_msg <- cli::format_inline("An options file {.path {options_file_name}} already exists.")

    if (isTRUE(should_overwrite)) {
      cli::cli_inform("{file_exists_msg} Overwriting this file...")
    } else {
      if (!interactive()) {
        cli::cli_abort("{file_exists_msg} Either allow overwriting or provide a different name.")
      }
      box::use(artma / interactive / input[ask_yes_no])
      if (!ask_yes_no(paste(file_exists_msg, "Do you wish to overwrite the contents of this file?"))) {
        cli::cli_abort("Aborting the overwriting of a user options file.")
      }
    }
    current_options <- flatten_user_options(user_options = read_options_file(options_path), leaf_set = leaf_set)
    utils::modifyList(current_options, user_input)
  }

  nested_options <- flat_to_nested(parsed_options)

  if (should_validate) {
    # Validate a temporary copy before touching the real file: options_validate()
    # can abort, and if it did after the real file was already overwritten, a
    # previously valid options file would be left corrupted on disk with no
    # way back.
    tmp_path <- tempfile(
      pattern = paste0(tools::file_path_sans_ext(options_file_name), "-"),
      tmpdir = options_dir,
      fileext = ".yaml"
    )
    write_options_file(tmp_path, nested_options)
    on.exit(if (file.exists(tmp_path)) file.remove(tmp_path), add = TRUE)

    withr::with_options(
      list("artma.verbose" = min(get_verbosity(), 2)),
      options_validate(
        options_file_name = basename(tmp_path),
        options_dir = options_dir,
        template_path = template_path,
        failure_action = "abort_verbose"
      )
    )

    file.copy(tmp_path, options_path, overwrite = TRUE)
  } else {
    write_options_file(options_path, nested_options)
  }

  if (get_verbosity() >= 3) {
    cli::cli_alert_success("{tools::toTitleCase(action_name)} a user options file {.path {options_file_name}} was successful.")
  }

  invisible(options_file_name)
}

# Deprecated dotted aliases (see contributingGuides/API.md). Removed after 1.0.

#' @rdname artma-deprecated
#' @export
options.validate <- function(...) {
  lifecycle::deprecate_warn("0.4.0", "options.validate()", "options_validate()")
  options_validate(...)
}

#' @rdname artma-deprecated
#' @export
options.copy <- function(...) {
  lifecycle::deprecate_warn("0.4.0", "options.copy()", "options_copy()")
  options_copy(...)
}

#' @rdname artma-deprecated
#' @export
options.delete <- function(...) {
  lifecycle::deprecate_warn("0.4.0", "options.delete()", "options_delete()")
  options_delete(...)
}

#' @rdname artma-deprecated
#' @export
options.list <- function(...) {
  lifecycle::deprecate_warn("0.4.0", "options.list()", "options_list()")
  options_list(...)
}

#' @rdname artma-deprecated
#' @export
options.diff <- function(...) {
  lifecycle::deprecate_warn("0.4.0", "options.diff()", "options_diff()")
  options_diff(...)
}

#' @rdname artma-deprecated
#' @export
options.load <- function(...) {
  lifecycle::deprecate_warn("0.4.0", "options.load()", "options_load()")
  options_load(...)
}

#' @rdname artma-deprecated
#' @export
options.modify <- function(...) {
  lifecycle::deprecate_warn("0.4.0", "options.modify()", "options_modify()")
  options_modify(...)
}

#' @rdname artma-deprecated
#' @export
options.open <- function(...) {
  lifecycle::deprecate_warn("0.4.0", "options.open()", "options_open()")
  options_open(...)
}

#' @rdname artma-deprecated
#' @export
options.help <- function(...) {
  lifecycle::deprecate_warn("0.4.0", "options.help()", "options_help()")
  options_help(...)
}

#' @rdname artma-deprecated
#' @export
options.print_default_dir <- function(...) { # nolint: object_name_linter.
  lifecycle::deprecate_warn("0.4.0", "options.print_default_dir()", "options_print_default_dir()")
  options_print_default_dir(...)
}

#' @rdname artma-deprecated
#' @export
options.fix <- function(...) {
  lifecycle::deprecate_warn("0.4.0", "options.fix()", "options_fix()")
  options_fix(...)
}

#' @rdname artma-deprecated
#' @export
options.create <- function(...) {
  lifecycle::deprecate_warn("0.4.0", "options.create()", "options_create()")
  options_create(...)
}

Try the artma package in your browser

Any scripts or data that you put into this service are public.

artma documentation built on Oct. 27, 2026, 9:06 a.m.