diff --git a/CLAUDE.md b/CLAUDE.md index 4545a21..71a9404 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,10 @@ r -e 'tinypkgr::check()' - Text files in `this_week/` remain the source of truth. `tasks()` is a read-through projection, not a cache. - Internal callers that need the full schema (sections, period, paths, ordering) should keep using `parse_todo()`. +## Preview mode + +Every mutator (`roll_day`, `run_monday`, `fix_parents`, `next_day`, `sync_from_daily`, `instantiate_todo`) accepts `preview = TRUE` and returns a `hacer_preview` describing the would-be change without writing. Set `HACER_PREVIEW=1` to flip the default — useful for one-shot agent invocations that should be inspectable before they touch the user's todo repo. Internals live in `R/preview.R`; each mutator builds a `targets` list of `path -> new_lines` and dispatches via `.write_or_preview()`. + ## Task File Format ``` diff --git a/DESCRIPTION b/DESCRIPTION index f7e8d76..4c5002d 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: hacer Title: Plain-Text Nested ToDo Planning -Version: 0.1.5 +Version: 0.1.6 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai", diff --git a/NAMESPACE b/NAMESPACE index a8568d1..e348d72 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -19,4 +19,6 @@ export(tasks) export(todo_config) export(use_repo) +S3method(print,hacer_preview) + importFrom(yaml,read_yaml) diff --git a/R/cli.R b/R/cli.R index 02bc83d..26df0b5 100644 --- a/R/cli.R +++ b/R/cli.R @@ -2,60 +2,66 @@ #' Generate the new week's files (run on Mondays) #' @param date A Date. Defaults to `Sys.Date()`. #' @param cfg A config list from `todo_config()`. +#' @param preview If `TRUE`, return a `hacer_preview` instead of writing. +#' Defaults to the `HACER_PREVIEW=1` env var or `FALSE`. #' @export -run_monday <- function(date = Sys.Date(), cfg = todo_config()) { - dir.create(cfg$live_dir, recursive = TRUE, showWarnings = FALSE) - dir.create(cfg$archive_dir, recursive = TRUE, showWarnings = FALSE) - +run_monday <- function(date = Sys.Date(), cfg = todo_config(), + preview = .preview_default()) { + if (!preview) { + dir.create(cfg$live_dir, recursive = TRUE, showWarnings = FALSE) + dir.create(cfg$archive_dir, recursive = TRUE, showWarnings = FALSE) + } + this_mon <- .monday_of(date) - # find previous Monday by subtracting 7 days prev_mon <- this_mon - 7L - - # locate previous set in archive or live + prev <- paths_for(prev_mon, cfg) if (!all(file.exists(prev$live)) && !all(file.exists(prev$archive))) { stop("Previous week's files not found in live or archive.") } src <- if (all(file.exists(prev$live))) prev$live else prev$archive - - # read previous + daily <- parse_todo(src[.period_types == "Daily"], "Daily") week <- parse_todo(src[.period_types == "Week"], "Week") month <- parse_todo(src[.period_types == "Month"], "Month") quarter <- parse_todo(src[.period_types == "Quarter"], "Quarter") - - # advance and build new tables + nxt <- advance_period(daily, week, month, quarter, prev_mon, this_mon) - - # write to live (Syncthing) + dst <- paths_for(this_mon, cfg) + targets <- list() for (p in .period_types) { df <- nxt[[p]] - write_todo_txt(df, dst$live[.period_types==p], p, cfg) + txt_path <- dst$live[.period_types == p] + targets[[txt_path]] <- build_todo_txt_lines(df, txt_path, p, cfg) if (isTRUE(cfg$render_markdown)) { - write_markdown(df, sub("\\.txt$", ".md", dst$live[.period_types==p]), p, cfg) + md_path <- sub("\\.txt$", ".md", txt_path) + targets[[md_path]] <- build_markdown_lines(df, md_path, p, cfg) } if (isTRUE(cfg$render_html)) { - write_simple_html(df, sub("\\.txt$", ".html", dst$live[.period_types==p]), p) + html_path <- sub("\\.txt$", ".html", txt_path) + targets[[html_path]] <- build_simple_html_lines(df, html_path, p) } } - + + result <- .write_or_preview(targets, preview) + # archive: copy previous live into archive, then git add/commit if the folder is a repo - if (all(file.exists(prev$live))) { + # This side effect only happens in non-preview mode. + if (!preview && all(file.exists(prev$live))) { file.copy(from = prev$live, to = prev$archive, overwrite = TRUE) - # best-effort git (no dependency) old_wd <- getwd(); on.exit(setwd(old_wd), add = TRUE) setwd(cfg$archive_dir) if (file.exists(file.path(cfg$archive_dir, ".git"))) { system2("git", c("add", ".")) msg <- paste("Archive ToDos:", format(prev_mon)) system2("git", c("commit", "-m", shQuote(msg)), stdout = FALSE, stderr = FALSE) - # optional push: system2("git", c("push"), stdout = FALSE, stderr = FALSE) } } - - invisible(dst$live) + + if (!preview) return(invisible(dst$live)) + result } #' Infer a period name from a ToDo filename @@ -69,33 +75,42 @@ infer_period_from_filename <- function(f){ #' Roll up parent statuses in a single file #' @param file_name Path to a ToDo `.txt` file. +#' @param preview If `TRUE`, return a `hacer_preview` instead of writing. +#' Defaults to the `HACER_PREVIEW=1` env var or `FALSE`. #' @export -fix_parents <- function(file_name){ +fix_parents <- function(file_name, preview = .preview_default()){ per <- infer_period_from_filename(file_name) df <- parse_todo(file_name, per) df <- inherit_recur_to_parents(df) df <- rollup_status(df) - write_todo_txt(df, file_name, ifelse(is.na(per), "Daily", per)) - invisible(file_name) + per_eff <- ifelse(is.na(per), "Daily", per) + new_lines <- build_todo_txt_lines(df, file_name, per_eff) + targets <- list() + targets[[file_name]] <- new_lines + result <- .write_or_preview(targets, preview) + if (!preview) return(invisible(file_name)) + result } # R/cli.R #' Sync new items added in Daily up to Week/Month/Quarter #' @param date A Date. Defaults to `Sys.Date()`. #' @param cfg A config list from `todo_config()`. +#' @param preview If `TRUE`, return a `hacer_preview` instead of writing. +#' Defaults to the `HACER_PREVIEW=1` env var or `FALSE`. #' @export -sync_from_daily <- function(date = Sys.Date(), cfg = todo_config()){ +sync_from_daily <- function(date = Sys.Date(), cfg = todo_config(), + preview = .preview_default()){ p <- paths_for(date, cfg) d <- parse_todo(p$live[grepl("_Daily", p$live)], "Daily") W <- parse_todo(p$live[grepl("_Week", p$live)], "Week") M <- parse_todo(p$live[grepl("_Month", p$live)], "Month") Q <- parse_todo(p$live[grepl("_Quarter",p$live)], "Quarter") - + add_missing <- function(src, tgt){ if (!nrow(src)) return(tgt) need <- setdiff(src$path, tgt$path) if (!length(need)) return(tgt) - # append missing paths (and any missing ancestors) append_path <- function(tgt, fullpath, section){ parts <- strsplit(fullpath, " > ", fixed=TRUE)[[1]] cur <- character() @@ -125,14 +140,22 @@ sync_from_daily <- function(date = Sys.Date(), cfg = todo_config()){ } tgt } - + W <- add_missing(d, W); M <- add_missing(d, M); Q <- add_missing(d, Q) W <- inherit_recur_to_parents(W); M <- inherit_recur_to_parents(M); Q <- inherit_recur_to_parents(Q) W <- rollup_status(W); M <- rollup_status(M); Q <- rollup_status(Q) - write_todo_txt(W, p$live[grepl("_Week", p$live)], "Week", cfg) - write_todo_txt(M, p$live[grepl("_Month", p$live)], "Month", cfg) - write_todo_txt(Q, p$live[grepl("_Quarter", p$live)], "Quarter", cfg) - invisible(TRUE) + + w_path <- p$live[grepl("_Week", p$live)] + m_path <- p$live[grepl("_Month", p$live)] + q_path <- p$live[grepl("_Quarter", p$live)] + targets <- list() + targets[[w_path]] <- build_todo_txt_lines(W, w_path, "Week", cfg) + targets[[m_path]] <- build_todo_txt_lines(M, m_path, "Month", cfg) + targets[[q_path]] <- build_todo_txt_lines(Q, q_path, "Quarter", cfg) + + result <- .write_or_preview(targets, preview) + if (!preview) return(invisible(TRUE)) + result } #' Advance tasks from today to tomorrow within the Daily file @@ -144,27 +167,28 @@ sync_from_daily <- function(date = Sys.Date(), cfg = todo_config()){ #' #' @param date A Date. Defaults to `Sys.Date()`. #' @param cfg A config list from `todo_config()`. +#' @param preview If `TRUE`, return a `hacer_preview` instead of writing. +#' Defaults to the `HACER_PREVIEW=1` env var or `FALSE`. #' @export -next_day <- function(date = Sys.Date(), cfg = todo_config()) { +next_day <- function(date = Sys.Date(), cfg = todo_config(), + preview = .preview_default()) { p <- paths_for(date, cfg) daily_file <- p$live[grepl("_Daily", p$live)] if (!file.exists(daily_file)) stop("Daily file not found: ", daily_file) lines <- readLines(daily_file, warn = FALSE) - days <- cfg$daily_sections # e.g., c("Monday", "Tuesday", ...) - - # Determine today's weekday name + days <- cfg$daily_sections today_name <- weekdays(date) today_idx <- match(today_name, days) if (is.na(today_idx)) stop("Today (", today_name, ") not in daily_sections") if (today_idx >= length(days)) { message("Already at ", today_name, " (last day). Nothing to advance.") + if (preview) return(.new_preview()) return(invisible(daily_file)) } tomorrow_name <- days[today_idx + 1L] - # Find section boundaries section_starts <- grep("^#\\s+\\w", lines) section_names <- sub("^#\\s+", "", lines[section_starts]) @@ -174,84 +198,75 @@ next_day <- function(date = Sys.Date(), cfg = todo_config()) { stop("Could not find sections for ", today_name, " and ", tomorrow_name) } - # Find end of today's section (line before tomorrow or next section) today_end <- tomorrow_start - 1L while (today_end > today_start && grepl("^\\s*$|^#", lines[today_end])) { today_end <- today_end - 1L } - # Extract today's task lines if (today_end < today_start) { message("No tasks in ", today_name, " section.") + if (preview) return(.new_preview()) return(invisible(daily_file)) } today_lines <- lines[(today_start + 1L):today_end] - # Filter for copying to tomorrow: - # - Skip [x] completed items - # - Skip Email/ToDo if [/] (daily recurring that got done) - # - Keep blank lines between top-level tasks copy_lines <- character() last_was_task <- FALSE for (ln in today_lines) { - # Keep blank lines (they separate top-level task groups) if (grepl("^\\s*$", ln)) { if (last_was_task) copy_lines <- c(copy_lines, "") last_was_task <- FALSE next } - if (!grepl("^\\s*\\[", ln)) next # not a task line + if (!grepl("^\\s*\\[", ln)) next status <- substr(sub("^\\s*", "", ln), 2, 2) - if (status == "x") next # skip completed + if (status == "x") next - # Check if it's Email or ToDo with [/] is_daily_recur <- grepl("-\\s*\\*?(Email|ToDo)\\s*$", ln, ignore.case = TRUE) - if (is_daily_recur && status == "/") next # skip done daily recurring + if (is_daily_recur && status == "/") next - # Reset status to blank for tomorrow ln_reset <- sub("\\[/\\]", "[ ]", ln) - ln_reset <- sub("\\[x\\]", "[ ]", ln_reset) # just in case + ln_reset <- sub("\\[x\\]", "[ ]", ln_reset) copy_lines <- c(copy_lines, ln_reset) last_was_task <- TRUE } - # Filter today's section: keep only [/] and [x], remove [ ] keep_today <- character() for (ln in today_lines) { if (!grepl("^\\s*\\[", ln)) { - # Keep non-task lines (blanks, etc.) keep_today <- c(keep_today, ln) } else { status <- substr(sub("^\\s*", "", ln), 2, 2) if (status %in% c("/", "x", "!")) { keep_today <- c(keep_today, ln) } - # Drop [ ] unchecked items } } - # Find the separator line before tomorrow (e.g., #######################################) separator_start <- tomorrow_start - 1L while (separator_start > today_end && grepl("^\\s*$", lines[separator_start])) { separator_start <- separator_start - 1L } - # separator_start now points to the ### line (or today_end if none) - # Build new file new_lines <- c( - lines[1:today_start], # everything up to and including today header - keep_today, # filtered today tasks - "", # blank line - lines[separator_start:tomorrow_start], # separator + blank + tomorrow header - "", # blank after header - copy_lines, # copied tasks - "", # blank line + lines[1:today_start], + keep_today, + "", + lines[separator_start:tomorrow_start], + "", + copy_lines, + "", if (tomorrow_start < length(lines)) lines[(tomorrow_start + 1L):length(lines)] else character() ) - # Remove excess blank lines - writeLines(new_lines, daily_file) - message("Advanced from ", today_name, " to ", tomorrow_name) - invisible(daily_file) + targets <- list() + targets[[daily_file]] <- new_lines + result <- .write_or_preview(targets, preview) + + if (!preview) { + message("Advanced from ", today_name, " to ", tomorrow_name) + return(invisible(daily_file)) + } + result } diff --git a/R/instantiate.R b/R/instantiate.R index d6c9844..edd177e 100644 --- a/R/instantiate.R +++ b/R/instantiate.R @@ -2,29 +2,34 @@ #' @param repo_dir Path to your ToDo repo directory. #' @param syncthing_live_dir Optional path to a Syncthing-shared live directory. #' @param overwrite Overwrite existing files? Default `FALSE`. +#' @param preview If `TRUE`, return a `hacer_preview` instead of writing. +#' Defaults to the `HACER_PREVIEW=1` env var or `FALSE`. #' @export instantiate_todo <- function(repo_dir, syncthing_live_dir = NULL, - overwrite = FALSE) { + overwrite = FALSE, + preview = .preview_default()) { repo_dir <- normalizePath(path.expand(repo_dir), mustWork = FALSE) - if (!dir.exists(repo_dir)) dir.create(repo_dir, recursive = TRUE, showWarnings = FALSE) - - # sanity: warn if not a git repo (but don’t require) + if (!preview && !dir.exists(repo_dir)) { + dir.create(repo_dir, recursive = TRUE, showWarnings = FALSE) + } + has_git <- file.exists(file.path(repo_dir, ".git")) - + live_dir <- if (is.null(syncthing_live_dir)) file.path(repo_dir, "this_week") else normalizePath(path.expand(syncthing_live_dir), mustWork = FALSE) archive_dir <- file.path(repo_dir, "archive") - - dir.create(live_dir, recursive = TRUE, showWarnings = FALSE) - dir.create(archive_dir, recursive = TRUE, showWarnings = FALSE) - - # 1) write local config file + + if (!preview) { + dir.create(live_dir, recursive = TRUE, showWarnings = FALSE) + dir.create(archive_dir, recursive = TRUE, showWarnings = FALSE) + } + cfg_path <- file.path(repo_dir, "hacer_config.R") if (file.exists(cfg_path) && !overwrite) { stop("Config already exists at ", cfg_path, ". Set overwrite=TRUE to replace.") } - + cfg_lines <- c( "## Local configuration for hacer", paste0("# ", repo_dir, "/hacer_config.R"), @@ -39,18 +44,15 @@ instantiate_todo <- function(repo_dir, " render_html = TRUE", ")" ) - writeLines(cfg_lines, cfg_path) - # 2) seed initial week in live_dir (based on current Monday) mon <- .monday_of(Sys.Date()) files <- .build_names_for(mon) paths <- file.path(live_dir, files) - + if (any(file.exists(paths)) && !overwrite) { stop("Initial ToDo files already exist in live_dir; set overwrite=TRUE to replace.") } - - # very small, neutral starters (you can expand later) + starter_daily <- function(fname) { secs <- c("Monday","Tuesday","Wednesday","Thursday","Friday") lines <- c(paste0("# ", basename(fname))) @@ -66,7 +68,7 @@ instantiate_todo <- function(repo_dir, } lines } - + starter_week <- function(fname) { c(paste0("# ", basename(fname)), "", "#######################################", "", @@ -79,7 +81,7 @@ instantiate_todo <- function(repo_dir, " [ ] - Fix leaks", " [ ] - Master Window") } - + starter_month <- function(fname) { c(paste0("# ", basename(fname)), "", "#######################################", "", @@ -87,7 +89,7 @@ instantiate_todo <- function(repo_dir, " [ ] - Major Task A", " [ ] - Major Task B") } - + starter_quarter <- function(fname) { c(paste0("# ", basename(fname)), "", "#######################################", "", @@ -95,16 +97,18 @@ instantiate_todo <- function(repo_dir, " [ ] - Theme 1", " [ ] - Theme 2") } - + starters <- list(starter_daily, starter_month, starter_quarter, starter_week) + + targets <- list() + targets[[cfg_path]] <- cfg_lines for (i in seq_along(paths)) { - writeLines(starters[[i]](paths[i]), paths[i]) + targets[[paths[i]]] <- starters[[i]](paths[i]) } - - # 3) basic README to guide the user (optional) + readme_path <- file.path(repo_dir, "README_HACER.md") if (!file.exists(readme_path) || overwrite) { - readme <- c( + targets[[readme_path]] <- c( "# ToDo Project", "", "- Edit `hacer_config.R` to tweak paths and options.", @@ -113,18 +117,22 @@ instantiate_todo <- function(repo_dir, "- Run `hacer::run_monday()` each Monday (or set a cron job).", "- Edit `.txt` files directly in RStudio; Markdown/HTML mirrors are optional." ) - writeLines(readme, readme_path) } - - if (!has_git) { - message("Note: '", repo_dir, "' does not look like a Git repo (no .git). ", - "You can run `git init` there before committing archives.") + + result <- .write_or_preview(targets, preview) + + if (!preview) { + if (!has_git) { + message("Note: '", repo_dir, "' does not look like a Git repo (no .git). ", + "You can run `git init` there before committing archives.") + } + message("Initialized hacer project at: ", repo_dir, + "\n- Config: ", cfg_path, + "\n- Live dir: ", live_dir, + "\n- Archive dir: ", archive_dir, + "\n- Seeded files: \n - ", paste(basename(paths), collapse = "\n - ")) + return(invisible(list(config = cfg_path, live_files = paths, + archive_dir = archive_dir))) } - - message("Initialized hacer project at: ", repo_dir, - "\n- Config: ", cfg_path, - "\n- Live dir: ", live_dir, - "\n- Archive dir: ", archive_dir, - "\n- Seeded files: \n - ", paste(basename(paths), collapse = "\n - ")) - invisible(list(config = cfg_path, live_files = paths, archive_dir = archive_dir)) + result } diff --git a/R/io.R b/R/io.R index 516f358..633b184 100644 --- a/R/io.R +++ b/R/io.R @@ -1,10 +1,8 @@ # R/io.R -write_todo_txt <- function(df, file, period, cfg = todo_config()) { - # rebuild nested text with sections and indent +build_todo_txt_lines <- function(df, file, period, cfg = todo_config()) { lines <- character() lines <- c(lines, paste0("# ", basename(file))) - - # for Daily, keep day sections; otherwise ignore sections + if (period == "Daily") { secs <- unique(df$section) secs <- secs[!is.na(secs)] @@ -17,7 +15,11 @@ write_todo_txt <- function(df, file, period, cfg = todo_config()) { lines <- c(lines, "", "#######################################", "") lines <- c(lines, .df_to_lines(df, cfg$indent)) } - writeLines(lines, file) + lines +} + +write_todo_txt <- function(df, file, period, cfg = todo_config()) { + writeLines(build_todo_txt_lines(df, file, period, cfg), file) } .df_to_lines <- function(df, indent) { @@ -36,10 +38,10 @@ write_todo_txt <- function(df, file, period, cfg = todo_config()) { } # optional mirrors: -write_markdown <- function(df, file_md, period, cfg = todo_config()) { +build_markdown_lines <- function(df, file_md, period, cfg = todo_config()) { lines <- character() lines <- c(lines, paste0("# ", sub("\\.md$", "", basename(file_md)))) - + one <- function(df) { if (!nrow(df)) return(character()) df <- df[order(df$order), , drop=FALSE] @@ -52,7 +54,7 @@ write_markdown <- function(df, file_md, period, cfg = todo_config()) { } out } - + if (period == "Daily") { secs <- unique(df$section); secs <- secs[!is.na(secs)] for (s in secs) { @@ -63,16 +65,19 @@ write_markdown <- function(df, file_md, period, cfg = todo_config()) { lines <- c(lines, "", "## Tasks", "") lines <- c(lines, one(df)) } - writeLines(lines, file_md) + lines } -write_simple_html <- function(df, file_html, period) { - # dead-simple static HTML: no deps +write_markdown <- function(df, file_md, period, cfg = todo_config()) { + writeLines(build_markdown_lines(df, file_md, period, cfg), file_md) +} + +build_simple_html_lines <- function(df, file_html, period) { esc <- function(x) { x <- gsub("&","&",x, fixed=TRUE); x <- gsub("<","<",x,fixed=TRUE); gsub(">",">",x,fixed=TRUE) } lines <- c("","", "", paste0("

