#![allow(dead_code)]
use std::fmt::Write as FmtWrite;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use chrono::Utc;
use sha2::{Digest, Sha256};
use crate::error::BeadsError;
pub const ENV_RUNS_DIR: &str = "BR_DOCTOR_RUNS_DIR";
static RUN_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
const WINDOWS_ERROR_PRIVILEGE_NOT_HELD: i32 = 1314;
#[cfg(unix)]
fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
let resolved_target = link
.parent()
.map_or_else(|| target.to_path_buf(), |parent| parent.join(target));
if resolved_target.is_dir() {
std::os::windows::fs::symlink_dir(target, link)
} else {
std::os::windows::fs::symlink_file(target, link)
}
}
#[cfg(all(not(unix), not(windows)))]
fn create_symlink(_target: &Path, _link: &Path) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"doctor: symlink creation is not supported on this platform",
))
}
#[cfg(unix)]
fn make_executable(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
}
#[cfg(not(unix))]
fn make_executable(_path: &Path) -> std::io::Result<()> {
Ok(())
}
#[derive(Debug, Clone)]
pub struct RunDir {
pub run_id: String,
pub repo_root: PathBuf,
pub root: PathBuf,
pub backups: PathBuf,
pub actions_file: PathBuf,
pub report_file: PathBuf,
pub undo_script: PathBuf,
pub latest_link: PathBuf,
}
pub fn create_run_dir(repo_root: &Path) -> Result<RunDir, BeadsError> {
let (run, _actions_file) = create_run_dir_with_actions_file(repo_root)?;
Ok(run)
}
pub fn create_repair_run_dir(repo_root: &Path) -> Result<(RunDir, std::fs::File), BeadsError> {
create_run_dir_with_actions_file(repo_root)
}
fn create_run_dir_with_actions_file(
repo_root: &Path,
) -> Result<(RunDir, std::fs::File), BeadsError> {
if !repo_root.exists() {
return Err(BeadsError::internal(format!(
"doctor: repo_root {} does not exist",
repo_root.display()
)));
}
if std::env::var_os(ENV_RUNS_DIR).is_none() {
ensure_doctor_in_gitignore(repo_root)?;
}
let runs_root = runs_root_for(repo_root);
fs::create_dir_all(&runs_root).map_err(BeadsError::Io)?;
let run_id = generate_run_id(repo_root);
let root = runs_root.join(&run_id);
let backups = root.join("backups");
fs::create_dir_all(&backups).map_err(BeadsError::Io)?;
let actions_file = root.join("actions.jsonl");
let actions_handle = OpenOptions::new()
.create(true)
.append(true)
.open(&actions_file)
.map_err(BeadsError::Io)?;
let report_file = root.join("report.json");
if !report_file.exists() {
OpenOptions::new()
.create(true)
.append(true)
.open(&report_file)
.map_err(BeadsError::Io)?;
}
let undo_script = root.join("undo.sh");
let latest_link = runs_root.parent().unwrap_or(&runs_root).join("latest");
update_latest_symlink(&latest_link, &root)?;
Ok((
RunDir {
run_id,
repo_root: repo_root.to_path_buf(),
root,
backups,
actions_file,
report_file,
undo_script,
latest_link,
},
actions_handle,
))
}
fn runs_root_for(repo_root: &Path) -> PathBuf {
runs_root_with_override(repo_root, std::env::var_os(ENV_RUNS_DIR).map(PathBuf::from))
}
fn runs_root_with_override(repo_root: &Path, env_override: Option<PathBuf>) -> PathBuf {
if let Some(dir) = env_override {
return dir.join("runs");
}
repo_root.join(".doctor").join("runs")
}
fn generate_run_id(repo_root: &Path) -> String {
let now = Utc::now();
let iso = now.format("%Y%m%dT%H%M%SZ").to_string();
let nanos = now.timestamp_nanos_opt().unwrap_or_default();
let ordinal = RUN_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
let mut hasher = Sha256::new();
hasher.update(repo_root.to_string_lossy().as_bytes());
hasher.update(iso.as_bytes());
hasher.update(nanos.to_le_bytes());
hasher.update(std::process::id().to_le_bytes());
hasher.update(ordinal.to_le_bytes());
let mut short = String::with_capacity(6);
for byte in hasher.finalize().iter().take(3) {
write!(&mut short, "{byte:02x}").expect("writing to a String cannot fail");
}
format!("{iso}__{short}")
}
fn update_latest_symlink(latest_link: &Path, target: &Path) -> Result<(), BeadsError> {
if let Some(parent) = latest_link.parent() {
fs::create_dir_all(parent).map_err(BeadsError::Io)?;
}
let rel_target = target
.strip_prefix(latest_link.parent().unwrap_or(target))
.map(Path::to_path_buf)
.unwrap_or_else(|_| target.to_path_buf());
let tmp = latest_link.with_file_name(format!(
".latest.doctor-tmp.{}.{}",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
));
match fs::remove_file(&tmp) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(BeadsError::Io(e)),
}
if let Err(error) = create_symlink(&rel_target, &tmp) {
if cfg!(windows) && is_windows_symlink_privilege_error(&error) {
tracing::warn!(
path = %latest_link.display(),
"doctor: Windows symlink privilege unavailable; latest run remains discoverable by directory scan"
);
return Ok(());
}
return Err(BeadsError::Io(error));
}
fs::rename(&tmp, latest_link).map_err(BeadsError::Io)?;
fsync_dir(latest_link.parent().unwrap_or_else(|| Path::new(".")))?;
Ok(())
}
fn is_windows_symlink_privilege_error(error: &std::io::Error) -> bool {
error.raw_os_error() == Some(WINDOWS_ERROR_PRIVILEGE_NOT_HELD)
}
fn ensure_doctor_in_gitignore(repo_root: &Path) -> Result<(), BeadsError> {
let gitignore = repo_root.join(".gitignore");
let needle = ".doctor/";
let existing = match fs::read_to_string(&gitignore) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(BeadsError::Io(e)),
};
let already = existing.lines().any(|line| {
let trimmed = line.trim();
trimmed == needle || trimmed == ".doctor" || trimmed == "/.doctor/"
});
if already {
return Ok(());
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::symlink_metadata(&gitignore)
&& meta.file_type().is_file()
&& (meta.permissions().mode() & 0o200) == 0
{
tracing::warn!(
path = %gitignore.display(),
"doctor: repo-root .gitignore is not owner-writable; skipping .doctor/ ignore-rule addition (operator-locked, see permissions.root_gitignore)"
);
return Ok(());
}
}
let mut new_contents = existing;
if !new_contents.is_empty() && !new_contents.ends_with('\n') {
new_contents.push('\n');
}
new_contents.push_str("# br doctor per-run artifacts\n");
new_contents.push_str(needle);
new_contents.push('\n');
let parent = gitignore.parent().unwrap_or_else(|| Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(parent).map_err(BeadsError::Io)?;
tmp.write_all(new_contents.as_bytes())
.map_err(BeadsError::Io)?;
tmp.as_file().sync_data().map_err(BeadsError::Io)?;
tmp.persist(&gitignore)
.map_err(|e| BeadsError::Io(e.error))?;
fsync_dir(parent)?;
Ok(())
}
fn fsync_dir(dir: &Path) -> Result<(), BeadsError> {
crate::util::sync_directory_best_effort(dir).map_err(BeadsError::Io)
}
pub fn write_undo_sh(run: &RunDir) -> Result<(), BeadsError> {
let repo_root_assignment = undo_repo_root_assignment(run);
let script = format!(
r#"#!/usr/bin/env bash
# br doctor undo — pure-bash fallback for run {run_id}
#
# Replays {{actions_jsonl}} in reverse, restoring the verbatim backups
# under {{backups_dir}}. Requires: bash, jq, cp, mv.
#
# This script is generated by br; do NOT hand-edit unless the live br
# binary is broken.
set -euo pipefail
run_dir="$(cd "$(dirname "$0")" && pwd)"
actions="${{run_dir}}/actions.jsonl"
backups="${{run_dir}}/backups"
{repo_root_assignment}
if [[ ! -s "${{actions}}" ]]; then
echo "no actions.jsonl entries — nothing to undo" >&2
exit 0
fi
# Reverse the actions and replay each one.
tac "${{actions}}" | while read -r line; do
op=$(jq -r '.op' <<<"${{line}}")
rel=$(jq -r '.path' <<<"${{line}}")
rename_to=$(jq -r '.rename_to // empty' <<<"${{line}}")
case "${{op}}" in
write_file|append_file|chmod|symlink_atomic)
# Restore from backup if one exists.
backup="${{backups}}/${{rel}}"
target="${{repo_root}}/${{rel}}"
if [[ -e "${{backup}}" ]]; then
mkdir -p "$(dirname "${{target}}")"
cp -p "${{backup}}" "${{target}}"
fi
;;
rename)
# Rename op moved <rel> -> <rename_to>; reverse it.
rename_source="${{rename_to}}"
if [[ -n "${{rename_source}}" && "${{rename_source}}" != /* ]]; then
rename_source="${{repo_root}}/${{rename_source}}"
fi
if [[ -n "${{rename_source}}" && -e "${{rename_source}}" ]]; then
mkdir -p "$(dirname "${{repo_root}}/${{rel}}")"
mv "${{rename_source}}" "${{repo_root}}/${{rel}}"
fi
;;
db_exec|db_migrate)
echo "[warn] cannot undo ${{op}} from bash — re-run br doctor undo" >&2
;;
*)
echo "[warn] unknown op ${{op}}; skipping" >&2
;;
esac
done
echo "undo complete for run {run_id}" >&2
"#,
run_id = run.run_id,
repo_root_assignment = repo_root_assignment,
);
fs::write(&run.undo_script, script).map_err(BeadsError::Io)?;
make_executable(&run.undo_script).map_err(BeadsError::Io)?;
Ok(())
}
fn undo_repo_root_assignment(run: &RunDir) -> String {
let default_runs_root = run.repo_root.join(".doctor").join("runs");
if run.root.starts_with(default_runs_root) {
return r#"repo_root="$(cd "${run_dir}/../../.." && pwd)""#.to_string();
}
format!("repo_root={}", shell_single_quote_path(&run.repo_root))
}
fn shell_single_quote_path(path: &Path) -> String {
shell_single_quote(path.to_string_lossy().as_ref())
}
fn shell_single_quote(value: &str) -> String {
if value.is_empty() {
return "''".to_string();
}
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::collections::HashSet;
use std::os::unix::fs::PermissionsExt;
fn unique_temp_root(label: &str) -> tempfile::TempDir {
let prefix = format!("br-doctor-rundir-{label}-");
tempfile::Builder::new()
.prefix(prefix.as_str())
.tempdir()
.expect("tempdir")
}
#[test]
fn create_run_dir_produces_stable_run_id_format() {
let tmp = unique_temp_root("stable");
let run = create_run_dir(tmp.path()).expect("create_run_dir");
let parts: Vec<&str> = run.run_id.split("__").collect();
assert_eq!(parts.len(), 2, "run_id must split into ts__hash");
assert_eq!(parts[0].len(), 16, "iso ts must be 16 chars");
assert_eq!(parts[1].len(), 6, "short hash must be 6 hex");
assert!(parts[1].chars().all(|c| c.is_ascii_hexdigit()));
assert!(run.root.is_dir(), "root dir missing");
assert!(run.backups.is_dir(), "backups dir missing");
assert!(run.actions_file.is_file(), "actions.jsonl missing");
assert!(run.report_file.is_file(), "report.json placeholder missing");
let meta = fs::symlink_metadata(&run.latest_link).expect("latest");
assert!(meta.file_type().is_symlink(), "latest must be symlink");
let target = fs::read_link(&run.latest_link).unwrap();
assert!(
target.to_string_lossy().contains(&run.run_id),
"symlink target {} must contain run_id {}",
target.display(),
run.run_id
);
let gi = fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
assert!(gi.contains(".doctor/"));
}
#[test]
fn create_repair_run_dir_returns_initial_actions_handle() {
let tmp = unique_temp_root("initial-actions-handle");
let (run, mut actions_file) = create_repair_run_dir(tmp.path()).expect("create repair run");
writeln!(actions_file, "{{\"op\":\"test\"}}").expect("append audit line");
actions_file.sync_data().expect("sync audit line");
assert_eq!(
fs::read_to_string(&run.actions_file).expect("read actions"),
"{\"op\":\"test\"}\n"
);
}
#[test]
fn second_run_replaces_latest_atomically() {
let tmp = unique_temp_root("atomic");
let run1 = create_run_dir(tmp.path()).expect("first run");
std::thread::sleep(std::time::Duration::from_millis(1100));
let run2 = create_run_dir(tmp.path()).expect("second run");
assert_ne!(run1.run_id, run2.run_id);
let target = fs::read_link(&run2.latest_link).unwrap();
assert!(target.to_string_lossy().contains(&run2.run_id));
assert!(run1.root.is_dir());
}
#[test]
fn generated_run_ids_are_unique_inside_one_process_second() {
let tmp = unique_temp_root("same-second-ids");
let mut seen = HashSet::new();
for _ in 0..8 {
let run_id = generate_run_id(tmp.path());
assert!(seen.insert(run_id), "run_id collision inside one process");
}
}
#[test]
fn fsync_dir_accepts_existing_directory() {
let tmp = unique_temp_root("fsync-dir");
fsync_dir(tmp.path()).expect("fsync temp dir");
}
#[test]
fn windows_symlink_fallback_matches_only_privilege_error() {
let privilege_error = std::io::Error::from_raw_os_error(1314);
let ordinary_access_denied = std::io::Error::from_raw_os_error(5);
assert!(is_windows_symlink_privilege_error(&privilege_error));
assert!(!is_windows_symlink_privilege_error(&ordinary_access_denied));
}
#[test]
fn write_undo_sh_emits_executable_script() {
let tmp = unique_temp_root("undo");
let run = create_run_dir(tmp.path()).expect("create run");
write_undo_sh(&run).expect("write undo");
assert!(run.undo_script.is_file());
let meta = fs::metadata(&run.undo_script).unwrap();
assert_eq!(meta.permissions().mode() & 0o777, 0o755);
let body = fs::read_to_string(&run.undo_script).unwrap();
assert!(body.starts_with("#!/usr/bin/env bash"));
assert!(body.contains(&run.run_id));
assert!(
body.contains(r#"rename_source="${rename_to}""#),
"rename undo must normalize the recorded destination before mv"
);
assert!(
body.contains(r#"rename_source="${repo_root}/${rename_source}""#),
"relative rename destinations must be resolved from repo_root"
);
}
#[test]
fn write_undo_sh_keeps_relative_repo_root_for_default_layout() {
let tmp = unique_temp_root("undo-default-root");
let run = create_run_dir(tmp.path()).expect("create run");
write_undo_sh(&run).expect("write undo");
let body = fs::read_to_string(&run.undo_script).unwrap();
assert!(
body.contains(r#"repo_root="$(cd "${run_dir}/../../.." && pwd)""#),
"default in-repo run dirs should keep the move-tolerant repo_root derivation"
);
}
#[test]
fn write_undo_sh_embeds_repo_root_for_redirected_run_dir() {
let repo = unique_temp_root("undo-repo-root");
let redirected = unique_temp_root("undo-redirected");
let run_root = redirected.path().join("runs").join("run-one");
fs::create_dir_all(&run_root).expect("create redirected run root");
let run = RunDir {
run_id: "run-one".to_string(),
repo_root: repo.path().to_path_buf(),
root: run_root.clone(),
backups: run_root.join("backups"),
actions_file: run_root.join("actions.jsonl"),
report_file: run_root.join("report.json"),
undo_script: run_root.join("undo.sh"),
latest_link: redirected.path().join("latest"),
};
write_undo_sh(&run).expect("write undo");
let body = fs::read_to_string(&run.undo_script).unwrap();
assert!(
body.contains(&format!(
"repo_root={}",
shell_single_quote_path(repo.path())
)),
"redirected run dirs must restore against the original workspace root"
);
assert!(
!body.contains(r#"repo_root="$(cd "${run_dir}/../../.." && pwd)""#),
"redirected run dirs cannot infer repo_root from the artifact path"
);
}
#[test]
fn runs_root_with_override_redirects_runs_root() {
let outer = unique_temp_root("envouter");
let override_dir = unique_temp_root("envoverride");
let computed =
runs_root_with_override(outer.path(), Some(override_dir.path().to_path_buf()));
assert!(computed.starts_with(override_dir.path()));
assert!(computed.ends_with("runs"));
let fallback = runs_root_with_override(outer.path(), None);
assert_eq!(fallback, outer.path().join(".doctor").join("runs"));
}
#[test]
fn ensure_doctor_in_gitignore_is_noop_when_already_present() {
let tmp = unique_temp_root("noop-gitignore");
let gitignore = tmp.path().join(".gitignore");
let initial = "node_modules\n.doctor/\nbuild/\n";
fs::write(&gitignore, initial).expect("seed gitignore");
let pre_meta = fs::metadata(&gitignore).expect("pre meta");
let pre_mtime = pre_meta.modified().expect("pre mtime");
ensure_doctor_in_gitignore(tmp.path()).expect("ensure");
let post_bytes = fs::read_to_string(&gitignore).expect("read post");
assert_eq!(
post_bytes, initial,
"idempotent path must not rewrite the file"
);
let post_meta = fs::metadata(&gitignore).expect("post meta");
assert_eq!(
post_meta.modified().expect("post mtime"),
pre_mtime,
"idempotent path must not even touch the inode mtime"
);
}
#[test]
fn ensure_doctor_in_gitignore_rejects_non_file_gitignore() {
let tmp = unique_temp_root("bad-gitignore");
fs::create_dir(tmp.path().join(".gitignore")).expect("directory at .gitignore path");
let err = ensure_doctor_in_gitignore(tmp.path()).expect_err("directory is not a gitignore");
assert!(
err.to_string().contains(".gitignore") || err.to_string().contains("directory"),
"error should name the invalid gitignore surface: {err}"
);
}
#[cfg(unix)]
#[test]
fn ensure_doctor_in_gitignore_skips_readonly_gitignore() {
use std::os::unix::fs::PermissionsExt;
let tmp = unique_temp_root("readonly-gitignore");
let gitignore = tmp.path().join(".gitignore");
let initial = "node_modules\nbuild/\n";
fs::write(&gitignore, initial).expect("seed gitignore");
fs::set_permissions(&gitignore, fs::Permissions::from_mode(0o444))
.expect("lock gitignore read-only");
ensure_doctor_in_gitignore(tmp.path())
.expect("locked gitignore must be a best-effort skip, not an error");
let post = fs::read_to_string(&gitignore).expect("read post");
assert_eq!(
post, initial,
"read-only .gitignore must not be modified by the .doctor/ carveout"
);
let mode = fs::metadata(&gitignore)
.expect("post meta")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o444, "operator's read-only mode must be preserved");
assert!(
!post.contains(".doctor"),
".doctor/ must NOT have been appended to the locked file"
);
}
#[test]
fn create_run_dir_fails_before_artifacts_when_gitignore_invalid() {
let tmp = unique_temp_root("bad-gitignore-order");
fs::create_dir(tmp.path().join(".gitignore")).expect("directory at .gitignore path");
let err = create_run_dir(tmp.path()).expect_err("invalid gitignore must fail run setup");
assert!(
err.to_string().contains(".gitignore") || err.to_string().contains("directory"),
"error should name the invalid gitignore surface: {err}"
);
assert!(
!tmp.path().join(".doctor").exists(),
"failed run-dir setup must not leave unignored .doctor artifacts"
);
}
#[test]
fn create_run_dir_call_to_gitignore_is_gated_on_env_override() {
let src = include_str!("run_dir.rs");
let production_section = src
.split("\nmod tests {")
.next()
.expect("run_dir.rs must have a non-test section");
assert!(
production_section.contains("if std::env::var_os(ENV_RUNS_DIR).is_none() {"),
"create_run_dir's gitignore touch must be gated on \
BR_DOCTOR_RUNS_DIR being unset; if you removed that gate, \
update the chokepoint carveout doc on \
ensure_doctor_in_gitignore and rewrite this test."
);
assert!(
production_section.contains("ensure_doctor_in_gitignore(repo_root)?;"),
"create_run_dir must propagate gitignore update failures; \
otherwise its success contract can lie about `.doctor/` \
being ignored."
);
assert_eq!(
production_section
.matches("ensure_doctor_in_gitignore(")
.count(),
2,
"ensure_doctor_in_gitignore must have exactly two call sites in \
production code: its own definition and the gated call from \
create_run_dir. A third call is the contract violation \
beads_rust-dfjs warned about."
);
}
}