", esc(period), "

")) - + to_ul <- function(df) { if (!nrow(df)) return(character()) df <- df[order(df$order), , drop=FALSE] @@ -86,7 +91,7 @@ write_simple_html <- function(df, file_html, period) { } out } - + if (period == "Daily") { secs <- unique(df$section); secs <- secs[!is.na(secs)] for (s in secs) { @@ -96,5 +101,9 @@ write_simple_html <- function(df, file_html, period) { } else { lines <- c(lines, to_ul(df)) } - writeLines(lines, file_html) + lines +} + +write_simple_html <- function(df, file_html, period) { + writeLines(build_simple_html_lines(df, file_html, period), file_html) } diff --git a/R/preview.R b/R/preview.R new file mode 100644 index 0000000..e1e3f90 --- /dev/null +++ b/R/preview.R @@ -0,0 +1,177 @@ +# R/preview.R +# Preview-mode plumbing: every mutator can return a `hacer_preview` object +# describing what would change instead of writing to disk. Set the env var +# HACER_PREVIEW=1 to flip the default for the whole session / one-shot CLI. + +.preview_default <- function() { + v <- Sys.getenv("HACER_PREVIEW", unset = "") + v %in% c("1", "true", "TRUE", "yes", "YES") +} + +.empty_diff_df <- function() { + data.frame(file = character(), line = integer(), text = character(), + stringsAsFactors = FALSE) +} + +.new_preview <- function() { + obj <- list( + files_created = character(), + files_modified = character(), + lines_added = .empty_diff_df(), + lines_removed = .empty_diff_df(), + done_log_appends = character() + ) + attr(obj, "targets") <- list() # named list: path -> new lines + attr(obj, "done_log_path") <- NA_character_ + class(obj) <- "hacer_preview" + obj +} + +# LCS-based line diff. Returns added/removed data.frames keyed to `file`. +.line_diff <- function(old, new, file) { + added <- list(); removed <- list() + if (!length(old) && !length(new)) { + return(list(added = .empty_diff_df(), removed = .empty_diff_df())) + } + if (!length(old)) { + return(list( + added = data.frame(file = file, line = seq_along(new), text = new, + stringsAsFactors = FALSE), + removed = .empty_diff_df() + )) + } + if (!length(new)) { + return(list( + added = .empty_diff_df(), + removed = data.frame(file = file, line = seq_along(old), text = old, + stringsAsFactors = FALSE) + )) + } + m <- length(old); n <- length(new) + L <- matrix(0L, m + 1L, n + 1L) + for (i in seq_len(m)) { + ai <- old[i] + for (j in seq_len(n)) { + if (identical(ai, new[j])) L[i + 1L, j + 1L] <- L[i, j] + 1L + else L[i + 1L, j + 1L] <- max(L[i, j + 1L], L[i + 1L, j]) + } + } + i <- m; j <- n + while (i > 0L || j > 0L) { + if (i > 0L && j > 0L && identical(old[i], new[j])) { + i <- i - 1L; j <- j - 1L + } else if (j > 0L && (i == 0L || L[i + 1L, j] >= L[i, j + 1L])) { + added[[length(added) + 1L]] <- data.frame( + file = file, line = j, text = new[j], stringsAsFactors = FALSE) + j <- j - 1L + } else { + removed[[length(removed) + 1L]] <- data.frame( + file = file, line = i, text = old[i], stringsAsFactors = FALSE) + i <- i - 1L + } + } + list( + added = if (length(added)) + do.call(rbind, rev(added)) else .empty_diff_df(), + removed = if (length(removed)) + do.call(rbind, rev(removed)) else .empty_diff_df() + ) +} + +# Take a list of (path -> new lines) plus optional done.log appends and +# either write everything to disk (preview = FALSE) or return a hacer_preview. +.write_or_preview <- function(targets, + preview, + done_log_path = NA_character_, + done_log_appends = character()) { + if (!preview) { + for (path in names(targets)) { + dir.create(dirname(path), recursive = TRUE, showWarnings = FALSE) + writeLines(targets[[path]], path) + } + if (length(done_log_appends) && !is.na(done_log_path)) { + if (file.exists(done_log_path)) { + cat(paste0(done_log_appends, "\n"), + file = done_log_path, append = TRUE, sep = "") + } else { + writeLines(done_log_appends, done_log_path) + } + } + return(invisible(names(targets))) + } + + pv <- .new_preview() + for (path in names(targets)) { + new_lines <- targets[[path]] + if (!file.exists(path)) { + pv$files_created <- c(pv$files_created, path) + if (length(new_lines)) { + pv$lines_added <- rbind(pv$lines_added, data.frame( + file = path, line = seq_along(new_lines), text = new_lines, + stringsAsFactors = FALSE)) + } + } else { + old_lines <- readLines(path, warn = FALSE) + if (!identical(old_lines, new_lines)) { + pv$files_modified <- c(pv$files_modified, path) + d <- .line_diff(old_lines, new_lines, path) + pv$lines_added <- rbind(pv$lines_added, d$added) + pv$lines_removed <- rbind(pv$lines_removed, d$removed) + } + } + } + pv$done_log_appends <- done_log_appends + attr(pv, "targets") <- targets + attr(pv, "done_log_path") <- done_log_path + pv +} + +# Apply a preview to disk. Mainly used by tests to verify that a preview +# round-trips to the same end state as a non-preview call. Internal. +.apply_preview <- function(pv) { + targets <- attr(pv, "targets") + for (path in names(targets)) { + dir.create(dirname(path), recursive = TRUE, showWarnings = FALSE) + writeLines(targets[[path]], path) + } + done_log_path <- attr(pv, "done_log_path") + if (length(pv$done_log_appends) && !is.na(done_log_path)) { + if (file.exists(done_log_path)) { + cat(paste0(pv$done_log_appends, "\n"), + file = done_log_path, append = TRUE, sep = "") + } else { + writeLines(pv$done_log_appends, done_log_path) + } + } + invisible(names(targets)) +} + +#' Print a hacer_preview summary +#' +#' @param x A `hacer_preview` object. +#' @param ... Unused. +#' @export +print.hacer_preview <- function(x, ...) { + cat("hacer preview\n") + if (length(x$files_created)) { + cat(" created (", length(x$files_created), "):\n", sep = "") + for (f in x$files_created) cat(" + ", f, "\n", sep = "") + } + if (length(x$files_modified)) { + cat(" modified (", length(x$files_modified), "):\n", sep = "") + for (f in x$files_modified) { + n_add <- sum(x$lines_added$file == f) + n_rm <- sum(x$lines_removed$file == f) + cat(" ~ ", f, " (+", n_add, "/-", n_rm, ")\n", sep = "") + } + } + if (length(x$done_log_appends)) { + cat(" done.log appends (", length(x$done_log_appends), "):\n", sep = "") + for (ln in x$done_log_appends) cat(" > ", ln, "\n", sep = "") + } + if (!length(x$files_created) && !length(x$files_modified) && + !length(x$done_log_appends)) { + cat(" (no changes)\n") + } + invisible(x) +} diff --git a/R/roll_day.R b/R/roll_day.R index e6d278b..058a5a9 100644 --- a/R/roll_day.R +++ b/R/roll_day.R @@ -8,15 +8,22 @@ #' #' @param date A Date. Defaults to \code{Sys.Date()}. #' @param cfg A config list from \code{todo_config()}. +#' @param preview If \code{TRUE}, return a \code{hacer_preview} describing the +#' change and write nothing. Defaults to \code{HACER_PREVIEW=1} env var or +#' \code{FALSE}. #' @export -roll_day <- function(date = Sys.Date(), cfg = todo_config()) { - dir.create(cfg$live_dir, recursive = TRUE, showWarnings = FALSE) +roll_day <- function(date = Sys.Date(), cfg = todo_config(), + preview = .preview_default()) { + if (!preview) { + dir.create(cfg$live_dir, recursive = TRUE, showWarnings = FALSE) + } types <- c("Daily", "Week", "Month", "Quarter") live_files <- list.files(cfg$live_dir, pattern = "^ToDo_\\d{6}_.+\\.txt$") if (!length(live_files)) { message("No prior files found in ", cfg$live_dir, ". Nothing to roll.") + if (preview) return(.new_preview()) return(invisible(character())) } @@ -25,6 +32,7 @@ roll_day <- function(date = Sys.Date(), cfg = todo_config()) { valid <- vapply(m, length, integer(1L)) == 3L if (!any(valid)) { message("No valid ToDo files found in ", cfg$live_dir, ". Nothing to roll.") + if (preview) return(.new_preview()) return(invisible(character())) } @@ -33,8 +41,8 @@ roll_day <- function(date = Sys.Date(), cfg = todo_config()) { file_types <- vapply(m, `[`, character(1L), 3L) today_str <- format(date, "%y%m%d") - created <- character() done_log <- character() + targets <- list() repo_dir <- dirname(cfg$live_dir) done_log_path <- file.path(repo_dir, "done.log") @@ -86,24 +94,19 @@ roll_day <- function(date = Sys.Date(), cfg = todo_config()) { out_lines <- c(out_lines, ln) } - writeLines(out_lines, dst_file) - created <- c(created, dst_file) + targets[[dst_file]] <- out_lines } - if (length(done_log)) { - if (file.exists(done_log_path)) { - cat(paste0(done_log, "\n"), file = done_log_path, append = TRUE) + result <- .write_or_preview(targets, preview, done_log_path, done_log) + + if (!preview) { + if (length(targets)) { + message("Rolled to ", format(date), ": ", + paste(basename(names(targets)), collapse = ", ")) } else { - writeLines(done_log, done_log_path) + message("Nothing to roll for ", format(date)) } + return(invisible(names(targets))) } - - if (length(created)) { - message("Rolled to ", format(date), ": ", - paste(basename(created), collapse = ", ")) - } else { - message("Nothing to roll for ", format(date)) - } - - invisible(created) + result } diff --git a/README.md b/README.md index a11d8ff..1d7dbb0 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,30 @@ Resolution order for the repo path is: `repo_dir` argument → `options("hacer.r If you're running [corteza](https://github.com/cornball-ai/corteza)'s MCP server, hacer's exports register automatically as `hacer::*` tools (on the corteza `hacer` branch) so any MCP-capable agent can call them. +### Look before you leap: preview mode + +Every function that writes to disk takes `preview = TRUE` and returns a `hacer_preview` object describing what would change without touching the filesystem: + +```r +pv <- hacer::roll_day(preview = TRUE) +print(pv) +#> hacer preview +#> created (1): +#> + ~/todo/this_week/ToDo_250916_Daily.txt +#> done.log appends (2): +#> > 2025-09-16 [x] - Some finished task +#> > 2025-09-16 [x] - A nested done item +``` + +Set `HACER_PREVIEW=1` to flip the default for a one-shot CLI agent so it never accidentally writes: + +```bash +HACER_REPO=~/todo HACER_PREVIEW=1 r -e 'hacer::run_monday()' +HACER_REPO=~/todo HACER_PREVIEW=1 r -e 'hacer::roll_day()' +``` + +Covers `roll_day()`, `run_monday()`, `fix_parents()`, `next_day()`, `sync_from_daily()`, and `instantiate_todo()`. The preview lists `files_created`, `files_modified`, line-level diffs, and any `done.log` lines that would be appended. + ### Reading: `tasks()` is the structured API `hacer::tasks()` is the read interface for agents. It returns the entire task set across `this_week/` as a `data.frame` so you can filter and reason without re-parsing text: diff --git a/inst/tinytest/test_preview.R b/inst/tinytest/test_preview.R new file mode 100644 index 0000000..274de97 --- /dev/null +++ b/inst/tinytest/test_preview.R @@ -0,0 +1,267 @@ +# test_preview.R - Tests for preview mode across all mutators + +library(hacer) + +tmp_repo <- function() { + d <- tempfile() + dir.create(file.path(d, "this_week"), recursive = TRUE, showWarnings = FALSE) + dir.create(file.path(d, "archive"), recursive = TRUE, showWarnings = FALSE) + d +} + +mtimes_in <- function(dir) { + fs <- list.files(dir, recursive = TRUE, full.names = TRUE) + setNames(file.info(fs)$mtime, fs) +} + +# ---- Helper: roll_day round-trip preview vs apply ---- +cfg_for <- function(repo, indent = 2L) { + list(live_dir = file.path(repo, "this_week"), indent = indent) +} + +# ---- Case 1: roll_day(preview=TRUE) writes nothing ---- +repo1 <- tmp_repo() +cfg1 <- cfg_for(repo1) +f1 <- file.path(cfg1$live_dir, "ToDo_250915_Daily.txt") +writeLines(c( + "# ToDo_250915_Daily.txt", + "", + "[ ] - Pending", + "[x] - Done" +), f1) + +before_mtimes <- mtimes_in(repo1) +pv1 <- hacer::roll_day(date = as.Date("2025-09-16"), cfg = cfg1, preview = TRUE) +after_mtimes <- mtimes_in(repo1) + +expect_inherits(pv1, "hacer_preview", + info = "preview=TRUE returns a hacer_preview") +expect_identical(before_mtimes, after_mtimes, + info = "preview=TRUE leaves on-disk mtimes untouched") +expect_false(file.exists(file.path(cfg1$live_dir, "ToDo_250916_Daily.txt")), + info = "preview=TRUE does not create new files") +expect_false(file.exists(file.path(repo1, "done.log")), + info = "preview=TRUE does not create done.log") + +# Preview reports the would-be changes +expect_equal(length(pv1$files_created), 1L, + info = "Preview lists one new file") +expect_true(grepl("ToDo_250916_Daily\\.txt$", pv1$files_created[1]), + info = "Preview names the new daily file") +expect_equal(length(pv1$done_log_appends), 1L, + info = "Preview lists one done.log append") +expect_true(grepl("Done", pv1$done_log_appends[1]), + info = "done.log append captures the dropped task text") + +unlink(repo1, recursive = TRUE) + +# ---- Case 2: roll_day preview + apply == roll_day non-preview ---- +make_repo <- function() { + r <- tmp_repo() + f <- file.path(r, "this_week", "ToDo_250915_Daily.txt") + writeLines(c( + "# ToDo_250915_Daily.txt", + "", + "#######################################", + "", + "# Monday", + "", + "[ ] - House", + " [/] - Drywall", + " [x] - Trim", + "[ ] -*Exercise", + "[x] - One-time" + ), f) + r +} + +repo_a <- make_repo() +hacer::roll_day(date = as.Date("2025-09-16"), + cfg = cfg_for(repo_a), + preview = FALSE) +state_a <- list( + daily = readLines(file.path(repo_a, "this_week", "ToDo_250916_Daily.txt"), + warn = FALSE), + done = readLines(file.path(repo_a, "done.log"), warn = FALSE) +) + +repo_b <- make_repo() +pv2 <- hacer::roll_day(date = as.Date("2025-09-16"), + cfg = cfg_for(repo_b), + preview = TRUE) +hacer:::.apply_preview(pv2) +state_b <- list( + daily = readLines(file.path(repo_b, "this_week", "ToDo_250916_Daily.txt"), + warn = FALSE), + done = readLines(file.path(repo_b, "done.log"), warn = FALSE) +) + +expect_identical(state_a$daily, state_b$daily, + info = "preview + apply produces same Daily file as direct roll") +expect_identical(state_a$done, state_b$done, + info = "preview + apply produces same done.log as direct roll") + +unlink(repo_a, recursive = TRUE) +unlink(repo_b, recursive = TRUE) + +# ---- Case 3: HACER_PREVIEW env var flips the default ---- +repo3 <- tmp_repo() +cfg3 <- cfg_for(repo3) +f3 <- file.path(cfg3$live_dir, "ToDo_250915_Daily.txt") +writeLines(c("# ToDo_250915_Daily.txt", "", "[ ] - Task"), f3) + +old_env <- Sys.getenv("HACER_PREVIEW", unset = NA) +Sys.setenv(HACER_PREVIEW = "1") +pv3 <- hacer::roll_day(date = as.Date("2025-09-16"), cfg = cfg3) +expect_inherits(pv3, "hacer_preview", + info = "HACER_PREVIEW=1 flips the default to preview mode") +expect_false(file.exists(file.path(cfg3$live_dir, "ToDo_250916_Daily.txt")), + info = "HACER_PREVIEW=1 still writes nothing") + +Sys.setenv(HACER_PREVIEW = "") +out3 <- hacer::roll_day(date = as.Date("2025-09-16"), cfg = cfg3) +expect_false(inherits(out3, "hacer_preview"), + info = "Empty HACER_PREVIEW restores the write default") +expect_true(file.exists(file.path(cfg3$live_dir, "ToDo_250916_Daily.txt")), + info = "Empty HACER_PREVIEW writes as before") + +if (is.na(old_env)) Sys.unsetenv("HACER_PREVIEW") else Sys.setenv(HACER_PREVIEW = old_env) +unlink(repo3, recursive = TRUE) + +# ---- Case 4: print.hacer_preview renders a sensible summary ---- +repo4 <- tmp_repo() +cfg4 <- cfg_for(repo4) +f4 <- file.path(cfg4$live_dir, "ToDo_250915_Daily.txt") +writeLines(c("# ToDo_250915_Daily.txt", "", "[ ] - Task", "[x] - Done"), f4) + +pv4 <- hacer::roll_day(date = as.Date("2025-09-16"), cfg = cfg4, preview = TRUE) +out4 <- capture.output(print(pv4)) +combined4 <- paste(out4, collapse = "\n") +expect_true(grepl("hacer preview", combined4), + info = "Print method labels itself") +expect_true(grepl("created", combined4), + info = "Print mentions created files") +expect_true(grepl("done\\.log", combined4), + info = "Print mentions done.log appends") +unlink(repo4, recursive = TRUE) + +# ---- Case 5: fix_parents preview vs apply round trip ---- +make_fp_repo <- function() { + r <- tmp_repo() + f <- file.path(r, "this_week", "ToDo_250915_Daily.txt") + writeLines(c( + "# ToDo_250915_Daily.txt", + "", + "#######################################", + "", + "# Monday", + "", + "[ ] - House", + " [/] - Drywall", + " [x] - Trim" + ), f) + list(repo = r, + file = f) +} + +fp_a <- make_fp_repo() +hacer::fix_parents(fp_a$file, preview = FALSE) +state_fp_a <- readLines(fp_a$file, warn = FALSE) + +fp_b <- make_fp_repo() +mtime_before <- file.info(fp_b$file)$mtime +pv5 <- hacer::fix_parents(fp_b$file, preview = TRUE) +mtime_after <- file.info(fp_b$file)$mtime +expect_identical(mtime_before, mtime_after, + info = "fix_parents preview leaves mtime untouched") +expect_inherits(pv5, "hacer_preview") +expect_equal(length(pv5$files_modified), 1L, + info = "fix_parents preview reports one modified file") + +hacer:::.apply_preview(pv5) +state_fp_b <- readLines(fp_b$file, warn = FALSE) +expect_identical(state_fp_a, state_fp_b, + info = "fix_parents preview + apply matches non-preview") + +unlink(fp_a$repo, recursive = TRUE) +unlink(fp_b$repo, recursive = TRUE) + +# ---- Case 6: instantiate_todo preview lists files but creates none ---- +repo6 <- tempfile() +pv6 <- hacer::instantiate_todo(repo6, preview = TRUE) +expect_inherits(pv6, "hacer_preview") +expect_false(dir.exists(repo6), + info = "instantiate_todo preview does not create the repo dir") +expect_true(length(pv6$files_created) >= 5L, + info = "Preview lists at least the seed + config files (>= 5)") + +# Apply the preview and verify everything materialized +hacer:::.apply_preview(pv6) +expect_true(dir.exists(repo6), + info = "Applying the preview creates the directory") +expect_true(file.exists(file.path(repo6, "hacer_config.R")), + info = "Config file exists after apply") +expect_true(file.exists(file.path(repo6, "README_HACER.md")), + info = "README exists after apply") +unlink(repo6, recursive = TRUE) + +# ---- Case 7: roll_day on empty repo returns empty preview, not error ---- +repo7 <- tmp_repo() +pv7 <- hacer::roll_day(date = as.Date("2025-09-16"), + cfg = cfg_for(repo7), + preview = TRUE) +expect_inherits(pv7, "hacer_preview") +expect_equal(length(pv7$files_created), 0L, + info = "Empty repo preview has no created files") +expect_equal(length(pv7$files_modified), 0L, + info = "Empty repo preview has no modified files") +unlink(repo7, recursive = TRUE) + +# ---- Case 8: sync_from_daily preview round-trip ---- +make_sync_repo <- function() { + r <- tmp_repo() + mon <- "250915" + for (p in c("Daily", "Week", "Month", "Quarter")) { + f <- file.path(r, "this_week", sprintf("ToDo_%s_%s.txt", mon, p)) + writeLines(c( + paste0("# ToDo_", mon, "_", p, ".txt"), + "", + "#######################################", + "", + "[ ] - Existing" + ), f) + } + # Add a brand-new task only to Daily + daily <- file.path(r, "this_week", paste0("ToDo_", mon, "_Daily.txt")) + writeLines(c( + paste0("# ToDo_", mon, "_Daily.txt"), + "", + "#######################################", + "", + "[ ] - Existing", + "[ ] - Brand new task" + ), daily) + r +} + +sync_a <- make_sync_repo() +hacer::sync_from_daily(date = as.Date("2025-09-15"), + cfg = cfg_for(sync_a), preview = FALSE) +state_sync_a <- lapply(c("Week","Month","Quarter"), function(p) { + readLines(file.path(sync_a, "this_week", paste0("ToDo_250915_", p, ".txt")), + warn = FALSE) +}) + +sync_b <- make_sync_repo() +pv8 <- hacer::sync_from_daily(date = as.Date("2025-09-15"), + cfg = cfg_for(sync_b), preview = TRUE) +expect_inherits(pv8, "hacer_preview") +hacer:::.apply_preview(pv8) +state_sync_b <- lapply(c("Week","Month","Quarter"), function(p) { + readLines(file.path(sync_b, "this_week", paste0("ToDo_250915_", p, ".txt")), + warn = FALSE) +}) +expect_identical(state_sync_a, state_sync_b, + info = "sync_from_daily preview + apply matches non-preview") +unlink(sync_a, recursive = TRUE) +unlink(sync_b, recursive = TRUE) diff --git a/man/fix_parents.Rd b/man/fix_parents.Rd index 5e50450..695815b 100644 --- a/man/fix_parents.Rd +++ b/man/fix_parents.Rd @@ -3,10 +3,13 @@ \alias{fix_parents} \title{Roll up parent statuses in a single file} \usage{ -fix_parents(file_name) +fix_parents(file_name, preview = .preview_default()) } \arguments{ \item{file_name}{Path to a ToDo `.txt` file.} + +\item{preview}{If `TRUE`, return a `hacer_preview` instead of writing. +Defaults to the `HACER_PREVIEW=1` env var or `FALSE`.} } \description{ Roll up parent statuses in a single file diff --git a/man/instantiate_todo.Rd b/man/instantiate_todo.Rd index 2758173..7a59368 100644 --- a/man/instantiate_todo.Rd +++ b/man/instantiate_todo.Rd @@ -3,7 +3,8 @@ \alias{instantiate_todo} \title{Create a hacer project layout in a Git repo.} \usage{ -instantiate_todo(repo_dir, syncthing_live_dir = NULL, overwrite = FALSE) +instantiate_todo(repo_dir, syncthing_live_dir = NULL, overwrite = FALSE, + preview = .preview_default()) } \arguments{ \item{repo_dir}{Path to your ToDo repo directory.} @@ -11,6 +12,9 @@ instantiate_todo(repo_dir, syncthing_live_dir = NULL, overwrite = FALSE) \item{syncthing_live_dir}{Optional path to a Syncthing-shared live directory.} \item{overwrite}{Overwrite existing files? Default `FALSE`.} + +\item{preview}{If `TRUE`, return a `hacer_preview` instead of writing. +Defaults to the `HACER_PREVIEW=1` env var or `FALSE`.} } \description{ Create a hacer project layout in a Git repo. diff --git a/man/next_day.Rd b/man/next_day.Rd index 2f2b1f0..94f253d 100644 --- a/man/next_day.Rd +++ b/man/next_day.Rd @@ -3,12 +3,15 @@ \alias{next_day} \title{Advance tasks from today to tomorrow within the Daily file} \usage{ -next_day(date = Sys.Date(), cfg = todo_config()) +next_day(date = Sys.Date(), cfg = todo_config(), preview = .preview_default()) } \arguments{ \item{date}{A Date. Defaults to `Sys.Date()`.} \item{cfg}{A config list from `todo_config()`.} + +\item{preview}{If `TRUE`, return a `hacer_preview` instead of writing. +Defaults to the `HACER_PREVIEW=1` env var or `FALSE`.} } \description{ Copies tasks from today's section to tomorrow's section (except completed diff --git a/man/print.hacer_preview.Rd b/man/print.hacer_preview.Rd new file mode 100644 index 0000000..0ab21b1 --- /dev/null +++ b/man/print.hacer_preview.Rd @@ -0,0 +1,15 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{print.hacer_preview} +\alias{print.hacer_preview} +\title{Print a hacer_preview summary} +\usage{ +\method{print}{hacer_preview}(x, ...) +} +\arguments{ +\item{x}{A `hacer_preview` object.} + +\item{...}{Unused.} +} +\description{ +Print a hacer_preview summary +} diff --git a/man/roll_day.Rd b/man/roll_day.Rd index 7713dc0..528df78 100644 --- a/man/roll_day.Rd +++ b/man/roll_day.Rd @@ -3,12 +3,16 @@ \alias{roll_day} \title{Roll the most recent ToDo files forward to today} \usage{ -roll_day(date = Sys.Date(), cfg = todo_config()) +roll_day(date = Sys.Date(), cfg = todo_config(), preview = .preview_default()) } \arguments{ \item{date}{A Date. Defaults to \code{Sys.Date()}.} \item{cfg}{A config list from \code{todo_config()}.} + +\item{preview}{If \code{TRUE}, return a \code{hacer_preview} describing the +change and write nothing. Defaults to \code{HACER_PREVIEW=1} env var or +\code{FALSE}.} } \description{ Finds the most recent file of each cadence in \code{this_week/}, copies it diff --git a/man/run_monday.Rd b/man/run_monday.Rd index adf5c85..c04d108 100644 --- a/man/run_monday.Rd +++ b/man/run_monday.Rd @@ -3,12 +3,15 @@ \alias{run_monday} \title{Generate the new week's files (run on Mondays)} \usage{ -run_monday(date = Sys.Date(), cfg = todo_config()) +run_monday(date = Sys.Date(), cfg = todo_config(), preview = .preview_default()) } \arguments{ \item{date}{A Date. Defaults to `Sys.Date()`.} \item{cfg}{A config list from `todo_config()`.} + +\item{preview}{If `TRUE`, return a `hacer_preview` instead of writing. +Defaults to the `HACER_PREVIEW=1` env var or `FALSE`.} } \description{ Generate the new week's files (run on Mondays) diff --git a/man/sync_from_daily.Rd b/man/sync_from_daily.Rd index 8ebc357..5943cab 100644 --- a/man/sync_from_daily.Rd +++ b/man/sync_from_daily.Rd @@ -3,12 +3,16 @@ \alias{sync_from_daily} \title{Sync new items added in Daily up to Week/Month/Quarter} \usage{ -sync_from_daily(date = Sys.Date(), cfg = todo_config()) +sync_from_daily(date = Sys.Date(), cfg = todo_config(), + preview = .preview_default()) } \arguments{ \item{date}{A Date. Defaults to `Sys.Date()`.} \item{cfg}{A config list from `todo_config()`.} + +\item{preview}{If `TRUE`, return a `hacer_preview` instead of writing. +Defaults to the `HACER_PREVIEW=1` env var or `FALSE`.} } \description{ Sync new items added in Daily up to Week/Month/Quarter