#![allow(clippy::option_if_let_else)]
use crate::cli::DoctorArgs;
use crate::cli::commands::doctor_subsystems::engine::{EngineBlock, engine_block};
use crate::cli::commands::doctor_subsystems::exit_codes::DoctorExitCode;
use crate::cli::commands::doctor_subsystems::mutate as chokepoint;
use crate::cli::commands::doctor_subsystems::mutate::{Capabilities, MutateContext, Op};
use crate::cli::commands::doctor_subsystems::refuse_gates::{self, GateOutcome};
use crate::cli::commands::doctor_subsystems::run_dir::{self, RunDir};
use crate::config;
use crate::error::{BeadsError, Result};
use crate::franken_sync::{Connection, Row};
use crate::health::{AnomalyClass, ReliabilityAuditRecord, WorkspaceClassification};
use crate::output::OutputContext;
use crate::storage::SqliteStorage;
use crate::storage::sqlite::PendingSyncMergeInspection;
#[cfg(test)]
use crate::sync::METADATA_SYNC_MERGE_PENDING_LEGACY;
use crate::sync::{
JsonlSourceSnapshot, JsonlTombstoneFilter, PathValidation, PreservedIssue,
SyncMergePendingPhase, SyncMergePendingReceipt, blocking_jsonl_family_write_lock_with_timeout,
capture_jsonl_source_snapshot, compute_staleness, dirty_issues_missing_from_jsonl,
restore_dirty_issues_after_rebuild, restore_tombstones_after_rebuild, scan_conflict_markers,
scan_conflict_markers_snapshot, scan_jsonl_snapshot_for_tombstone_filter,
snapshot_dirty_live_issues, snapshot_tombstones, tombstones_missing_from_jsonl_tombstones,
validate_jsonl_issue_records, validate_jsonl_snapshot_issue_records, validate_no_git_path,
validate_sync_path, validate_sync_path_with_external,
};
use chrono::{NaiveDate, Utc};
use fsqlite_error::FrankenError;
use fsqlite_types::SqliteValue;
use rich_rust::prelude::*;
use serde::Serialize;
use std::collections::BTreeSet;
use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
enum CheckStatus {
Ok,
Warn,
Error,
}
#[derive(Debug, Clone, Serialize)]
struct CheckResult {
name: String,
status: CheckStatus,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
details: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize)]
struct DoctorReport {
ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
workspace_health: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
reliability_audit: Option<ReliabilityAuditRecord>,
checks: Vec<CheckResult>,
}
#[derive(Debug, Clone, Serialize)]
struct DoctorRepairResult {
imported: usize,
skipped: usize,
fk_violations_cleaned: usize,
preserved_tombstones: usize,
preserved_dirty_issues: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
preserved_dirty_issue_ids: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
verified_backups: Vec<config::RecoveryBackupVerification>,
#[serde(skip_serializing_if = "Vec::is_empty")]
preserved_history: Vec<PreservedHistoryTableReport>,
#[serde(skip_serializing_if = "Vec::is_empty")]
history_preservation_warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
struct PreservedHistoryTableReport {
table: String,
restored: usize,
skipped: usize,
}
#[derive(Debug, Clone)]
struct DoctorRun {
report: DoctorReport,
jsonl_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PendingSyncMergeCondition {
Valid,
Legacy,
Malformed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PendingSyncMergeState {
pub condition: PendingSyncMergeCondition,
pub metadata_key: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub receipt_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phase: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expected_jsonl_issue_count: Option<usize>,
pub diagnostic: String,
}
impl PendingSyncMergeState {
#[must_use]
pub const fn condition_name(&self) -> &'static str {
match self.condition {
PendingSyncMergeCondition::Valid => "valid",
PendingSyncMergeCondition::Legacy => "legacy",
PendingSyncMergeCondition::Malformed => "malformed",
}
}
fn valid(receipt: &SyncMergePendingReceipt) -> Self {
let phase = match receipt.phase {
SyncMergePendingPhase::DatabaseCommitted => "database_committed",
SyncMergePendingPhase::ExportFinalized => "export_finalized",
};
Self {
condition: PendingSyncMergeCondition::Valid,
metadata_key: crate::sync::METADATA_SYNC_MERGE_PENDING.to_string(),
receipt_id: Some(receipt.receipt_id.clone()),
phase: Some(phase.to_string()),
resolution: Some(receipt.intent.resolution.clone()),
created_at: Some(receipt.created_at.clone()),
expected_jsonl_issue_count: Some(receipt.jsonl_after_issue_count),
diagnostic: "A committed sync merge still requires JSONL/base artifact reconciliation"
.to_string(),
}
}
fn legacy(metadata_key: String, row_count: usize, diagnostic: &str) -> Self {
Self {
condition: PendingSyncMergeCondition::Legacy,
metadata_key,
receipt_id: None,
phase: None,
resolution: None,
created_at: None,
expected_jsonl_issue_count: None,
diagnostic: format!("{diagnostic} ({row_count} metadata row(s))"),
}
}
fn malformed(metadata_key: impl Into<String>, diagnostic: String) -> Self {
Self {
condition: PendingSyncMergeCondition::Malformed,
metadata_key: metadata_key.into(),
receipt_id: None,
phase: None,
resolution: None,
created_at: None,
expected_jsonl_issue_count: None,
diagnostic,
}
}
fn from_inspection(inspection: PendingSyncMergeInspection) -> Option<Self> {
match inspection {
PendingSyncMergeInspection::Absent => None,
PendingSyncMergeInspection::Valid(receipt) => Some(Self::valid(&receipt)),
PendingSyncMergeInspection::Legacy {
metadata_key,
row_count,
diagnostic,
} => Some(Self::legacy(metadata_key, row_count, &diagnostic)),
PendingSyncMergeInspection::Malformed {
metadata_key,
diagnostic,
} => Some(Self::malformed(metadata_key, diagnostic)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DoctorInspectionMode {
Full,
Quick,
}
#[derive(Debug, Clone, Default, Serialize)]
struct LocalRepairResult {
blocked_cache_rebuilt: bool,
indexes_reindexed: bool,
vacuumed: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
quarantined_artifacts: Vec<String>,
}
struct DoctorRepairSession {
run: RunDir,
ctx: MutateContext,
}
impl DoctorRepairSession {
fn new(repo_root: &Path, dry_run: bool) -> Result<Self> {
let (run, actions_file) = run_dir::create_repair_run_dir(repo_root)?;
let mut capabilities = Capabilities::for_repo(repo_root);
capabilities.write_scopes.push(repo_root.join(".gitignore"));
let ctx = MutateContext {
run_id: run.run_id.clone(),
run_dir: run.root.clone(),
capabilities,
actions_file: Mutex::new(actions_file),
fixer_id: "doctor".to_string(),
repo_root: repo_root.to_path_buf(),
dry_run,
start_ns: now_ns_for_session(),
};
Ok(Self { run, ctx })
}
fn set_fixer(&mut self, fixer_id: &str) {
self.ctx.fixer_id = fixer_id.to_string();
}
fn record_legacy_mutation<F>(
&mut self,
fixer_id: &str,
paths: &[&Path],
legacy: F,
) -> Result<()>
where
F: FnOnce() -> Result<()>,
{
self.record_legacy_mutation_result(fixer_id, paths, legacy)
.map(|_| ())
}
fn record_legacy_mutation_result<T, F>(
&mut self,
fixer_id: &str,
paths: &[&Path],
legacy: F,
) -> Result<Option<T>>
where
F: FnOnce() -> Result<T>,
{
let prior_fixer = std::mem::replace(&mut self.ctx.fixer_id, fixer_id.to_string());
let mut legacy_output = None;
let result = chokepoint::record_legacy_op(&self.ctx, fixer_id, paths, || {
legacy_output = Some(legacy()?);
Ok(())
});
self.ctx.fixer_id = prior_fixer;
result.map(|()| legacy_output)
}
}
#[allow(dead_code)] #[derive(Debug, Clone, Default)]
pub(crate) struct FixerFilter {
only: Vec<String>,
skip: Vec<String>,
}
impl FixerFilter {
pub(crate) fn from_args(only: &[String], skip: &[String]) -> Self {
Self {
only: only
.iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
skip: skip
.iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
}
}
#[must_use]
pub(crate) fn allows(&self, fm_id: &str) -> bool {
if !self.only.is_empty() && !self.only.iter().any(|s| s == fm_id) {
return false;
}
if self.skip.iter().any(|s| s == fm_id) {
return false;
}
true
}
#[must_use]
pub(crate) fn has_only(&self) -> bool {
!self.only.is_empty()
}
#[must_use]
pub(crate) fn has_skip(&self) -> bool {
!self.skip.is_empty()
}
}
const FM_BLOCKED_CACHE_STALE: &str = "fm-caches_indexes-blocked-cache-stale";
const FM_PARTIAL_INDEX_STALE: &str = "fm-caches_indexes-partial-index-stale";
const FM_JSONL_ROW_COUNT_MISMATCH: &str = "fm-state_files-jsonl-row-count-mismatch";
const FM_EMPTY_OR_TRUNCATED_DATABASE: &str = "fm-state_files-empty-or-truncated-database";
const FM_SQLITE_PAGE_MALFORMED: &str = "fm-state_files-sqlite-page-malformed";
const FM_WAL_SHM_SIDECAR_ORPHAN: &str = "fm-state_files-wal-shm-sidecar-orphan";
const FM_MISSING_REQUIRED_TABLE: &str = "fm-schemas-missing-required-table";
const FM_MISSING_REQUIRED_COLUMN: &str = "fm-schemas-missing-required-column";
const JSONL_REBUILD_FILTERED_REASON: &str =
"JSONL rebuild filtered out by --only/--skip (no rebuild-addressed FM allowed)";
fn now_ns_for_session() -> u128 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
}
#[derive(Debug, Clone, Serialize)]
struct RecoveryAuditRecord {
phase: String,
action: String,
outcome: String,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
applied_actions: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
quarantined_artifacts: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
verified_backups: Vec<config::RecoveryBackupVerification>,
#[serde(skip_serializing_if = "Option::is_none")]
imported: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
skipped: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
fk_violations_cleaned: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PriorJsonlRebuildFailureEvidence {
path: PathBuf,
artifact_count: usize,
}
const BLOCKED_CACHE_STALE_FINDING: &str = "blocked_issues_cache is marked stale and needs rebuild";
const BLOCKED_CACHE_CONTENT_MISMATCH_FINDING: &str =
"blocked_issues_cache content differs from direct dependency graph and needs rebuild";
const READY_PROJECTION_CONTENT_MISMATCH_FINDING: &str =
"ready projection content differs from direct dependency graph and needs rebuild";
const JSONL_REBUILD_AUTHORITY_ERROR_PREFIX: &str = "Cannot repair: JSONL authority is unsafe";
const JSONL_REBUILD_REPEAT_ERROR_PREFIX: &str =
"Cannot repair: previous JSONL rebuild verification failed";
const JSONL_REBUILD_DRY_RUN_SKIP_MESSAGE: &str =
"doctor --repair --dry-run skipped JSONL rebuild; no database writes were applied";
const JSONL_REBUILD_VERIFICATION_FAILED_SUFFIX: &str = ".verification-failed.json";
const ROOT_GITIGNORE_OFFENDING_PATTERNS: &[&str] = &[
".beads",
".beads/",
".beads/*",
".beads/**",
".beads/.gitignore",
"/.beads",
"/.beads/",
"/.beads/*",
"/.beads/**",
"/.beads/.gitignore",
];
const ROOT_GITIGNORE_REPAIR_MESSAGE: &str =
"Removed offending .beads ignore pattern(s) from root .gitignore";
const NO_OP_REPAIR_MESSAGE: &str = "No errors detected; nothing to repair.";
const REINDEX_INCOMPLETE_MESSAGE: &str = "REINDEX was attempted but did not complete.";
fn is_quick_suppressed_doctor_check(name: &str) -> bool {
matches!(
name,
"db.recoverable_anomalies"
| "counts.db_vs_jsonl"
| "sync.metadata"
| "sqlite.cli_integrity"
| "sqlite3.integrity_check"
| "db.write_probe"
)
}
#[derive(Debug, Default)]
struct SidecarInspection {
findings: Vec<String>,
informational_findings: Vec<String>,
quarantine_candidates: Vec<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FilesystemPathKind {
Missing,
File,
Directory,
Symlink,
Other,
}
impl LocalRepairResult {
fn applied(&self) -> bool {
self.blocked_cache_rebuilt
|| self.indexes_reindexed
|| self.vacuumed
|| !self.quarantined_artifacts.is_empty()
}
}
fn local_repair_applied_actions(repair: &LocalRepairResult) -> Vec<String> {
let mut actions = Vec::new();
if repair.blocked_cache_rebuilt {
actions.push("blocked_cache_rebuilt".to_string());
}
if repair.indexes_reindexed {
actions.push("indexes_reindexed".to_string());
}
if repair.vacuumed {
actions.push("vacuumed".to_string());
}
if !repair.quarantined_artifacts.is_empty() {
actions.push("quarantined_artifacts".to_string());
}
actions
}
fn local_repair_audit_record(
phase: &str,
outcome: &str,
repair: &LocalRepairResult,
reason: Option<String>,
) -> RecoveryAuditRecord {
RecoveryAuditRecord {
phase: phase.to_string(),
action: "local_repair".to_string(),
outcome: outcome.to_string(),
reason,
applied_actions: local_repair_applied_actions(repair),
quarantined_artifacts: repair.quarantined_artifacts.clone(),
verified_backups: Vec::new(),
imported: None,
skipped: None,
fk_violations_cleaned: None,
}
}
fn jsonl_rebuild_audit_record(
phase: &str,
outcome: &str,
repair: Option<&DoctorRepairResult>,
reason: Option<String>,
) -> RecoveryAuditRecord {
RecoveryAuditRecord {
phase: phase.to_string(),
action: "jsonl_rebuild".to_string(),
outcome: outcome.to_string(),
reason,
applied_actions: Vec::new(),
quarantined_artifacts: Vec::new(),
verified_backups: repair.map_or_else(Vec::new, |result| result.verified_backups.clone()),
imported: repair.map(|result| result.imported),
skipped: repair.map(|result| result.skipped),
fk_violations_cleaned: repair.map(|result| result.fk_violations_cleaned),
}
}
fn emit_recovery_audit_record(record: &RecoveryAuditRecord) {
let applied_actions = record.applied_actions.join(",");
tracing::info!(
target: "br::reliability",
phase = %record.phase,
action = %record.action,
outcome = %record.outcome,
reason = record.reason.as_deref().unwrap_or(""),
applied_actions = %applied_actions,
quarantined_artifacts = record.quarantined_artifacts.len(),
verified_backups = record.verified_backups.len(),
verified_backup_details = ?record.verified_backups,
imported = record.imported.unwrap_or(0),
skipped = record.skipped.unwrap_or(0),
fk_violations_cleaned = record.fk_violations_cleaned.unwrap_or(0),
"doctor recovery audit record"
);
}
fn emit_concurrency_lost(beads_dir: &Path, err: &BeadsError, ctx: &OutputContext, operation: &str) {
let lock_path = beads_dir.join(".write.lock");
let detail = err.to_string();
let recovery_audit = RecoveryAuditRecord {
phase: "doctor.concurrency".to_string(),
action: "acquire_workspace_write_lock".to_string(),
outcome: "refused".to_string(),
reason: Some(format!(
"workspace write lock at {} is held by another process: {detail}",
lock_path.display()
)),
applied_actions: Vec::new(),
quarantined_artifacts: Vec::new(),
verified_backups: Vec::new(),
imported: None,
skipped: None,
fk_violations_cleaned: None,
};
emit_recovery_audit_record(&recovery_audit);
if ctx.is_json() {
ctx.json(&serde_json::json!({
"ok": false,
"exit_code": DoctorExitCode::ConcurrencyLost.as_i32(),
"code": DoctorExitCode::ConcurrencyLost.as_str(),
"message": format!(
"Refusing {operation}: workspace write lock at {} is held by another process",
lock_path.display()
),
"detail": detail,
"lock_path": lock_path.display().to_string(),
"recovery_audit": recovery_audit,
}));
} else {
ctx.error(&format!(
"Refusing {operation}: workspace write lock at {} is held by another process. \
Wait for the other br invocation to finish or pass --lock-timeout to wait longer. \
Underlying error: {detail}",
lock_path.display()
));
}
}
fn emit_refused_unsafe(
operation: &str,
reason: &str,
evidence: &serde_json::Value,
ctx: &OutputContext,
) {
let gate_name = evidence
.get("gate")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown")
.to_string();
let recovery_audit = RecoveryAuditRecord {
phase: "doctor.refuse_gate".to_string(),
action: format!("gate:{gate_name}"),
outcome: "refused".to_string(),
reason: Some(reason.to_string()),
applied_actions: Vec::new(),
quarantined_artifacts: Vec::new(),
verified_backups: Vec::new(),
imported: None,
skipped: None,
fk_violations_cleaned: None,
};
emit_recovery_audit_record(&recovery_audit);
if ctx.is_json() {
ctx.json(&serde_json::json!({
"ok": false,
"exit_code": DoctorExitCode::RefusedUnsafe.as_i32(),
"code": DoctorExitCode::RefusedUnsafe.as_str(),
"message": reason,
"gate": gate_name,
"evidence": evidence,
"recovery_audit": recovery_audit,
}));
} else {
ctx.error(&format!(
"Refusing {operation}: {reason} (gate={gate_name})"
));
}
}
fn refuse_doctor_mutation_if_merge_pending(
operation: &str,
db_path: &Path,
authority: &Arc<crate::sync::DatabaseFamilyWriteLock>,
ctx: &OutputContext,
) {
let refusal = match inspect_pending_sync_merge_under_authority(db_path, authority) {
Ok(None) => return,
Ok(Some(state)) => {
let reason = format!(
"{}; only `br sync --merge` may reconcile this state before generic doctor mutation",
state.diagnostic
);
let evidence = serde_json::json!({
"gate": "sync.merge_pending",
"finding": "sync.merge_pending",
"pending": true,
"state": state,
"database_path": db_path.display().to_string(),
"remediation": "Run `br sync --merge`, verify that it clears the pending receipt, then rerun the requested doctor operation."
});
(reason, evidence)
}
Err(error) => {
let reason = format!(
"could not prove that no sync merge is pending ({error}); doctor mutation fails closed"
);
let evidence = serde_json::json!({
"gate": "sync.merge_pending",
"finding": "sync.merge_pending",
"pending": "unknown",
"database_path": db_path.display().to_string(),
"inspection_error": error.to_string(),
"remediation": "Restore read-only access to the database family and rerun `br doctor` before attempting repair."
});
(reason, evidence)
}
};
emit_refused_unsafe(operation, &refusal.0, &refusal.1, ctx);
crate::shutdown::exit_process(DoctorExitCode::RefusedUnsafe.as_i32());
}
impl FilesystemPathKind {
fn exists(self) -> bool {
!matches!(self, Self::Missing)
}
fn is_regular_file(self) -> bool {
matches!(self, Self::File)
}
fn description(self) -> &'static str {
match self {
Self::Missing => "missing",
Self::File => "regular file",
Self::Directory => "directory",
Self::Symlink => "symlink",
Self::Other => "special filesystem entry",
}
}
}
fn push_check(
checks: &mut Vec<CheckResult>,
name: &str,
status: CheckStatus,
message: Option<String>,
details: Option<serde_json::Value>,
) {
let details_with_fm = match finding_id_for(name) {
Some(fm) => Some(inject_finding_id(details, fm)),
None => details,
};
checks.push(CheckResult {
name: name.to_string(),
status,
message,
details: details_with_fm,
});
}
fn inject_finding_id(details: Option<serde_json::Value>, fm: &'static str) -> serde_json::Value {
use serde_json::Value;
match details {
None => serde_json::json!({ "finding_id": fm }),
Some(Value::Object(mut map)) => {
map.entry("finding_id".to_string())
.or_insert(Value::String(fm.to_string()));
Value::Object(map)
}
Some(other) => serde_json::json!({
"finding_id": fm,
"data": other,
}),
}
}
pub(crate) const CHECK_NAME_TO_FINDING_ID: &[(&str, &str)] = &[
("jsonl.parse", "fm-state_files-jsonl-malformed-utf8"),
("sync.merge_pending", "fm-state_files-sync-merge-pending"),
(
"jsonl.merge_artifacts",
"fm-state_files-merge-artifact-stuck",
),
("base_jsonl", "fm-state_files-base-jsonl-missing-or-stale"),
(
"base_jsonl.missing_post_flush",
"fm-state_files-base-jsonl-missing-or-stale",
),
("dirty_bitmap", "fm-caches_indexes-dirty-bitmap-divergence"),
(
"doctor.runs_dir",
"fm-observability-doctor-runs-dir-grows-unbounded",
),
(
"doctor.runs_creatable",
"fm-permissions-doctor-runs-not-creatable",
),
(
"permissions.recovery_dir",
"fm-permissions-recovery-dir-not-writable",
),
(
"permissions.write_lock",
"fm-state_files-orphaned-write-lock",
),
(
"permissions.root_gitignore",
"fm-permissions-gitignore-not-writable-blocks-repair",
),
(
"permissions.db_sidecars",
"fm-permissions-db-sidecar-mode-too-open",
),
("jsonl.duplicate_ids", "fm-state_files-jsonl-duplicate-ids"),
("comments.orphans", "fm-caches_indexes-comments-orphans"),
("labels.orphans", "fm-caches_indexes-labels-orphans"),
(
"dependencies.orphans",
"fm-caches_indexes-dependencies-orphans",
),
(
"permissions.config_yaml_secrets",
"fm-permissions-config-yaml-mode-leaks-secrets",
),
("br_path_dupes", "fm-external_artifacts-multiple-br-in-path"),
(
"gitignore.beads_inner_present",
"fm-configs-gitignore-leaking-beads",
),
(
"permissions.jsonl_world_writable",
"fm-permissions-jsonl-world-writable",
),
("tmp_files_orphan", "fm-state_files-orphan-tmp-files"),
("jsonl_size", "fm-state_files-jsonl-oversized"),
(
"br_history.size",
"fm-state_files-br-history-grows-unbounded",
),
(
"jsonl_eof_newline",
"fm-state_files-jsonl-missing-trailing-newline",
),
("jsonl_crlf", "fm-state_files-jsonl-crlf-line-endings"),
("jsonl_bom", "fm-state_files-jsonl-utf8-bom-prefix"),
("db_bloat", "fm-caches_indexes-db-bloat-vs-jsonl"),
("wal_size", "fm-state_files-wal-oversized"),
("startup_cache.health", "fm-configs-startup-cache-poisoned"),
("sync_jsonl_path", FM_JSONL_ROW_COUNT_MISMATCH),
(
"sync_conflict_markers",
"fm-state_files-jsonl-conflict-markers",
),
("db.exists", FM_EMPTY_OR_TRUNCATED_DATABASE),
("db.open", FM_SQLITE_PAGE_MALFORMED),
("db.sidecars", FM_WAL_SHM_SIDECAR_ORPHAN),
(
"db.read_only_open_observational",
"fm-state_files-read-only-open-not-observational",
),
(
"db.recovery_artifacts",
"fm-state_files-recovery-artifacts-orphaned",
),
(
"db.recovery_artifacts.aged",
"fm-state_files-recovery-artifacts-orphaned",
),
(
"db.foreign_recovery_debris",
"fm-state_files-recovery-artifacts-orphaned",
),
(
"db.export_hash_cache",
"fm-caches_indexes-export-hash-cache-divergence",
),
("db.recoverable_anomalies", FM_BLOCKED_CACHE_STALE),
("counts.db_vs_jsonl", FM_JSONL_ROW_COUNT_MISMATCH),
("sync.metadata", "fm-state_files-dirty-flag-divergence"),
("sqlite.integrity_check", FM_SQLITE_PAGE_MALFORMED),
("sqlite3.integrity_check", FM_SQLITE_PAGE_MALFORMED),
("db.write_probe", FM_SQLITE_PAGE_MALFORMED),
("db.null_defaults", "fm-schemas-missing-required-column"),
("schema.tables", FM_MISSING_REQUIRED_TABLE),
("schema.columns", FM_MISSING_REQUIRED_COLUMN),
("schema.inspect", "fm-schemas-issue-column-order-divergence"),
("beads_dir", "fm-configs-metadata-json-stale"),
("metadata", "fm-configs-metadata-json-stale"),
("metadata.json", "fm-configs-metadata-json-stale"),
(
"gitignore.beads_inner",
"fm-configs-gitignore-leaking-beads",
),
("gitignore.root", "fm-configs-gitignore-leaking-beads"),
("config.yaml", "fm-configs-yaml-malformed"),
("config.unknown_keys", "fm-configs-unknown-keys"),
(
"audit.suspect_close_reasons",
"fm-agent_coordination-suspect-close-reason",
),
(
"policy.workflow_statuses",
"fm-agent_coordination-workflow-status-out-of-set",
),
("routes_jsonl", "fm-routes_external-routes-jsonl-corrupt"),
("routes.targets", "fm-routes_external-route-target-missing"),
("rust_log", "fm-observability-rust-log-noisy-breaks-json"),
("permissions.beads_dir", "fm-permissions-beads-dir-readonly"),
(
"binary_version",
"fm-external_artifacts-binary-version-mismatch",
),
(
"write_lock",
"fm-concurrency_primitives-orphaned-write-lock",
),
(
"db.no_db_mode",
"fm-state_files-no-db-mode-db-checks-skipped",
),
(
"dep.dead_closed_blocking_edges",
"fm-dependencies-dead-closed-blocking-edges",
),
(
"dep.fully_unblocked_open",
"fm-dependencies-fully-unblocked-open-issues",
),
];
#[must_use]
#[allow(dead_code)] fn finding_id_for(check_name: &str) -> Option<&'static str> {
CHECK_NAME_TO_FINDING_ID
.iter()
.find(|(name, _)| *name == check_name)
.map(|(_, fm_id)| *fm_id)
}
fn has_error(checks: &[CheckResult]) -> bool {
checks
.iter()
.any(|check| matches!(check.status, CheckStatus::Error))
}
fn has_non_ok(checks: &[CheckResult]) -> bool {
checks
.iter()
.any(|check| !matches!(check.status, CheckStatus::Ok))
}
pub fn inspect_pending_sync_merge_at_path(db_path: &Path) -> Result<Option<PendingSyncMergeState>> {
match fs::symlink_metadata(db_path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(BeadsError::SyncConflict {
message: format!(
"Cannot inspect pending sync-merge state because database path '{}' is a symlink",
db_path.display()
),
});
}
Ok(metadata) if !metadata.is_file() => {
return Err(BeadsError::SyncConflict {
message: format!(
"Cannot inspect pending sync-merge state because database path '{}' is not a regular file",
db_path.display()
),
});
}
Ok(_) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Err(BeadsError::SyncConflict {
message: format!(
"Pending sync-merge state is unknown because database '{}' is missing",
db_path.display()
),
});
}
Err(error) => {
return Err(BeadsError::WithContext {
context: format!(
"Failed to inspect database path '{}' before checking pending sync-merge state",
db_path.display()
),
source: Box::new(error),
});
}
}
let Some(storage) = SqliteStorage::open_current_read_only(db_path)? else {
if SqliteStorage::namespace_sidecars_need_mode_repair(db_path)? {
return Err(BeadsError::SyncConflict {
message: format!(
"Pending sync-merge state is unknown because an fsqlite namespace sidecar beside database '{}' is group/other accessible; the repair to owner-only (0600) runs once the database-family authority is held",
db_path.display()
),
});
}
return Err(BeadsError::SyncConflict {
message: format!(
"Pending sync-merge state is unknown because database '{}' does not have the current supported schema",
db_path.display()
),
});
};
Ok(PendingSyncMergeState::from_inspection(
storage.inspect_pending_sync_merge()?,
))
}
pub fn inspect_pending_sync_merge_under_authority(
db_path: &Path,
authority: &Arc<crate::sync::DatabaseFamilyWriteLock>,
) -> Result<Option<PendingSyncMergeState>> {
Ok(PendingSyncMergeState::from_inspection(
SqliteStorage::inspect_pending_sync_merge_under_authority(db_path, authority)?,
))
}
fn check_pending_sync_merge(db_path: &Path, checks: &mut Vec<CheckResult>) {
match inspect_pending_sync_merge_at_path(db_path) {
Ok(None) => push_check(
checks,
"sync.merge_pending",
CheckStatus::Ok,
Some("No pending sync merge requires artifact reconciliation".to_string()),
None,
),
Ok(Some(state)) => {
let status = if state.condition == PendingSyncMergeCondition::Valid {
CheckStatus::Warn
} else {
CheckStatus::Error
};
let condition = state.condition_name();
push_check(
checks,
"sync.merge_pending",
status,
Some(format!(
"Pending sync merge state is {condition}; generic repair and non-merge mutations are disabled"
)),
Some(serde_json::json!({
"pending": true,
"state": state,
"remediation": "Run `br sync --merge` to validate and resume the exact pending merge. Do not run generic repair, import, flush, or tracker mutation commands first."
})),
);
}
Err(error) => push_check(
checks,
"sync.merge_pending",
CheckStatus::Error,
Some(format!(
"Could not prove that no sync merge is pending: {error}"
)),
Some(serde_json::json!({
"pending": "unknown",
"database_path": db_path.display().to_string(),
"remediation": "Restore read-only access to the database family, then rerun `br doctor`. Mutating commands must remain disabled until pending merge state can be inspected."
})),
),
}
}
fn push_anomaly(anomalies: &mut Vec<AnomalyClass>, anomaly: AnomalyClass) {
if !anomalies.contains(&anomaly) {
anomalies.push(anomaly);
}
}
fn check_message(check: &CheckResult) -> String {
check.message.clone().unwrap_or_else(|| check.name.clone())
}
fn check_findings(check: &CheckResult) -> Vec<String> {
check
.details
.as_ref()
.and_then(|details| details.get("findings"))
.and_then(serde_json::Value::as_array)
.map(|findings| {
findings
.iter()
.filter_map(serde_json::Value::as_str)
.map(ToString::to_string)
.collect()
})
.unwrap_or_else(|| check.message.iter().cloned().collect())
}
fn blocked_cache_rebuild_finding(finding: &str) -> bool {
finding.contains(BLOCKED_CACHE_STALE_FINDING)
|| finding.contains(BLOCKED_CACHE_CONTENT_MISMATCH_FINDING)
|| finding.contains(READY_PROJECTION_CONTENT_MISMATCH_FINDING)
}
fn parse_duplicate_identifier_and_count(finding: &str, marker: &str) -> Option<(String, i64)> {
let (_, tail) = finding.split_once(marker)?;
let (identifier, count_tail) = tail.split_once("' (")?;
let count_text = count_tail
.strip_suffix(" rows)")
.or_else(|| count_tail.strip_suffix(" row)"))?;
let count = count_text.parse().ok()?;
Some((identifier.to_string(), count))
}
fn append_recoverable_anomaly_findings(check: &CheckResult, anomalies: &mut Vec<AnomalyClass>) {
for finding in check_findings(check) {
if finding.contains("sqlite_master contains duplicate") {
let (name, count) = parse_duplicate_identifier_and_count(&finding, " entries for '")
.unwrap_or_else(|| ("unknown".to_string(), 2));
push_anomaly(anomalies, AnomalyClass::DuplicateSchemaRows { name, count });
} else if finding.contains("config contains duplicate rows") {
let (key, count) = parse_duplicate_identifier_and_count(&finding, " rows for key '")
.unwrap_or_else(|| ("unknown".to_string(), 2));
push_anomaly(anomalies, AnomalyClass::DuplicateConfigKeys { key, count });
} else if finding.contains("metadata contains duplicate rows") {
let (key, count) = parse_duplicate_identifier_and_count(&finding, " rows for key '")
.unwrap_or_else(|| ("unknown".to_string(), 2));
push_anomaly(
anomalies,
AnomalyClass::DuplicateMetadataKeys { key, count },
);
} else if finding.contains(BLOCKED_CACHE_STALE_FINDING) {
push_anomaly(anomalies, AnomalyClass::BlockedCacheStale);
} else if finding.contains(BLOCKED_CACHE_CONTENT_MISMATCH_FINDING) {
push_anomaly(anomalies, AnomalyClass::BlockedCacheContentMismatch);
} else if finding.contains(READY_PROJECTION_CONTENT_MISMATCH_FINDING) {
push_anomaly(anomalies, AnomalyClass::ReadyProjectionContentMismatch);
}
}
}
fn append_null_default_anomalies(check: &CheckResult, anomalies: &mut Vec<AnomalyClass>) {
let Some(findings) = check
.details
.as_ref()
.and_then(|details| details.get("findings"))
.and_then(serde_json::Value::as_array)
else {
return;
};
for finding in findings {
let table = finding
.get("table")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown");
let column = finding
.get("column")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown");
push_anomaly(
anomalies,
AnomalyClass::NullInNotNullColumn {
table: table.to_string(),
column: column.to_string(),
},
);
}
}
fn append_count_mismatch_anomaly(check: &CheckResult, anomalies: &mut Vec<AnomalyClass>) {
let Some(details) = check.details.as_ref() else {
return;
};
let Some(db_count) = details.get("db").and_then(serde_json::Value::as_i64) else {
return;
};
let Some(jsonl_count) = details.get("jsonl").and_then(serde_json::Value::as_u64) else {
return;
};
let Ok(db_count) = usize::try_from(db_count) else {
return;
};
let Ok(jsonl_count) = usize::try_from(jsonl_count) else {
return;
};
if db_count != jsonl_count {
push_anomaly(
anomalies,
AnomalyClass::DbJsonlCountMismatch {
db_count,
jsonl_count,
},
);
}
if let Some(id_delta) = details.get("id_delta") {
let only_db = id_delta
.get("only_db")
.and_then(serde_json::Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let only_jsonl = id_delta
.get("only_jsonl")
.and_then(serde_json::Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let only_db_count = id_delta
.get("only_db_count")
.and_then(serde_json::Value::as_u64)
.and_then(|n| usize::try_from(n).ok())
.unwrap_or(only_db.len());
let only_jsonl_count = id_delta
.get("only_jsonl_count")
.and_then(serde_json::Value::as_u64)
.and_then(|n| usize::try_from(n).ok())
.unwrap_or(only_jsonl.len());
let both_count = id_delta
.get("both_count")
.and_then(serde_json::Value::as_u64)
.and_then(|n| usize::try_from(n).ok())
.unwrap_or(0);
if only_db_count > 0 || only_jsonl_count > 0 {
push_anomaly(
anomalies,
AnomalyClass::DbJsonlIdSetMismatch {
only_db_count,
only_jsonl_count,
only_db,
only_jsonl,
both_count,
},
);
}
}
}
fn sidecar_presence_from_check(check: &CheckResult) -> (bool, bool) {
let findings = check
.details
.as_ref()
.and_then(|details| details.get("findings"))
.and_then(serde_json::Value::as_array);
if let Some(findings) = findings {
let has_wal = findings
.iter()
.filter_map(serde_json::Value::as_str)
.any(|finding| finding.starts_with("WAL sidecar"));
let has_shm = findings
.iter()
.filter_map(serde_json::Value::as_str)
.any(|finding| finding.starts_with("SHM sidecar"));
if has_wal || has_shm {
return (has_wal, has_shm);
}
}
let message = check.message.as_deref().unwrap_or_default().trim_start();
(
message.starts_with("WAL sidecar"),
message.starts_with("SHM sidecar"),
)
}
fn append_sync_metadata_anomalies(check: &CheckResult, anomalies: &mut Vec<AnomalyClass>) {
let message = check.message.as_deref().unwrap_or_default();
let pending_import = check
.details
.as_ref()
.and_then(|details| details.get("pending_import"))
.and_then(serde_json::Value::as_bool)
.unwrap_or_else(|| {
message.contains("External changes pending import")
|| message.contains("Database and JSONL have diverged")
});
let pending_export = check
.details
.as_ref()
.and_then(|details| details.get("pending_export"))
.and_then(serde_json::Value::as_bool)
.unwrap_or_else(|| {
message.contains("Local changes pending export")
|| message.contains("Local changes exist but no export is recorded")
|| message.contains("Database and JSONL have diverged")
});
if pending_import {
push_anomaly(anomalies, AnomalyClass::JsonlNewer);
}
if pending_export {
push_anomaly(anomalies, AnomalyClass::DbNewer);
}
}
fn append_doctor_check_anomalies(check: &CheckResult, anomalies: &mut Vec<AnomalyClass>) {
match check.name.as_str() {
"db.exists" if matches!(check.status, CheckStatus::Error) => {
push_anomaly(anomalies, AnomalyClass::DatabaseMissing);
}
"db.open"
| "schema.tables"
| "schema.columns"
| "sqlite.integrity_check"
| "sqlite3.integrity_check"
if matches!(check.status, CheckStatus::Error) =>
{
push_anomaly(
anomalies,
AnomalyClass::DatabaseCorrupt {
detail: check_message(check),
},
);
}
"sqlite.integrity_check" | "sqlite3.integrity_check"
if is_repairable_integrity_warning_check(check) =>
{
push_anomaly(
anomalies,
AnomalyClass::DatabaseCorrupt {
detail: check_message(check),
},
);
}
"jsonl.parse" if matches!(check.status, CheckStatus::Error) => {
push_anomaly(
anomalies,
AnomalyClass::JsonlParseError {
detail: check_message(check),
},
);
}
"sync_conflict_markers" if matches!(check.status, CheckStatus::Error) => {
push_anomaly(anomalies, AnomalyClass::JsonlConflictMarkers);
}
"counts.db_vs_jsonl" if matches!(check.status, CheckStatus::Warn) => {
append_count_mismatch_anomaly(check, anomalies);
}
"sync.metadata" => {
append_sync_metadata_anomalies(check, anomalies);
}
"db.recovery_artifacts.aged" if matches!(check.status, CheckStatus::Warn) => {
push_anomaly(anomalies, AnomalyClass::StaleRecoveryArtifacts);
}
"db.sidecars" if matches!(check.status, CheckStatus::Error) => {
let message = check.message.as_deref().unwrap_or_default();
if message.contains("rollback journal") {
push_anomaly(anomalies, AnomalyClass::JournalSidecarPresent);
} else {
let (has_wal, has_shm) = sidecar_presence_from_check(check);
push_anomaly(
anomalies,
AnomalyClass::SidecarMismatch { has_wal, has_shm },
);
}
}
"db.recoverable_anomalies"
if matches!(check.status, CheckStatus::Error | CheckStatus::Warn) =>
{
append_recoverable_anomaly_findings(check, anomalies);
}
"db.null_defaults" if matches!(check.status, CheckStatus::Warn) => {
append_null_default_anomalies(check, anomalies);
}
"db.write_probe" if matches!(check.status, CheckStatus::Error) => {
push_anomaly(
anomalies,
AnomalyClass::WriteProbeFailed {
detail: check_message(check),
},
);
}
_ => {}
}
}
fn classify_doctor_checks(
db_path: &Path,
jsonl_path: &Path,
checks: &[CheckResult],
) -> WorkspaceClassification {
let mut anomalies = crate::health::classify_file_state(db_path, jsonl_path);
for check in checks {
append_doctor_check_anomalies(check, &mut anomalies);
}
WorkspaceClassification::from_anomalies(anomalies)
}
fn emit_doctor_reliability_audit(
phase: &str,
report_ok: bool,
audit: &ReliabilityAuditRecord,
checks: &[CheckResult],
) {
let warning_count = checks
.iter()
.filter(|check| matches!(check.status, CheckStatus::Warn))
.count();
let error_count = checks
.iter()
.filter(|check| matches!(check.status, CheckStatus::Error))
.count();
audit.emit_tracing(phase, if report_ok { "ok" } else { "findings" });
tracing::info!(
target: "br::reliability",
phase,
ok = report_ok,
workspace_health = %audit.health,
anomaly_count = audit.anomaly_count,
warning_count,
error_count,
"doctor check summary"
);
}
#[cfg(test)]
fn report_has_blocked_cache_stale_finding(report: &DoctorReport) -> bool {
report_has_blocked_cache_finding(report, |message| {
message.contains(BLOCKED_CACHE_STALE_FINDING)
})
}
fn report_has_blocked_cache_rebuild_finding(report: &DoctorReport) -> bool {
report_has_blocked_cache_finding(report, blocked_cache_rebuild_finding)
}
fn report_has_projection_content_mismatch_finding(report: &DoctorReport) -> bool {
report_has_blocked_cache_finding(report, |message| {
message.contains(BLOCKED_CACHE_CONTENT_MISMATCH_FINDING)
|| message.contains(READY_PROJECTION_CONTENT_MISMATCH_FINDING)
})
}
fn report_has_blocked_cache_finding(
report: &DoctorReport,
predicate: impl Fn(&str) -> bool + Copy,
) -> bool {
report.checks.iter().any(|check| {
if check.name != "db.recoverable_anomalies" {
return false;
}
if check.message.as_deref().is_some_and(predicate) {
return true;
}
check
.details
.as_ref()
.and_then(|details| details.get("findings"))
.and_then(serde_json::Value::as_array)
.is_some_and(|findings| {
findings
.iter()
.any(|finding| finding.as_str().is_some_and(predicate))
})
})
}
fn report_has_sidecar_anomaly(report: &DoctorReport) -> bool {
report
.checks
.iter()
.any(|check| check.name == "db.sidecars" && matches!(check.status, CheckStatus::Error))
}
fn filter_allows_recoverable_db_state_repair(
filter: &FixerFilter,
has_blocked_cache_rebuild: bool,
has_sidecar_anomaly: bool,
) -> bool {
(has_blocked_cache_rebuild && filter.allows(FM_BLOCKED_CACHE_STALE))
|| (has_sidecar_anomaly && filter.allows(FM_WAL_SHM_SIDECAR_ORPHAN))
}
fn filter_allows_jsonl_rebuild(filter: &FixerFilter) -> bool {
[
FM_JSONL_ROW_COUNT_MISMATCH,
FM_EMPTY_OR_TRUNCATED_DATABASE,
FM_SQLITE_PAGE_MALFORMED,
FM_MISSING_REQUIRED_TABLE,
FM_MISSING_REQUIRED_COLUMN,
FM_BLOCKED_CACHE_STALE,
]
.iter()
.any(|fm| filter.allows(fm))
}
fn report_has_page_corruption(report: &DoctorReport) -> bool {
report.checks.iter().any(|check| {
if !matches!(check.status, CheckStatus::Error) {
return false;
}
if check.name != "sqlite.integrity_check" && check.name != "sqlite3.integrity_check" {
return false;
}
check.message.as_deref().is_some_and(|msg| {
let lower = msg.to_lowercase();
lower.contains("free space corruption")
|| lower.contains("malformed")
|| lower.contains("disk image")
})
})
}
fn acquire_doctor_database_write_authority(
beads_dir: &Path,
db_path: &Path,
lock_timeout: Option<u64>,
) -> Result<Arc<crate::sync::DatabaseFamilyWriteLock>> {
let write_authority = Arc::new(
crate::sync::blocking_database_family_write_lock_with_timeout(
beads_dir,
db_path,
lock_timeout,
)?,
);
write_authority.bind_database_inode_for_mutation()?;
write_authority.verify_database_authority()?;
Ok(write_authority)
}
fn open_doctor_storage_under_write_authority(
db_path: &Path,
write_authority: &Arc<crate::sync::DatabaseFamilyWriteLock>,
) -> Result<SqliteStorage> {
write_authority.verify_database_authority()?;
let mut storage = SqliteStorage::open(db_path)?;
write_authority.verify_database_authority()?;
storage.attach_write_authority(Arc::clone(write_authority));
Ok(storage)
}
fn repair_via_vacuum(
db_path: &Path,
repair: &mut LocalRepairResult,
session: Option<&mut DoctorRepairSession>,
write_authority: &Arc<crate::sync::DatabaseFamilyWriteLock>,
) {
if !db_path.is_file() {
tracing::debug!(
path = %db_path.display(),
"Skipping VACUUM because the database file is missing"
);
return;
}
let do_vacuum = |repair: &mut LocalRepairResult| {
if let Err(err) = write_authority.verify_database_authority() {
tracing::warn!(
path = %db_path.display(),
error = %err,
"Skipping VACUUM because database-family authority was lost"
);
return;
}
match open_doctor_storage_under_write_authority(db_path, write_authority) {
Ok(storage) => {
if let Err(err) = storage.execute_raw("VACUUM") {
tracing::warn!(path = %db_path.display(), error = %err, "VACUUM failed");
return;
}
repair.vacuumed = true;
match config::compact_database_via_vacuum_into_in_place(storage, db_path, None) {
Ok(_storage) => {
tracing::info!(
path = %db_path.display(),
"VACUUM plus VACUUM INTO compaction completed successfully"
);
}
Err(err) => {
tracing::warn!(
path = %db_path.display(),
error = %err,
"VACUUM INTO compaction failed after VACUUM"
);
}
}
}
Err(err) => {
tracing::warn!(
path = %db_path.display(),
error = %err,
"Skipping VACUUM because the database could not be opened"
);
}
}
};
if let Some(session) = session {
let family_paths = existing_sqlite_family_paths_for_legacy_op(db_path);
let family_refs: Vec<&Path> = family_paths.iter().map(PathBuf::as_path).collect();
let result = session.record_legacy_mutation("repair_via_vacuum", &family_refs, || {
do_vacuum(repair);
Ok(())
});
if let Err(err) = result {
tracing::warn!(
path = %db_path.display(),
error = %err,
"Failed to record VACUUM legacy-op audit; mutation still proceeded if possible"
);
}
} else {
do_vacuum(repair);
}
}
fn write_probe_after_repair(
db_path: &Path,
write_authority: &Arc<crate::sync::DatabaseFamilyWriteLock>,
) -> bool {
if write_authority.verify_database_authority().is_err() {
return false;
}
let Ok(conn) = Connection::open(db_path.to_string_lossy().into_owned()) else {
return false;
};
let _ = conn.execute("PRAGMA busy_timeout=5000");
let probe_id = "__doctor_write_probe__";
let now = chrono::Utc::now().to_rfc3339();
let probe = (|| -> std::result::Result<(), Box<dyn std::error::Error>> {
write_authority.verify_database_authority()?;
conn.execute("BEGIN IMMEDIATE")?;
conn.execute_with_params(
"INSERT OR REPLACE INTO issues (id, title, status, priority, created_at, updated_at) \
VALUES (?, ?, 'open', 2, ?, ?)",
&[
SqliteValue::from(probe_id),
SqliteValue::from("doctor write probe"),
SqliteValue::from(now.as_str()),
SqliteValue::from(now.as_str()),
],
)?;
let rows = conn.query_with_params(
"WITH target(id_value) AS (SELECT ?) \
SELECT i.id FROM issues AS i, target AS t \
WHERE i.id = t.id_value",
&[SqliteValue::from(probe_id)],
)?;
if rows.is_empty() {
conn.execute("ROLLBACK")?;
write_authority.verify_database_authority()?;
tracing::warn!("Write probe: INSERT succeeded but SELECT returned no rows");
return Err("read-after-write divergence".into());
}
conn.execute("ROLLBACK")?;
write_authority.verify_database_authority()?;
Ok(())
})();
let probe_ok = match probe {
Ok(()) => {
tracing::info!("Post-repair write probe passed");
true
}
Err(err) => {
tracing::warn!(error = %err, "Post-repair write probe failed — DB may still be corrupt");
let _ = conn.execute("ROLLBACK");
false
}
};
if let Err(err) = conn.close() {
tracing::warn!(error = %err, "Post-repair write probe connection close failed");
return false;
}
probe_ok && write_authority.verify_database_authority().is_ok()
}
fn is_warn_level_page_anomaly_check(check: &CheckResult) -> bool {
if !matches!(check.status, CheckStatus::Warn) {
return false;
}
if check.name != "sqlite.integrity_check" && check.name != "sqlite3.integrity_check" {
return false;
}
check.message.as_deref().is_some_and(|msg| {
let lower = msg.to_lowercase();
lower.contains("never used")
|| lower.contains("free space corruption")
|| lower.contains("malformed")
|| lower.contains("disk image")
})
}
fn report_has_warn_level_page_anomaly(report: &DoctorReport) -> bool {
report.checks.iter().any(is_warn_level_page_anomaly_check)
}
fn repair_report_verified(report: &DoctorReport) -> bool {
report.ok && !has_non_ok(&report.checks)
}
fn is_pending_export_only_sync_metadata(check: &CheckResult) -> bool {
if check.name != "sync.metadata" || !matches!(check.status, CheckStatus::Warn) {
return false;
}
let detail_flag = |key: &str| {
check
.details
.as_ref()
.and_then(|details| details.get(key))
.and_then(serde_json::Value::as_bool)
};
match (detail_flag("pending_export"), detail_flag("pending_import")) {
(Some(pending_export), Some(pending_import)) => pending_export && !pending_import,
_ => false,
}
}
fn is_benign_post_rebuild_finding(check: &CheckResult) -> bool {
if !matches!(check.status, CheckStatus::Warn) {
return false;
}
match check.name.as_str() {
"db.recovery_artifacts" | "rust_log" | "br_path_dupes" => true,
"sync.metadata" => is_pending_export_only_sync_metadata(check),
_ => false,
}
}
fn jsonl_rebuild_repair_verified(
report: &DoctorReport,
preserved_dirty_issue_ids: &[String],
) -> bool {
report.ok
&& report.checks.iter().all(|check| {
matches!(check.status, CheckStatus::Ok)
|| is_benign_post_rebuild_finding(check)
|| is_preserved_dirty_count_divergence(check, preserved_dirty_issue_ids)
})
}
fn is_preserved_dirty_count_divergence(
check: &CheckResult,
preserved_dirty_issue_ids: &[String],
) -> bool {
if preserved_dirty_issue_ids.is_empty()
|| check.name != "counts.db_vs_jsonl"
|| !matches!(check.status, CheckStatus::Warn)
{
return false;
}
let Some(delta) = check
.details
.as_ref()
.and_then(|details| details.get("id_delta"))
else {
return false;
};
if delta
.get("only_jsonl_count")
.and_then(serde_json::Value::as_u64)
!= Some(0)
{
return false;
}
let Some(only_db_count) = delta
.get("only_db_count")
.and_then(serde_json::Value::as_u64)
.and_then(|count| usize::try_from(count).ok())
else {
return false;
};
let Some(only_db) = delta.get("only_db").and_then(serde_json::Value::as_array) else {
return false;
};
if only_db.len() != only_db_count {
return false;
}
only_db.iter().all(|id| {
id.as_str()
.is_some_and(|id| preserved_dirty_issue_ids.iter().any(|kept| kept == id))
})
}
fn is_partial_index_warning_check(check: &CheckResult) -> bool {
if !matches!(check.status, CheckStatus::Warn) {
return false;
}
if check.name != "sqlite.integrity_check" && check.name != "sqlite3.integrity_check" {
return false;
}
check
.message
.as_deref()
.is_some_and(|msg| msg.to_lowercase().contains("missing from index"))
}
fn report_has_partial_index_warnings(report: &DoctorReport) -> bool {
report.checks.iter().any(is_partial_index_warning_check)
}
fn is_repairable_integrity_warning_check(check: &CheckResult) -> bool {
is_warn_level_page_anomaly_check(check) || is_partial_index_warning_check(check)
}
fn warning_repair_verified(
report: &DoctorReport,
repaired_blocked_cache: bool,
repaired_partial_index_warnings: bool,
) -> bool {
report.ok
&& (!repaired_blocked_cache || !report_has_blocked_cache_rebuild_finding(report))
&& (!repaired_partial_index_warnings || !report_has_partial_index_warnings(report))
&& !report_has_warn_level_page_anomaly(report)
}
fn local_repair_message(local_repair: &LocalRepairResult) -> String {
let mut actions = Vec::new();
if local_repair.blocked_cache_rebuilt {
actions.push("rebuilt the blocked cache".to_string());
}
if local_repair.indexes_reindexed {
actions.push("rebuilt all indexes via REINDEX".to_string());
}
if local_repair.vacuumed {
actions.push("compacted database via VACUUM to fix page-level anomalies".to_string());
}
if !local_repair.quarantined_artifacts.is_empty() {
actions.push(format!(
"quarantined {} anomalous database artifact(s)",
local_repair.quarantined_artifacts.len()
));
}
if actions.is_empty() {
"No remaining errors detected after recoverable-state repair.".to_string()
} else {
format!("Repair complete: {}.", actions.join("; "))
}
}
fn is_offending_root_gitignore_pattern(line: &str) -> bool {
let trimmed = line.trim();
!trimmed.is_empty()
&& !trimmed.starts_with('#')
&& !trimmed.starts_with('!')
&& ROOT_GITIGNORE_OFFENDING_PATTERNS.contains(&trimmed)
}
fn repair_outcome_message_from_parts(
mut messages: Vec<String>,
local_repair: Option<&LocalRepairResult>,
incomplete_attempt_message: Option<&str>,
) -> String {
if let Some(repair) = local_repair {
if repair.applied() {
messages.push(local_repair_message(repair));
} else if let Some(message) = incomplete_attempt_message {
messages.push(message.to_string());
}
}
if messages.is_empty() {
NO_OP_REPAIR_MESSAGE.to_string()
} else {
messages.join(" ")
}
}
#[derive(Debug, Clone, Copy)]
struct EarlyRepairSummary {
gitignore: bool,
merge_artifacts: bool,
startup_cache: bool,
recovery_aged: bool,
export_hash: bool,
base_jsonl_symlink: bool,
base_jsonl_stale: bool,
orphan_tmp: bool,
jsonl_eof_newline: bool,
jsonl_bom: bool,
jsonl_crlf: bool,
jsonl_world_writable: bool,
config_yaml_secret_mode: bool,
inner_gitignore: bool,
dirty_bitmap_orphans: bool,
comments_orphans: bool,
labels_orphans: bool,
dependencies_orphans: bool,
wal_checkpoint: bool,
null_defaults: bool,
db_bloat_vacuum: bool,
}
impl EarlyRepairSummary {
fn applied(self) -> bool {
self.gitignore
|| self.merge_artifacts
|| self.startup_cache
|| self.recovery_aged
|| self.export_hash
|| self.base_jsonl_symlink
|| self.base_jsonl_stale
|| self.orphan_tmp
|| self.jsonl_eof_newline
|| self.jsonl_bom
|| self.jsonl_crlf
|| self.jsonl_world_writable
|| self.config_yaml_secret_mode
|| self.inner_gitignore
|| self.dirty_bitmap_orphans
|| self.comments_orphans
|| self.labels_orphans
|| self.dependencies_orphans
|| self.wal_checkpoint
|| self.null_defaults
|| self.db_bloat_vacuum
}
fn action_labels(self) -> Vec<String> {
let mut actions = Vec::new();
if self.gitignore {
actions.push("gitignore_repaired".to_string());
}
if self.merge_artifacts {
actions.push("merge_artifacts_quarantined".to_string());
}
if self.startup_cache {
actions.push("startup_cache_quarantined".to_string());
}
if self.recovery_aged {
actions.push("recovery_artifacts_aged_quarantined".to_string());
}
if self.export_hash {
actions.push("export_hash_cache_recomputed".to_string());
}
if self.base_jsonl_symlink {
actions.push("base_jsonl_symlink_quarantined".to_string());
}
if self.base_jsonl_stale {
actions.push("base_jsonl_anchor_regenerated".to_string());
}
if self.orphan_tmp {
actions.push("orphan_tmp_quarantined".to_string());
}
if self.jsonl_eof_newline {
actions.push("jsonl_trailing_newline_appended".to_string());
}
if self.jsonl_bom {
actions.push("jsonl_bom_stripped".to_string());
}
if self.jsonl_crlf {
actions.push("jsonl_crlf_converted".to_string());
}
if self.jsonl_world_writable {
actions.push("jsonl_world_write_stripped".to_string());
}
if self.config_yaml_secret_mode {
actions.push("config_yaml_secret_mode_chmod".to_string());
}
if self.inner_gitignore {
actions.push("inner_gitignore_appended".to_string());
}
if self.dirty_bitmap_orphans {
actions.push("dirty_bitmap_orphans_pruned".to_string());
}
if self.comments_orphans {
actions.push("comments_orphans_pruned".to_string());
}
if self.labels_orphans {
actions.push("labels_orphans_pruned".to_string());
}
if self.dependencies_orphans {
actions.push("dependencies_orphans_pruned".to_string());
}
if self.wal_checkpoint {
actions.push("wal_checkpoint_truncated".to_string());
}
if self.null_defaults {
actions.push("null_defaults_backfilled".to_string());
}
if self.db_bloat_vacuum {
actions.push("db_bloat_vacuumed".to_string());
}
actions
}
fn messages(self) -> Vec<String> {
let mut messages = Vec::new();
if self.gitignore {
messages.push(ROOT_GITIGNORE_REPAIR_MESSAGE.to_string());
}
if self.merge_artifacts {
messages.push("Quarantined stuck merge artifacts.".to_string());
}
if self.startup_cache {
messages.push("Quarantined poisoned startup-cache files.".to_string());
}
if self.recovery_aged {
messages.push("Quarantined aged recovery artifacts.".to_string());
}
if self.export_hash {
messages.push("Recomputed metadata.jsonl_content_hash.".to_string());
}
if self.base_jsonl_symlink {
messages.push("Quarantined symlinked merge anchor.".to_string());
}
if self.base_jsonl_stale {
messages.push("Regenerated stale merge anchor from current JSONL.".to_string());
}
if self.orphan_tmp {
messages.push("Quarantined orphan tmp files.".to_string());
}
if self.jsonl_eof_newline {
messages.push(
"Appended missing trailing newline to the selected JSONL export.".to_string(),
);
}
if self.jsonl_bom {
messages.push("Stripped UTF-8 BOM from the selected JSONL export.".to_string());
}
if self.jsonl_crlf {
messages.push(
"Converted CRLF line endings to LF in the selected JSONL export.".to_string(),
);
}
if self.jsonl_world_writable {
messages.push("Stripped world-write bit from the selected JSONL export.".to_string());
}
if self.config_yaml_secret_mode {
messages.push(
"Stripped world-read/write bits from `.beads/config.yaml` (contains secrets)."
.to_string(),
);
}
if self.inner_gitignore {
messages.push("Appended canonical patterns to `.beads/.gitignore`.".to_string());
}
if self.dirty_bitmap_orphans {
messages.push("Pruned orphan rows from dirty_issues table.".to_string());
}
if self.comments_orphans {
messages.push("Pruned orphan rows from comments table.".to_string());
}
if self.labels_orphans {
messages.push("Pruned orphan rows from labels table.".to_string());
}
if self.dependencies_orphans {
messages.push("Pruned orphan rows from dependencies table.".to_string());
}
if self.wal_checkpoint {
messages.push("Truncated the SQLite WAL via PRAGMA wal_checkpoint.".to_string());
}
if self.null_defaults {
messages.push(
"Backfilled schema-declared defaults into NULL NOT-NULL columns.".to_string(),
);
}
if self.db_bloat_vacuum {
messages.push(
"Compacted database via VACUUM to reclaim freelist space (--unsafe-auto-fix opt-in)."
.to_string(),
);
}
messages
}
fn audit_record(self) -> RecoveryAuditRecord {
let applied_actions = self.action_labels();
let outcome = match applied_actions.as_slice() {
[] => "nothing_to_repair".to_string(),
[action] => action.clone(),
_ => "repairs_applied".to_string(),
};
let phase = if applied_actions.is_empty() {
"doctor.noop"
} else {
"doctor.early_repair"
};
RecoveryAuditRecord {
phase: phase.to_string(),
action: "repair".to_string(),
outcome,
reason: None,
applied_actions,
quarantined_artifacts: Vec::new(),
verified_backups: Vec::new(),
imported: None,
skipped: None,
fk_violations_cleaned: None,
}
}
fn prepend_actions_to_audit(self, mut record: RecoveryAuditRecord) -> RecoveryAuditRecord {
let mut early_actions = self.action_labels();
if early_actions.is_empty() {
return record;
}
early_actions.append(&mut record.applied_actions);
record.applied_actions = early_actions;
record
}
}
fn classify_path_kind(path: &Path) -> Result<FilesystemPathKind> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => Ok(FilesystemPathKind::Symlink),
Ok(metadata) if metadata.is_file() => Ok(FilesystemPathKind::File),
Ok(metadata) if metadata.is_dir() => Ok(FilesystemPathKind::Directory),
Ok(_) => Ok(FilesystemPathKind::Other),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(FilesystemPathKind::Missing),
Err(err) => Err(err.into()),
}
}
fn database_sidecar_paths(db_path: &Path) -> [(PathBuf, &'static str); 3] {
let db_string = db_path.to_string_lossy();
[
(PathBuf::from(format!("{db_string}-wal")), "WAL"),
(PathBuf::from(format!("{db_string}-shm")), "SHM"),
(
PathBuf::from(format!("{db_string}-journal")),
"rollback journal",
),
]
}
fn inspect_database_sidecars(db_path: &Path) -> Result<SidecarInspection> {
let db_kind = classify_path_kind(db_path)?;
let mut inspection = SidecarInspection::default();
let mut wal_kind = FilesystemPathKind::Missing;
let mut shm_kind = FilesystemPathKind::Missing;
for (path, label) in database_sidecar_paths(db_path) {
let kind = classify_path_kind(&path)?;
match label {
"WAL" => wal_kind = kind,
"SHM" => shm_kind = kind,
_ => {}
}
if kind.exists() && !db_kind.is_regular_file() {
inspection.quarantine_candidates.push(path.clone());
}
if kind.exists() && !kind.is_regular_file() {
inspection.findings.push(format!(
"{label} sidecar at {} is a {} instead of a regular file",
path.display(),
kind.description()
));
inspection.quarantine_candidates.push(path);
}
}
if wal_kind.is_regular_file() && !shm_kind.exists() {
inspection.informational_findings.push(format!(
"WAL sidecar exists without a matching SHM sidecar at {} (expected for frankensqlite)",
PathBuf::from(format!("{}-wal", db_path.to_string_lossy())).display()
));
}
if shm_kind.is_regular_file() && !wal_kind.exists() {
let shm_path = PathBuf::from(format!("{}-shm", db_path.to_string_lossy()));
inspection.findings.push(format!(
"SHM sidecar exists without a matching WAL sidecar at {}",
shm_path.display()
));
inspection.quarantine_candidates.push(shm_path);
}
if shm_kind.is_regular_file() && wal_kind.is_regular_file() {
inspection.informational_findings.push(format!(
"SHM sidecar at {} is inert beside the WAL (a WAL-only family is expected for frankensqlite; the WAL index lives in process memory)",
PathBuf::from(format!("{}-shm", db_path.to_string_lossy())).display()
));
}
if !db_kind.is_regular_file() {
let has_dangling_sidecars = database_sidecar_paths(db_path)
.into_iter()
.any(|(path, _)| classify_path_kind(&path).is_ok_and(FilesystemPathKind::exists));
if has_dangling_sidecars {
inspection.findings.push(format!(
"Database sidecars exist even though the primary database at {} is a {}",
db_path.display(),
db_kind.description()
));
}
}
inspection.quarantine_candidates.sort();
inspection.quarantine_candidates.dedup();
Ok(inspection)
}
fn check_database_sidecars(db_path: &Path, checks: &mut Vec<CheckResult>) -> Result<()> {
let inspection = inspect_database_sidecars(db_path)?;
if !inspection.findings.is_empty() {
push_check(
checks,
"db.sidecars",
CheckStatus::Error,
Some(inspection.findings[0].clone()),
Some(serde_json::json!({
"findings": inspection.findings,
"quarantine_candidates": inspection
.quarantine_candidates
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>(),
})),
);
return Ok(());
}
if !inspection.informational_findings.is_empty() {
push_check(
checks,
"db.sidecars",
CheckStatus::Ok,
Some(inspection.informational_findings[0].clone()),
Some(serde_json::json!({
"findings": inspection.informational_findings,
})),
);
return Ok(());
}
push_check(checks, "db.sidecars", CheckStatus::Ok, None, None);
Ok(())
}
#[cfg(any(unix, windows))]
fn namespace_database_identity(db_path: &Path) -> Result<fsqlite_vfs::FileIdentity> {
if !fs::symlink_metadata(db_path)?.is_file() {
return Err(BeadsError::Config(
"Database pathname is not a regular, non-symlink file".to_string(),
));
}
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
}
let file = options.open(db_path)?;
if !file.metadata()?.is_file() || !fs::symlink_metadata(db_path)?.is_file() {
return Err(BeadsError::Config(
"Database pathname changed during identity inspection".to_string(),
));
}
fsqlite_vfs::FileIdentity::from_file(&file)?.ok_or_else(|| {
BeadsError::Config("Database descriptor identity is unavailable".to_string())
})
}
#[cfg(any(unix, windows))]
struct NamespaceIdentityObservation {
recorded: fsqlite_vfs::FileIdentity,
current: fsqlite_vfs::FileIdentity,
}
#[cfg(any(unix, windows))]
fn inspect_namespace_identity(db_path: &Path) -> Result<Option<NamespaceIdentityObservation>> {
use fsqlite_vfs::{NamespaceOpenIntent, PendingNamespaceOpen};
let paths = fsqlite_namespace_sidecar_paths(db_path);
let mut present = 0;
for path in &paths {
match fs::symlink_metadata(path) {
Ok(_) => present += 1,
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
if present == 0 {
return Ok(None);
}
if present != paths.len() {
return Err(BeadsError::Config(
"Namespace sidecar pair is incomplete; identity cannot be established".to_string(),
));
}
let admission = PendingNamespaceOpen::begin(db_path, NamespaceOpenIntent::ReadOnlyExisting)?;
let expected = admission.expected_identity().ok_or_else(|| {
BeadsError::Config("Namespace record has no established identity".to_string())
})?;
let current = namespace_database_identity(db_path)?;
if namespace_database_identity(db_path)? != current {
return Err(BeadsError::Config(
"Database pathname changed during identity inspection; retry after writers stop"
.to_string(),
));
}
Ok(Some(NamespaceIdentityObservation {
recorded: expected,
current,
}))
}
fn check_namespace_identity(db_path: &Path, checks: &mut Vec<CheckResult>) -> bool {
const NAME: &str = "db.namespace_identity";
#[cfg(any(unix, windows))]
{
let sidecars = fsqlite_namespace_sidecar_paths(db_path);
match inspect_namespace_identity(db_path) {
Ok(Some(identity)) => {
let matched = identity.recorded == identity.current;
push_check(
checks,
NAME,
if matched { CheckStatus::Ok } else { CheckStatus::Error },
(!matched).then(|| "Database identity differs from its persistent namespace sidecars (foreign sidecar family). Preserve the main database and every sidecar, including WAL and journal files: they may contain committed data for the displaced database. Stop all writers, capture `br doctor --bundle <path> --include-db`, and validate the authoritative family before operator-directed recovery. No automatic repair is offered.".to_string()),
Some(serde_json::json!({
"state": if matched { "matched" } else { "mismatch" },
"database": db_path, "sidecars": sidecars,
"identities_match": matched,
"automatic_repair": false,
})),
);
matched
}
Ok(None) => {
push_check(
checks,
NAME,
CheckStatus::Ok,
Some(
"No persistent namespace sidecars; identity comparison is not applicable"
.to_string(),
),
Some(serde_json::json!({ "state": "not_applicable", "database": db_path })),
);
true
}
Err(error) => {
push_check(
checks,
NAME,
CheckStatus::Warn,
Some(format!(
"Namespace identity evidence is unavailable: {error}. This does not establish a foreign sidecar family; preserve all database-family files."
)),
Some(serde_json::json!({
"state": "unavailable", "database": db_path, "sidecars": sidecars,
"reason": error.to_string(), "automatic_repair": false,
})),
);
false
}
}
}
#[cfg(not(any(unix, windows)))]
{
push_check(
checks,
NAME,
CheckStatus::Warn,
Some("Namespace identity inspection is unavailable on this platform".to_string()),
Some(serde_json::json!({ "state": "unavailable", "database": db_path })),
);
false
}
}
fn check_recovery_artifacts(
beads_dir: &Path,
db_path: &Path,
checks: &mut Vec<CheckResult>,
) -> Result<()> {
let artifacts = recovery_artifacts_for_db_family(beads_dir, db_path)?
.into_iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>();
if artifacts.is_empty() {
push_check(checks, "db.recovery_artifacts", CheckStatus::Ok, None, None);
} else {
push_check(
checks,
"db.recovery_artifacts",
CheckStatus::Warn,
Some(format!(
"Preserved recovery artifacts remain for this database family ({} item(s))",
artifacts.len()
)),
Some(serde_json::json!({ "artifacts": artifacts })),
);
}
Ok(())
}
fn check_recovery_artifacts_aged(
beads_dir: &Path,
db_path: &Path,
checks: &mut Vec<CheckResult>,
) -> Result<()> {
let aged = recovery_artifacts_aged(beads_dir, db_path)?;
if aged.is_empty() {
push_check(
checks,
"db.recovery_artifacts.aged",
CheckStatus::Ok,
None,
None,
);
return Ok(());
}
let display: Vec<String> = aged.iter().map(|p| p.display().to_string()).collect();
push_check(
checks,
"db.recovery_artifacts.aged",
CheckStatus::Warn,
Some(format!(
"{} recovery artifact(s) older than {} days — eligible for quarantine via --repair",
aged.len(),
RECOVERY_AGED_TTL_DAYS
)),
Some(serde_json::json!({
"artifacts": display,
"ttl_days": RECOVERY_AGED_TTL_DAYS,
})),
);
Ok(())
}
fn db_family_prefix(db_path: &Path) -> &str {
db_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("beads.db")
}
fn recovery_artifacts_for_db_family(beads_dir: &Path, db_path: &Path) -> Result<Vec<PathBuf>> {
let recovery_dir = config::recovery_dir_for_db_path(db_path, beads_dir);
let db_prefix = db_family_prefix(db_path);
let db_parent = db_path.parent().unwrap_or(beads_dir);
let mut artifacts = Vec::new();
if recovery_dir.is_dir() {
for entry in fs::read_dir(&recovery_dir)? {
let entry = entry?;
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with(db_prefix) {
artifacts.push(entry.path());
}
}
}
for entry in fs::read_dir(db_parent)? {
let entry = entry?;
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with(&format!("{db_prefix}.bad_")) {
artifacts.push(entry.path());
}
}
artifacts.sort();
artifacts.dedup();
Ok(artifacts)
}
const RECOVERY_AGED_TTL_DAYS: u64 = 30;
const FOREIGN_DEBRIS_SCAN_LIMIT: usize = 4096;
fn is_foreign_recovery_debris_dir_name(name: &str) -> bool {
if name.starts_with(".br_") {
return false;
}
name.starts_with("recovery") || name.starts_with("snapshot") || name.starts_with(".beads_snap")
}
fn is_foreign_recovery_debris_file_name(name: &str, db_prefix: &str) -> bool {
name.starts_with(&format!("{db_prefix}.rebuild_"))
}
#[derive(Debug, Default)]
struct ForeignRecoveryDebris {
directories: Vec<PathBuf>,
files: Vec<PathBuf>,
bytes: u64,
truncated: bool,
}
impl ForeignRecoveryDebris {
fn is_empty(&self) -> bool {
self.directories.is_empty() && self.files.is_empty()
}
}
fn bounded_tree_bytes(root: &Path, remaining: &mut usize) -> (u64, bool) {
let mut bytes = 0_u64;
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
if *remaining == 0 {
return (bytes, true);
}
*remaining -= 1;
match entry.file_type() {
Ok(ft) if ft.is_dir() => stack.push(entry.path()),
Ok(ft) if ft.is_file() => {
bytes = bytes.saturating_add(entry.metadata().map_or(0, |m| m.len()));
}
_ => {}
}
}
}
(bytes, false)
}
fn scan_foreign_recovery_debris(beads_dir: &Path, db_path: &Path) -> Result<ForeignRecoveryDebris> {
let db_prefix = db_family_prefix(db_path);
let mut found = ForeignRecoveryDebris::default();
let mut budget = FOREIGN_DEBRIS_SCAN_LIMIT;
if !beads_dir.is_dir() {
return Ok(found);
}
for entry in fs::read_dir(beads_dir)?.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() && is_foreign_recovery_debris_dir_name(&name) {
let (bytes, truncated) = bounded_tree_bytes(&entry.path(), &mut budget);
found.bytes = found.bytes.saturating_add(bytes);
found.truncated |= truncated;
found.directories.push(entry.path());
} else if file_type.is_file() && is_foreign_recovery_debris_file_name(&name, db_prefix) {
found.bytes = found
.bytes
.saturating_add(entry.metadata().map_or(0, |m| m.len()));
found.files.push(entry.path());
}
}
found.directories.sort();
found.files.sort();
Ok(found)
}
fn check_foreign_recovery_debris(
beads_dir: &Path,
db_path: &Path,
checks: &mut Vec<CheckResult>,
) -> Result<()> {
let found = scan_foreign_recovery_debris(beads_dir, db_path)?;
if found.is_empty() {
push_check(
checks,
"db.foreign_recovery_debris",
CheckStatus::Ok,
None,
None,
);
return Ok(());
}
let approx = if found.truncated { "at least " } else { "" };
let message = format!(
"{} foreign recovery artifact(s) in .beads/ were not created by br \
({} director{}, {} file(s), {approx}{:.1} MB); br will not remove them",
found.directories.len() + found.files.len(),
found.directories.len(),
if found.directories.len() == 1 {
"y"
} else {
"ies"
},
found.files.len(),
found.bytes as f64 / (1024.0 * 1024.0),
);
let display = |paths: &[PathBuf]| -> Vec<String> {
paths.iter().map(|p| p.display().to_string()).collect()
};
push_check(
checks,
"db.foreign_recovery_debris",
CheckStatus::Ok,
Some(message),
Some(serde_json::json!({
"directories": display(&found.directories),
"files": display(&found.files),
"bytes": found.bytes,
"bytes_are_lower_bound": found.truncated,
"remediation": "Inspect with `du -sh .beads/*` and remove what you \
no longer need. br never created these and will \
not delete them.",
"finding_id": "fm-state_files-recovery-artifacts-orphaned",
})),
);
Ok(())
}
fn recovery_artifacts_aged(beads_dir: &Path, db_path: &Path) -> Result<Vec<PathBuf>> {
use std::time::{Duration, SystemTime};
let all = recovery_artifacts_for_db_family(beads_dir, db_path)?;
let threshold = Duration::from_secs(RECOVERY_AGED_TTL_DAYS * 24 * 60 * 60);
let now = SystemTime::now();
let mut aged = Vec::new();
for path in all {
let Ok(meta) = fs::metadata(&path) else {
continue;
};
let Ok(mtime) = meta.modified() else {
continue;
};
if now
.duration_since(mtime)
.map(|age| age > threshold)
.unwrap_or(false)
{
aged.push(path);
}
}
Ok(aged)
}
fn is_failed_jsonl_rebuild_artifact(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
name.contains(".rebuild-failed")
|| name.ends_with(JSONL_REBUILD_VERIFICATION_FAILED_SUFFIX)
})
}
fn prior_jsonl_rebuild_failure_evidence(
beads_dir: &Path,
db_path: &Path,
) -> Result<Option<PriorJsonlRebuildFailureEvidence>> {
let artifacts = recovery_artifacts_for_db_family(beads_dir, db_path)?;
let evidence_path = artifacts
.iter()
.find(|path| is_failed_jsonl_rebuild_artifact(path))
.cloned();
Ok(evidence_path.map(|path| PriorJsonlRebuildFailureEvidence {
path,
artifact_count: artifacts.len(),
}))
}
fn repeated_jsonl_rebuild_refusal_message(evidence: &PriorJsonlRebuildFailureEvidence) -> String {
format!(
"{JSONL_REBUILD_REPEAT_ERROR_PREFIX}: prior failed recovery evidence remains at '{}' among {} preserved database-family artifact(s). Inspect and preserve the recovery evidence before rerunning with --allow-repeated-repair.",
evidence.path.display(),
evidence.artifact_count
)
}
fn repeated_jsonl_rebuild_refusal_reason(
beads_dir: &Path,
db_path: &Path,
allow_repeated_repair: bool,
) -> Result<Option<String>> {
if allow_repeated_repair {
return Ok(None);
}
Ok(prior_jsonl_rebuild_failure_evidence(beads_dir, db_path)?
.as_ref()
.map(repeated_jsonl_rebuild_refusal_message))
}
fn push_inspection_error(
checks: &mut Vec<CheckResult>,
name: &str,
context: &str,
err: &BeadsError,
) {
push_check(
checks,
name,
CheckStatus::Error,
Some(format!("{context}: {err}")),
None,
);
}
fn build_issue_write_probe_check(
issue_id: &str,
update_result: std::result::Result<usize, FrankenError>,
rollback_result: std::result::Result<usize, FrankenError>,
) -> CheckResult {
let mut details = serde_json::json!({ "issue_id": issue_id });
match (update_result, rollback_result) {
(Ok(affected_rows), Ok(_)) => {
if affected_rows > 0 {
CheckResult {
name: "db.write_probe".to_string(),
status: CheckStatus::Ok,
message: Some(format!(
"Rollback-only issue write succeeded for {issue_id}"
)),
details: None,
}
} else {
details["affected_rows"] = serde_json::json!(affected_rows);
CheckResult {
name: "db.write_probe".to_string(),
status: CheckStatus::Error,
message: Some(format!(
"Rollback-only issue write affected 0 rows for {issue_id}"
)),
details: Some(details),
}
}
}
(Ok(affected_rows), Err(rollback_err)) => {
details["affected_rows"] = serde_json::json!(affected_rows);
details["rollback_error"] = serde_json::json!(rollback_err.to_string());
let message = if affected_rows == 0 {
format!(
"Rollback-only issue write affected 0 rows and rollback also failed: {rollback_err}"
)
} else {
format!("Rollback-only issue write succeeded but rollback failed: {rollback_err}")
};
CheckResult {
name: "db.write_probe".to_string(),
status: CheckStatus::Error,
message: Some(message),
details: Some(details),
}
}
(Err(update_err), Ok(_)) => CheckResult {
name: "db.write_probe".to_string(),
status: CheckStatus::Error,
message: Some(format!("Rollback-only issue write failed: {update_err}")),
details: Some(details),
},
(Err(update_err), Err(rollback_err)) => {
details["rollback_error"] = serde_json::json!(rollback_err.to_string());
CheckResult {
name: "db.write_probe".to_string(),
status: CheckStatus::Error,
message: Some(format!(
"Rollback-only issue write failed and rollback also failed: {update_err}"
)),
details: Some(details),
}
}
}
}
fn repair_database_from_jsonl(
beads_dir: &Path,
db_path: &Path,
jsonl_path: &Path,
cli: &config::CliOverrides,
show_progress: bool,
session: Option<&mut DoctorRepairSession>,
) -> Result<DoctorRepairResult> {
let jsonl_authority =
blocking_jsonl_family_write_lock_with_timeout(jsonl_path, cli.lock_timeout)?;
jsonl_authority.verify_jsonl_authority()?;
let source = capture_jsonl_source_snapshot(jsonl_path)?;
preflight_jsonl_rebuild_authority(&source)?;
if let Some(session) = session {
let family_paths = existing_sqlite_family_paths_for_legacy_op(db_path);
let path_refs: Vec<&Path> = family_paths.iter().map(PathBuf::as_path).collect();
return match session.record_legacy_mutation_result(
"doctor.jsonl_rebuild",
&path_refs,
|| {
repair_database_from_jsonl_after_preflight(
beads_dir,
db_path,
cli,
show_progress,
&source,
&jsonl_authority,
)
},
)? {
Some(result) => Ok(result),
None => Err(BeadsError::Config(
JSONL_REBUILD_DRY_RUN_SKIP_MESSAGE.to_string(),
)),
};
}
repair_database_from_jsonl_after_preflight(
beads_dir,
db_path,
cli,
show_progress,
&source,
&jsonl_authority,
)
}
fn repair_database_from_jsonl_after_preflight(
beads_dir: &Path,
db_path: &Path,
cli: &config::CliOverrides,
show_progress: bool,
source: &JsonlSourceSnapshot,
jsonl_authority: &crate::sync::JsonlFamilyWriteLock,
) -> Result<DoctorRepairResult> {
let bootstrap_layer = config::ConfigLayer::merge_layers(&[
config::load_startup_config(beads_dir)?,
cli.as_layer(),
]);
let (preserved_tombstones, preserved_dirty_issues) =
preserved_state_for_doctor_rebuild(db_path, source);
let history_snapshot = snapshot_history_for_doctor_rebuild(db_path);
for warning in &history_snapshot.failures {
tracing::warn!(
db_path = %db_path.display(),
warning,
"doctor --repair could not snapshot part of the DB-only history before the JSONL rebuild; those rows will be lost"
);
}
let recovery =
if let Some(authority) = cli.database_family_write_authority_for(beads_dir, db_path) {
config::repair_database_from_jsonl_snapshot_under_write_authority(
beads_dir,
db_path,
cli.lock_timeout,
&bootstrap_layer,
show_progress,
false,
source,
jsonl_authority,
authority,
)
} else {
config::repair_database_from_jsonl_snapshot(
beads_dir,
db_path,
cli.lock_timeout,
&bootstrap_layer,
show_progress,
false,
source,
jsonl_authority,
)
};
let (mut storage, import_result, verified_backups) = recovery?;
restore_tombstones_after_rebuild(&mut storage, &preserved_tombstones)?;
restore_dirty_issues_after_rebuild(&mut storage, &preserved_dirty_issues)?;
let mut history_preservation_warnings = history_snapshot.failures.clone();
let preserved_history = match storage.restore_auxiliary_history_tables(&history_snapshot) {
Ok(report) => report
.into_iter()
.map(|(table, restored, skipped)| PreservedHistoryTableReport {
table,
restored,
skipped,
})
.collect(),
Err(err) => {
history_preservation_warnings.push(format!(
"could not restore the {} snapshotted history row(s) into the rebuilt database: {err}",
history_snapshot.row_count()
));
Vec::new()
}
};
let fk_violations_cleaned = cleanup_repair_missing_issue_references(&mut storage)?;
Ok(DoctorRepairResult {
imported: import_result.imported_count,
skipped: import_result.skipped_count,
fk_violations_cleaned,
preserved_tombstones: preserved_tombstones.len(),
preserved_dirty_issues: preserved_dirty_issues.len(),
preserved_dirty_issue_ids: preserved_dirty_issues
.iter()
.map(|preserved| preserved.issue.id.clone())
.collect(),
verified_backups,
preserved_history,
history_preservation_warnings,
})
}
fn snapshot_history_for_doctor_rebuild(
db_path: &Path,
) -> crate::storage::sqlite::AuxiliaryHistorySnapshot {
let mut snapshot = crate::storage::sqlite::AuxiliaryHistorySnapshot::default();
if !db_path.is_file() {
return snapshot;
}
match SqliteStorage::open(db_path) {
Ok(storage) => storage.snapshot_auxiliary_history_tables(),
Err(err) => {
snapshot.failures.push(format!(
"could not open the pre-repair database to preserve history tables ({}): {err}",
crate::storage::sqlite::AUXILIARY_HISTORY_TABLES.join(", ")
));
snapshot
}
}
}
fn cleanup_repair_missing_issue_references(storage: &mut SqliteStorage) -> Result<usize> {
let missing_references = storage.missing_issue_references()?;
if missing_references.is_empty() {
return Ok(0);
}
tracing::warn!(
references = ?missing_references,
"Missing issue references found after repair import; cleaning local orphans"
);
let orphan_tables = &[
("dependencies", "issue_id"),
("dependencies", "depends_on_id"),
("labels", "issue_id"),
("comments", "issue_id"),
("events", "issue_id"),
("dirty_issues", "issue_id"),
("export_hashes", "issue_id"),
("blocked_issues_cache", "issue_id"),
("child_counters", "parent_id"),
("close_metadata", "issue_id"),
("gate_result_history", "issue_id"),
("gate_results", "issue_id"),
("capacity_exemption_history", "issue_id"),
("capacity_exemptions", "issue_id"),
("capacity_occupancy", "issue_id"),
];
let mut cleaned = 0usize;
let mut dependency_rows_cleaned = 0usize;
for (table, col) in orphan_tables {
let external_dependency_filter = match (*table, *col) {
("dependencies", "issue_id") => " AND issue_id NOT LIKE 'external:%'",
("dependencies", "depends_on_id") => " AND depends_on_id NOT LIKE 'external:%'",
_ => "",
};
let cleanup = format!(
"DELETE FROM {table} WHERE {col} NOT IN (SELECT id FROM issues){external_dependency_filter}"
);
let removed = storage.execute_raw_count(&cleanup)?;
if *table == "dependencies" {
dependency_rows_cleaned += removed;
}
cleaned += removed;
}
let remaining = storage.missing_issue_references()?;
if !remaining.is_empty() {
return Err(BeadsError::Config(format!(
"Repair import finished with orphaned issue references still present: {}",
remaining.join(", ")
)));
}
if dependency_rows_cleaned > 0 {
storage.rebuild_blocked_cache(true)?;
}
Ok(cleaned)
}
fn preflight_jsonl_rebuild_authority(source: &JsonlSourceSnapshot) -> Result<()> {
let conflict_markers = scan_conflict_markers_snapshot(source)?;
if !conflict_markers.is_empty() {
let preview = conflict_markers
.iter()
.take(3)
.map(|marker| {
let branch = marker
.branch
.as_ref()
.map_or(String::new(), |branch| format!(" ({branch})"));
format!("line {}: {:?}{branch}", marker.line, marker.marker_type)
})
.collect::<Vec<_>>()
.join("; ");
let suffix = if conflict_markers.len() > 3 {
" ..."
} else {
""
};
return Err(BeadsError::Config(format!(
"{JSONL_REBUILD_AUTHORITY_ERROR_PREFIX}: found {} merge conflict marker(s): {preview}{suffix}. Resolve JSONL conflicts before rebuilding SQLite from it.",
conflict_markers.len()
)));
}
let validation = validate_jsonl_snapshot_issue_records(source)?;
if validation.invalid_count > 0 {
let preview = validation.preview_messages().join("; ");
let suffix = if validation.invalid_count > validation.failures.len() {
" ..."
} else {
""
};
return Err(BeadsError::Config(format!(
"{JSONL_REBUILD_AUTHORITY_ERROR_PREFIX}: found {} invalid issue record(s): {preview}{suffix}. Fix JSONL before rebuilding SQLite from it.",
validation.invalid_count
)));
}
Ok(())
}
fn jsonl_rebuild_failure_outcome(err: &BeadsError) -> &'static str {
if let BeadsError::Config(message) = err {
if message.starts_with(JSONL_REBUILD_AUTHORITY_ERROR_PREFIX) {
return "refused";
}
if message == JSONL_REBUILD_DRY_RUN_SKIP_MESSAGE {
return "skipped";
}
}
"failed"
}
fn jsonl_rebuild_error_is_self_describing(outcome: &str) -> bool {
matches!(outcome, "refused" | "skipped")
}
fn jsonl_rebuild_root_error(err: &BeadsError) -> &BeadsError {
match err {
BeadsError::WithContext { source, .. } => source
.downcast_ref::<BeadsError>()
.map_or(err, jsonl_rebuild_root_error),
_ => err,
}
}
fn is_database_unavailable_failure(err: &BeadsError) -> bool {
match jsonl_rebuild_root_error(err) {
BeadsError::DatabaseLocked { .. } | BeadsError::WriteLockTimeout { .. } => true,
BeadsError::Database(inner) => matches!(
inner,
FrankenError::CannotOpen { .. }
| FrankenError::DatabaseLocked { .. }
| FrankenError::LockFailed { .. }
| FrankenError::Busy
),
_ => false,
}
}
fn is_jsonl_content_failure(err: &BeadsError) -> bool {
matches!(
jsonl_rebuild_root_error(err),
BeadsError::JsonlParse { .. }
| BeadsError::PrefixMismatch { .. }
| BeadsError::ImportCollision { .. }
)
}
fn jsonl_rebuild_failure_message(err: &BeadsError) -> String {
if let timeout @ BeadsError::WriteLockTimeout { .. } = jsonl_rebuild_root_error(err) {
return format!(
"Repair import failed: {err}. {}",
timeout.suggestion().unwrap_or_default()
);
}
if is_database_unavailable_failure(err) {
return format!(
"Repair import failed: {err}. \
The database could not be opened for writing — the JSONL is not implicated. \
Another process most likely holds this workspace: close other `br` \
invocations and any MCP `br serve` session, check `.beads/.write.lock`, \
then retry."
);
}
if is_jsonl_content_failure(err) {
return format!(
"Repair import failed: {err}. \
The import rejected records in the JSONL. \
Fix the offending records in `.beads/issues.jsonl`, then retry."
);
}
format!(
"Repair import failed: {err}. \
No database writes were applied; the workspace is unchanged. \
Re-run `br doctor --json` to inspect the current state."
)
}
fn write_jsonl_rebuild_verification_failed_marker(
beads_dir: &Path,
db_path: &Path,
post_repair: &DoctorRun,
repair_result: &DoctorRepairResult,
session: &mut DoctorRepairSession,
) -> Result<PathBuf> {
let recovery_dir = config::recovery_dir_for_db_path(db_path, beads_dir);
let stamp = Utc::now().format("%Y%m%d_%H%M%S_%f");
let marker_path = recovery_dir.join(format!(
"{}.{stamp}{JSONL_REBUILD_VERIFICATION_FAILED_SUFFIX}",
db_family_prefix(db_path)
));
let failed_checks = post_repair
.report
.checks
.iter()
.filter(|check| {
matches!(check.status, CheckStatus::Error) || is_warn_level_page_anomaly_check(check)
})
.collect::<Vec<_>>();
let payload = serde_json::json!({
"phase": "doctor.jsonl_rebuild",
"action": "jsonl_rebuild",
"outcome": "verification_failed",
"created_at": Utc::now().to_rfc3339(),
"db_path": db_path.display().to_string(),
"imported": repair_result.imported,
"skipped": repair_result.skipped,
"fk_violations_cleaned": repair_result.fk_violations_cleaned,
"preserved_tombstones": repair_result.preserved_tombstones,
"preserved_dirty_issues": repair_result.preserved_dirty_issues,
"preserved_dirty_issue_ids": &repair_result.preserved_dirty_issue_ids,
"verified_backups": &repair_result.verified_backups,
"workspace_health": post_repair.report.workspace_health.as_deref(),
"failed_checks": failed_checks,
});
let bytes = serde_json::to_vec_pretty(&payload)?;
session.set_fixer("doctor.jsonl_rebuild_verification_marker");
chokepoint::mutate(
&session.ctx,
&marker_path,
Op::WriteFile {
content: bytes,
mode: None,
},
)?;
Ok(marker_path)
}
fn preserved_state_for_doctor_rebuild(
db_path: &Path,
source: &JsonlSourceSnapshot,
) -> (Vec<PreservedIssue>, Vec<PreservedIssue>) {
if !db_path.is_file() {
return (Vec::new(), Vec::new());
}
let storage = match SqliteStorage::open(db_path) {
Ok(storage) => storage,
Err(err) => {
tracing::debug!(
db_path = %db_path.display(),
error = %err,
"Could not open DB for pre-repair preservation snapshot; proceeding without preservation"
);
return (Vec::new(), Vec::new());
}
};
let tombstone_snapshot = snapshot_tombstones(&storage);
let dirty_snapshot = snapshot_dirty_live_issues(&storage);
drop(storage);
if tombstone_snapshot.is_empty() && dirty_snapshot.is_empty() {
return (tombstone_snapshot, dirty_snapshot);
}
let jsonl_filter = match scan_jsonl_snapshot_for_tombstone_filter(source) {
Ok(filter) => filter,
Err(err) => {
tracing::debug!(
jsonl_path = %source.display_path().display(),
error = %err,
"Could not scan immutable JSONL snapshot for tombstone filter during doctor --repair; preserving every snapshotted issue and letting the rebuild surface the JSONL error"
);
JsonlTombstoneFilter::default()
}
};
(
tombstones_missing_from_jsonl_tombstones(tombstone_snapshot, &jsonl_filter),
dirty_issues_missing_from_jsonl(dirty_snapshot, &jsonl_filter),
)
}
#[cfg(test)]
fn repair_recoverable_db_state(
beads_dir: &Path,
db_path: &Path,
report: &DoctorReport,
session: Option<&mut DoctorRepairSession>,
fixer_filter: &FixerFilter,
) -> LocalRepairResult {
let write_authority = match acquire_doctor_database_write_authority(
beads_dir,
db_path,
Some(crate::sync::default_write_lock_timeout_ms()),
) {
Ok(authority) => authority,
Err(err) => {
tracing::warn!(
path = %db_path.display(),
error = %err,
"Skipping recoverable database repair because authority could not be acquired"
);
return LocalRepairResult::default();
}
};
repair_recoverable_db_state_under_write_authority(
beads_dir,
db_path,
report,
session,
fixer_filter,
&write_authority,
)
}
fn repair_recoverable_db_state_under_write_authority(
beads_dir: &Path,
db_path: &Path,
report: &DoctorReport,
mut session: Option<&mut DoctorRepairSession>,
fixer_filter: &FixerFilter,
write_authority: &Arc<crate::sync::DatabaseFamilyWriteLock>,
) -> LocalRepairResult {
let mut repair = LocalRepairResult::default();
if report_has_sidecar_anomaly(report)
&& fixer_filter.allows("fm-state_files-wal-shm-sidecar-orphan")
{
if let Err(err) = write_authority.verify_database_authority() {
tracing::warn!(
path = %db_path.display(),
error = %err,
"Skipping sidecar repair because database authority was lost"
);
return repair;
}
repair_database_sidecars(db_path, &mut repair, session.as_deref_mut());
if let Err(err) = write_authority.verify_database_authority() {
tracing::warn!(
path = %db_path.display(),
error = %err,
"Database authority changed during sidecar repair"
);
return repair;
}
}
if !db_path.is_file() {
tracing::debug!(
path = %db_path.display(),
"Skipping blocked-cache repair because the database file is missing"
);
return repair;
}
let open_for_repair = || {
open_doctor_storage_under_write_authority(db_path, write_authority).or_else(|first_err| {
if !db_file_has_sqlite_header(db_path) {
return Err(first_err);
}
crate::config::quarantine_truncated_wal_sidecar(db_path, beads_dir);
open_doctor_storage_under_write_authority(db_path, write_authority).inspect_err(|_| {
tracing::debug!(
path = %db_path.display(),
first_error = %first_err,
"Reopen after truncated-WAL quarantine still failed"
);
})
})
};
let do_rebuild = |repair: &mut LocalRepairResult| match open_for_repair() {
Ok(mut storage) => {
let force_rebuild = report_has_projection_content_mismatch_finding(report);
let rebuild_result = if force_rebuild {
storage.rebuild_blocked_cache(true).map(|_| true)
} else {
storage.ensure_blocked_cache_fresh()
};
match rebuild_result {
Ok(blocked_cache_rebuilt) => {
repair.blocked_cache_rebuilt = blocked_cache_rebuilt;
}
Err(err) => {
tracing::warn!(
path = %db_path.display(),
error = %err,
"Skipping blocked-cache repair; falling back to JSONL rebuild"
);
}
}
}
Err(err) => {
tracing::warn!(
path = %db_path.display(),
error = %err,
"Skipping blocked-cache repair because the database could not be opened"
);
}
};
if let Some(session) = session {
let family_paths = existing_sqlite_family_paths_for_legacy_op(db_path);
let family_refs: Vec<&Path> = family_paths.iter().map(PathBuf::as_path).collect();
let result =
session.record_legacy_mutation("repair_recoverable_db_state", &family_refs, || {
do_rebuild(&mut repair);
Ok(())
});
if let Err(err) = result {
tracing::warn!(
path = %db_path.display(),
error = %err,
"Failed to record blocked-cache rebuild legacy-op audit; mutation still proceeded if possible"
);
}
} else {
do_rebuild(&mut repair);
}
repair
}
#[cfg(test)]
fn repair_partial_indexes(
db_path: &Path,
repair: &mut LocalRepairResult,
session: Option<&mut DoctorRepairSession>,
) {
let Some(beads_dir) = db_path.parent() else {
tracing::warn!(path = %db_path.display(), "Skipping REINDEX for parentless database path");
return;
};
let write_authority = match acquire_doctor_database_write_authority(
beads_dir,
db_path,
Some(crate::sync::default_write_lock_timeout_ms()),
) {
Ok(authority) => authority,
Err(err) => {
tracing::warn!(
path = %db_path.display(),
error = %err,
"Skipping REINDEX because database authority could not be acquired"
);
return;
}
};
repair_partial_indexes_under_write_authority(db_path, repair, session, &write_authority);
}
fn repair_partial_indexes_under_write_authority(
db_path: &Path,
repair: &mut LocalRepairResult,
session: Option<&mut DoctorRepairSession>,
write_authority: &Arc<crate::sync::DatabaseFamilyWriteLock>,
) {
if !db_path.is_file() {
tracing::debug!(
path = %db_path.display(),
"Skipping REINDEX because the database file is missing"
);
return;
}
let do_reindex = |repair: &mut LocalRepairResult| {
if let Err(err) = write_authority.verify_database_authority() {
tracing::warn!(
path = %db_path.display(),
error = %err,
"Skipping REINDEX because database authority was lost"
);
return;
}
match Connection::open(db_path.to_string_lossy().into_owned()) {
Ok(conn) => {
let _ = conn.execute("PRAGMA busy_timeout=30000");
match conn.execute("REINDEX") {
Ok(_) => {
tracing::info!(
path = %db_path.display(),
"REINDEX completed successfully"
);
repair.indexes_reindexed = true;
}
Err(err) => {
tracing::warn!(
path = %db_path.display(),
error = %err,
"REINDEX failed; partial-index warnings may persist"
);
}
}
if let Err(err) = conn.close() {
tracing::warn!(
path = %db_path.display(),
error = %err,
"REINDEX connection close failed"
);
}
if let Err(err) = write_authority.verify_database_authority() {
tracing::warn!(
path = %db_path.display(),
error = %err,
"Database authority changed during REINDEX"
);
repair.indexes_reindexed = false;
}
}
Err(err) => {
tracing::warn!(
path = %db_path.display(),
error = %err,
"Skipping REINDEX because the database could not be opened"
);
}
}
};
if let Some(session) = session {
let family_paths = existing_sqlite_family_paths_for_legacy_op(db_path);
let family_refs: Vec<&Path> = family_paths.iter().map(PathBuf::as_path).collect();
let result = session.record_legacy_mutation("repair_partial_indexes", &family_refs, || {
do_reindex(repair);
Ok(())
});
if let Err(err) = result {
tracing::warn!(
path = %db_path.display(),
error = %err,
"Failed to record REINDEX legacy-op audit; mutation still proceeded if possible"
);
}
} else {
do_reindex(repair);
}
}
fn repair_database_sidecars(
db_path: &Path,
repair: &mut LocalRepairResult,
session: Option<&mut DoctorRepairSession>,
) {
match inspect_database_sidecars(db_path) {
Ok(_) => quarantine_anomalous_sidecars(db_path, repair, session),
Err(err) => tracing::warn!(
path = %db_path.display(),
error = %err,
"Skipping sidecar repair because filesystem inspection failed"
),
}
}
fn quarantine_anomalous_sidecars(
db_path: &Path,
repair: &mut LocalRepairResult,
session: Option<&mut DoctorRepairSession>,
) {
match inspect_database_sidecars(db_path) {
Ok(post_checkpoint_inspection) => {
let quarantine_paths: BTreeSet<_> = post_checkpoint_inspection
.quarantine_candidates
.into_iter()
.collect();
if quarantine_paths.is_empty() {
return;
}
let Some(session) = session else {
tracing::warn!(
path = %db_path.display(),
"Skipping sidecar quarantine: no doctor repair session is available"
);
return;
};
session.set_fixer("doctor.database_sidecar_quarantine");
let mut quarantined = Vec::new();
for source in &quarantine_paths {
let Some(name) = source.file_name() else {
tracing::warn!(
path = %source.display(),
"Skipping sidecar quarantine candidate without a file name"
);
continue;
};
let dest = session
.run
.root
.join("quarantine")
.join(".beads")
.join(name);
match chokepoint::mutate(&session.ctx, source, Op::Rename { to: dest.clone() }) {
Ok(result) if result.ok => {
quarantined.push(dest.display().to_string());
}
Ok(_) => tracing::warn!(
path = %source.display(),
"Sidecar quarantine no-op"
),
Err(err) => tracing::warn!(
path = %source.display(),
error = %err,
"Failed to quarantine anomalous database sidecar artifact"
),
}
}
if !quarantined.is_empty() {
repair.quarantined_artifacts = quarantined;
}
}
Err(err) => tracing::warn!(
path = %db_path.display(),
error = %err,
"Failed to re-inspect database sidecars after local repair"
),
}
}
thread_local! {
static ENGINE_BLOCK: std::cell::RefCell<Option<EngineBlock>> = const { std::cell::RefCell::new(None) };
}
fn set_engine_block(block: EngineBlock) {
ENGINE_BLOCK.with(|slot| *slot.borrow_mut() = Some(block));
}
fn current_engine_block() -> Option<EngineBlock> {
ENGINE_BLOCK.with(|slot| slot.borrow().clone())
}
fn print_report(report: &DoctorReport, ctx: &OutputContext) -> Result<()> {
if ctx.is_json() {
match current_engine_block() {
Some(engine) => {
let mut value = serde_json::to_value(report).map_err(|err| {
BeadsError::validation("doctor", format!("cannot serialize report: {err}"))
})?;
if let serde_json::Value::Object(map) = &mut value {
map.insert(
"engine".to_string(),
serde_json::to_value(&engine).unwrap_or(serde_json::Value::Null),
);
}
ctx.json(&value);
}
None => ctx.json(report),
}
return Ok(());
}
if ctx.is_quiet() {
return Ok(());
}
if ctx.is_rich() {
render_doctor_rich(report, ctx);
return Ok(());
}
print_report_plain(report);
Ok(())
}
fn print_report_plain(report: &DoctorReport) {
println!("br doctor");
if let Some(health) = &report.workspace_health {
println!("HEALTH workspace: {health}");
}
if let Some(engine) = current_engine_block() {
println!("ENGINE {}", engine.summary_line());
}
for check in &report.checks {
let label = match check.status {
CheckStatus::Ok => "OK",
CheckStatus::Warn => "WARN",
CheckStatus::Error => "ERROR",
};
if let Some(message) = &check.message {
println!("{label} {}: {}", check.name, message);
} else {
println!("{label} {}", check.name);
}
}
}
fn render_doctor_rich(report: &DoctorReport, ctx: &OutputContext) {
let theme = ctx.theme();
let mut content = Text::new("");
let mut ok_count = 0usize;
let mut warn_count = 0usize;
let mut error_count = 0usize;
for check in &report.checks {
match check.status {
CheckStatus::Ok => ok_count += 1,
CheckStatus::Warn => warn_count += 1,
CheckStatus::Error => error_count += 1,
}
}
content.append_styled("Diagnostics Report\n", theme.emphasis.clone());
content.append("\n");
content.append_styled("Status: ", theme.dimmed.clone());
if report.ok {
content.append_styled("OK", theme.success.clone());
} else {
content.append_styled("Issues found", theme.error.clone());
}
content.append("\n");
if let Some(health) = &report.workspace_health {
content.append_styled("Health: ", theme.dimmed.clone());
content.append_styled(health, theme.accent.clone());
content.append("\n");
}
content.append_styled("Checks: ", theme.dimmed.clone());
content.append_styled(
&format!("{ok_count} ok, {warn_count} warn, {error_count} error"),
theme.accent.clone(),
);
content.append("\n\n");
for check in &report.checks {
let (label, style) = match check.status {
CheckStatus::Ok => ("[OK]", theme.success.clone()),
CheckStatus::Warn => ("[WARN]", theme.warning.clone()),
CheckStatus::Error => ("[ERROR]", theme.error.clone()),
};
content.append_styled(label, style);
content.append(" ");
content.append_styled(&check.name, theme.issue_title.clone());
if let Some(message) = &check.message {
content.append_styled(": ", theme.dimmed.clone());
content.append(message);
}
content.append("\n");
if !matches!(check.status, CheckStatus::Ok)
&& let Some(details) = &check.details
&& let Ok(details_text) = serde_json::to_string_pretty(details)
{
for line in details_text.lines() {
content.append_styled(" ", theme.dimmed.clone());
content.append_styled(line, theme.dimmed.clone());
content.append("\n");
}
}
}
let panel = Panel::from_rich_text(&content, ctx.width())
.title(Text::styled("Doctor", theme.panel_title.clone()))
.box_style(theme.box_style)
.border_style(theme.panel_border.clone());
ctx.render(&panel);
}
fn collect_table_columns(conn: &Connection, table: &str) -> Result<Vec<String>> {
let rows = conn.query(&format!("PRAGMA table_info({table})"))?;
let mut columns = Vec::with_capacity(rows.len());
for row in &rows {
if let Some(name) = row.get(1).and_then(SqliteValue::as_text) {
columns.push(name.to_string());
}
}
Ok(columns)
}
#[allow(clippy::too_many_lines)]
fn required_schema_checks(conn: &Connection, checks: &mut Vec<CheckResult>) -> Result<()> {
let rows = conn
.query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")?;
let mut tables = Vec::with_capacity(rows.len());
for row in &rows {
if let Some(name) = row.get(0).and_then(SqliteValue::as_text) {
tables.push(name.to_string());
}
}
let required_tables = [
"issues",
"dependencies",
"labels",
"comments",
"events",
"config",
"metadata",
"dirty_issues",
"export_hashes",
"blocked_issues_cache",
"child_counters",
];
if tables.is_empty() {
for &table in &required_tables {
let probe = format!("SELECT 1 FROM {table} LIMIT 1");
if conn.query(&probe).is_ok() {
tables.push(table.to_string());
}
}
}
let missing_tables: Vec<&str> = required_tables
.iter()
.copied()
.filter(|table| !tables.iter().any(|t| t == table))
.collect();
if missing_tables.is_empty() {
push_check(
checks,
"schema.tables",
CheckStatus::Ok,
None,
Some(serde_json::json!({ "tables": tables })),
);
} else {
push_check(
checks,
"schema.tables",
CheckStatus::Error,
Some(format!("Missing tables: {}", missing_tables.join(", "))),
Some(serde_json::json!({ "missing": missing_tables })),
);
}
let required_columns: &[(&str, &[&str])] = &[
(
"issues",
&[
"id",
"title",
"status",
"priority",
"issue_type",
"created_at",
"updated_at",
],
),
(
"dependencies",
&["issue_id", "depends_on_id", "type", "created_at"],
),
(
"comments",
&["id", "issue_id", "author", "text", "created_at"],
),
(
"events",
&["id", "issue_id", "event_type", "actor", "created_at"],
),
];
let mut missing_columns = Vec::new();
for (table, cols) in required_columns {
let present = collect_table_columns(conn, table)?;
let missing: Vec<&str> = cols
.iter()
.copied()
.filter(|col| !present.iter().any(|p| p == col))
.collect();
if !missing.is_empty() {
missing_columns.push(serde_json::json!({
"table": table,
"missing": missing,
}));
}
}
if missing_columns.is_empty() {
push_check(checks, "schema.columns", CheckStatus::Ok, None, None);
} else {
push_check(
checks,
"schema.columns",
CheckStatus::Error,
Some("Missing required columns".to_string()),
Some(serde_json::json!({ "tables": missing_columns })),
);
}
Ok(())
}
fn integrity_messages_only_benign(messages: &[String]) -> bool {
if messages.is_empty() {
return false;
}
let has_benign = messages.iter().any(|msg| {
let lower = msg.to_lowercase();
lower.contains("never used")
|| lower.contains("missing from index")
|| lower.contains("out of order")
});
if !has_benign {
return false;
}
messages.iter().all(|msg| {
let lower = msg.to_lowercase();
lower.contains("never used")
|| lower.contains("missing from index")
|| lower.contains("out of order")
|| lower.contains("*** in database")
})
}
fn check_integrity(conn: &Connection, checks: &mut Vec<CheckResult>) {
let rows = match conn.query("PRAGMA integrity_check") {
Ok(rows) => rows,
Err(err) => {
push_check(
checks,
"sqlite.integrity_check",
CheckStatus::Error,
Some(err.to_string()),
None,
);
return;
}
};
let row_values: Vec<Vec<SqliteValue>> = rows.iter().map(|row| row.values().to_vec()).collect();
let messages = integrity_check_messages(&row_values);
if messages.len() == 1 && messages[0].trim().eq_ignore_ascii_case("ok") {
push_check(
checks,
"sqlite.integrity_check",
CheckStatus::Ok,
None,
None,
);
} else if integrity_messages_only_benign(&messages) {
push_check(
checks,
"sqlite.integrity_check",
CheckStatus::Warn,
Some(messages.join("; ")),
(messages.len() > 1).then(|| serde_json::json!({ "messages": messages })),
);
} else {
push_check(
checks,
"sqlite.integrity_check",
CheckStatus::Error,
Some(messages.join("; ")),
(messages.len() > 1).then(|| serde_json::json!({ "messages": messages })),
);
}
}
fn latest_metadata_value(conn: &Connection, key: &str) -> Option<String> {
conn.query_row_with_params(
"SELECT value FROM metadata WHERE key = ? ORDER BY rowid DESC LIMIT 1",
&[SqliteValue::from(key)],
)
.ok()
.and_then(|row| {
row.get(0)
.and_then(SqliteValue::as_text)
.filter(|value| !value.is_empty())
.map(str::to_string)
})
}
fn check_export_hash_cache_divergence(
conn: &Connection,
jsonl_path: Option<&Path>,
checks: &mut Vec<CheckResult>,
) {
let Some(jsonl) = jsonl_path else {
push_check(checks, "db.export_hash_cache", CheckStatus::Ok, None, None);
return;
};
if !jsonl.is_file() {
push_check(checks, "db.export_hash_cache", CheckStatus::Ok, None, None);
return;
}
let Some(stored) = latest_metadata_value(conn, "jsonl_content_hash") else {
push_check(checks, "db.export_hash_cache", CheckStatus::Ok, None, None);
return;
};
let Ok(computed) = crate::sync::compute_jsonl_hash(jsonl) else {
push_check(checks, "db.export_hash_cache", CheckStatus::Ok, None, None);
return;
};
if stored == computed {
push_check(checks, "db.export_hash_cache", CheckStatus::Ok, None, None);
} else {
push_check(
checks,
"db.export_hash_cache",
CheckStatus::Warn,
Some("Top-level JSONL content hash in `metadata` differs from computed hash. Cache is stale; doctor --repair will recompute.".to_string()),
Some(serde_json::json!({
"stored_top_hash": stored,
"computed_top_hash": computed,
})),
);
}
}
fn check_base_jsonl_missing_post_flush(
conn: &Connection,
beads_dir: &Path,
jsonl_path: Option<&Path>,
checks: &mut Vec<CheckResult>,
) {
let anchor = beads_dir.join("beads.base.jsonl");
if fs::symlink_metadata(&anchor).is_ok() {
push_check(
checks,
"base_jsonl.missing_post_flush",
CheckStatus::Ok,
None,
None,
);
return;
}
let last_export = latest_metadata_value(conn, "last_export_time");
match last_export {
Some(stamp) if !stamp.is_empty() => {
if workspace_verifiably_in_sync(conn, jsonl_path) {
push_check(
checks,
"base_jsonl.missing_post_flush",
CheckStatus::Ok,
Some(
"Merge anchor absent, but database and JSONL are in sync; the next `br sync --flush-only` recreates it"
.to_string(),
),
Some(serde_json::json!({
"anchor": anchor.display().to_string(),
"last_export_time": stamp,
"kind": "missing_but_in_sync",
})),
);
return;
}
push_check(
checks,
"base_jsonl.missing_post_flush",
CheckStatus::Warn,
Some(format!(
"Merge anchor {} is missing despite metadata.last_export_time={stamp}, and the workspace is not verifiably in sync — run `br sync --flush-only` to reconcile and regenerate it",
anchor.display()
)),
Some(serde_json::json!({
"anchor": anchor.display().to_string(),
"last_export_time": stamp,
"kind": "missing_post_flush",
})),
);
}
_ => {
push_check(
checks,
"base_jsonl.missing_post_flush",
CheckStatus::Ok,
None,
None,
);
}
}
}
fn workspace_verifiably_in_sync(conn: &Connection, jsonl_path: Option<&Path>) -> bool {
let Some(jsonl) = jsonl_path else {
return false;
};
if !jsonl.is_file() {
return false;
}
let Some(stored) = latest_metadata_value(conn, "jsonl_content_hash") else {
return false;
};
let Ok(computed) = crate::sync::compute_jsonl_hash(jsonl) else {
return false;
};
if stored != computed {
return false;
}
let Ok(rows) = conn.query("SELECT COUNT(*) FROM dirty_issues") else {
return false;
};
let dirty_count = rows
.first()
.and_then(|row| row.values().first().cloned())
.and_then(|v| match v {
SqliteValue::Integer(n) => Some(n),
_ => None,
})
.unwrap_or(i64::MAX);
dirty_count == 0
}
const CRLF_SCAN_PREFIX_BYTES: usize = 64 * 1024;
const WAL_OVERSIZED_BYTES: u64 = 32 * 1024 * 1024;
fn sqlite_wal_sidecar_path(db_path: &Path) -> PathBuf {
let mut sidecar = db_path.as_os_str().to_os_string();
sidecar.push("-wal");
PathBuf::from(sidecar)
}
fn sqlite_journal_sidecar_path(db_path: &Path) -> PathBuf {
let mut sidecar = db_path.as_os_str().to_os_string();
sidecar.push("-journal");
PathBuf::from(sidecar)
}
fn db_file_has_sqlite_header(db_path: &Path) -> bool {
use std::io::Read;
let Ok(mut file) = fs::File::open(db_path) else {
return false;
};
let mut header = [0u8; 16];
file.read_exact(&mut header).is_ok() && &header == b"SQLite format 3\0"
}
fn fsqlite_namespace_sidecar_paths(db_path: &Path) -> Vec<PathBuf> {
crate::config::FSQLITE_NAMESPACE_SIDECAR_SUFFIXES
.iter()
.map(|suffix| {
let mut sidecar = db_path.as_os_str().to_os_string();
sidecar.push(suffix);
PathBuf::from(sidecar)
})
.collect()
}
fn fsqlite_wal_cert_sidecar_paths(db_path: &Path) -> Vec<PathBuf> {
crate::config::FSQLITE_WAL_CERT_SIDECAR_SUFFIXES
.iter()
.map(|suffix| {
let mut sidecar = db_path.as_os_str().to_os_string();
sidecar.push(suffix);
PathBuf::from(sidecar)
})
.collect()
}
fn existing_sqlite_family_paths_for_legacy_op(db_path: &Path) -> Vec<PathBuf> {
let mut paths = vec![db_path.to_path_buf()];
let migration_state = {
let mut sidecar = db_path.as_os_str().to_os_string();
sidecar.push(".fsqlite-migration-state");
PathBuf::from(sidecar)
};
let sidecars = [
sqlite_wal_sidecar_path(db_path),
sqlite_shm_sidecar_path(db_path),
sqlite_journal_sidecar_path(db_path),
migration_state,
]
.into_iter()
.chain(fsqlite_namespace_sidecar_paths(db_path))
.chain(fsqlite_wal_cert_sidecar_paths(db_path));
for sidecar in sidecars {
if fs::symlink_metadata(&sidecar)
.is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
{
paths.push(sidecar);
}
}
paths
}
fn check_wal_oversized(db_path: &Path, checks: &mut Vec<CheckResult>) {
let path = sqlite_wal_sidecar_path(db_path);
let Ok(meta) = fs::symlink_metadata(&path) else {
push_check(checks, "wal_size", CheckStatus::Ok, None, None);
return;
};
if !meta.is_file() || meta.file_type().is_symlink() {
push_check(checks, "wal_size", CheckStatus::Ok, None, None);
return;
}
let bytes = meta.len();
if bytes > WAL_OVERSIZED_BYTES {
push_check(
checks,
"wal_size",
CheckStatus::Warn,
Some(format!(
"{} is {}MB (>{}MB threshold); SQLite auto-checkpoint may be blocked by a long-running read snapshot",
path.display(),
bytes / (1024 * 1024),
WAL_OVERSIZED_BYTES / (1024 * 1024)
)),
Some(serde_json::json!({
"path": path.display().to_string(),
"size_bytes": bytes,
"threshold_bytes": WAL_OVERSIZED_BYTES,
"remediation": "Run `br doctor --repair --only fm-state_files-wal-oversized` or manually run `PRAGMA wal_checkpoint(TRUNCATE)` against the selected SQLite database",
})),
);
} else {
push_check(checks, "wal_size", CheckStatus::Ok, None, None);
}
}
#[cfg(test)]
fn fix_wal_oversized_if_warned(
db_path: &Path,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
let Some(beads_dir) = db_path.parent() else {
return false;
};
let write_authority = match acquire_doctor_database_write_authority(
beads_dir,
db_path,
Some(crate::sync::default_write_lock_timeout_ms()),
) {
Ok(authority) => authority,
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!(
"Skipping wal_checkpoint: database authority unavailable ({err})"
));
}
return false;
}
};
fix_wal_oversized_if_warned_under_write_authority(
db_path,
report,
ctx,
session,
&write_authority,
)
}
fn fix_wal_oversized_if_warned_under_write_authority(
db_path: &Path,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
write_authority: &Arc<crate::sync::DatabaseFamilyWriteLock>,
) -> bool {
let has_warning = report
.checks
.iter()
.any(|c| c.name == "wal_size" && c.status == CheckStatus::Warn);
if !has_warning {
return false;
}
let Some(session) = session else {
if !ctx.is_json() {
ctx.warning(
"Skipping wal_checkpoint: no doctor repair session (run-dir creation failed)",
);
}
return false;
};
let wal_path = sqlite_wal_sidecar_path(db_path);
let shm_path = sqlite_shm_sidecar_path(db_path);
match session.record_legacy_mutation(
"doctor.wal_checkpoint_truncate",
&[db_path, &wal_path, &shm_path],
|| checkpoint_wal_truncate(db_path, write_authority),
) {
Ok(()) => {
if !ctx.is_json() {
ctx.info("Checkpointed and truncated SQLite WAL");
}
true
}
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!("Failed to checkpoint WAL: {err}"));
}
false
}
}
}
fn sqlite_shm_sidecar_path(db_path: &Path) -> PathBuf {
let mut sidecar = db_path.as_os_str().to_os_string();
sidecar.push("-shm");
PathBuf::from(sidecar)
}
fn checkpoint_wal_truncate(
db_path: &Path,
write_authority: &Arc<crate::sync::DatabaseFamilyWriteLock>,
) -> Result<()> {
write_authority.verify_database_authority()?;
let conn = Connection::open(db_path.to_string_lossy().into_owned())?;
let checkpoint_complete = match wal_checkpoint_truncate_complete(&conn) {
Ok(complete) => complete,
Err(err) => {
let _ = conn.close();
return Err(err.into());
}
};
conn.close()?;
write_authority.verify_database_authority()?;
if !checkpoint_complete {
return Err(BeadsError::internal(
"doctor: WAL checkpoint did not complete; a reader may still hold a snapshot",
));
}
let wal_path = sqlite_wal_sidecar_path(db_path);
truncate_oversized_regular_wal_if_needed(&wal_path)?;
write_authority.verify_database_authority()?;
Ok(())
}
fn truncate_oversized_regular_wal_if_needed(wal_path: &Path) -> Result<()> {
if let Ok(meta) = fs::symlink_metadata(wal_path)
&& meta.is_file()
&& !meta.file_type().is_symlink()
&& meta.len() > WAL_OVERSIZED_BYTES
{
OpenOptions::new().write(true).open(wal_path)?.set_len(0)?;
}
Ok(())
}
#[cfg(test)]
#[allow(dead_code)] fn fix_db_bloat_via_vacuum_if_warned(
db_path: &Path,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
let Some(beads_dir) = db_path.parent() else {
return false;
};
let write_authority = match acquire_doctor_database_write_authority(
beads_dir,
db_path,
Some(crate::sync::default_write_lock_timeout_ms()),
) {
Ok(authority) => authority,
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!(
"Skipping db-bloat VACUUM: database authority unavailable ({err})"
));
}
return false;
}
};
fix_db_bloat_via_vacuum_if_warned_under_write_authority(
db_path,
report,
ctx,
session,
&write_authority,
)
}
fn fix_db_bloat_via_vacuum_if_warned_under_write_authority(
db_path: &Path,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
write_authority: &Arc<crate::sync::DatabaseFamilyWriteLock>,
) -> bool {
let has_warning = report
.checks
.iter()
.any(|c| c.name == "db_bloat" && c.status == CheckStatus::Warn);
if !has_warning {
return false;
}
let Some(session) = session else {
if !ctx.is_json() {
ctx.warning(
"Skipping db-bloat VACUUM: no doctor repair session (run-dir creation failed)",
);
}
return false;
};
let wal_path = sqlite_wal_sidecar_path(db_path);
let shm_path = sqlite_shm_sidecar_path(db_path);
match session.record_legacy_mutation(
"doctor.db_bloat_vacuum",
&[db_path, &wal_path, &shm_path],
|| vacuum_database(db_path, write_authority),
) {
Ok(()) => {
if !ctx.is_json() {
ctx.info(
"Compacted database via VACUUM (--unsafe-auto-fix opt-in, bloat-triggered)",
);
}
true
}
Err(err) => {
tracing::warn!(
error = %err,
db_path = %db_path.display(),
"db-bloat VACUUM failed; database left uncompacted"
);
if !ctx.is_json() {
ctx.warning(&format!("Failed to VACUUM database: {err}"));
}
false
}
}
}
fn vacuum_database(
db_path: &Path,
write_authority: &Arc<crate::sync::DatabaseFamilyWriteLock>,
) -> Result<()> {
let storage = open_doctor_storage_under_write_authority(db_path, write_authority)?;
config::compact_database_via_vacuum_into_in_place(storage, db_path, None)?;
write_authority.rebind_database_inode_after_authorized_replace()?;
Ok(())
}
const DB_BLOAT_RATIO_THRESHOLD: u64 = 10;
const DB_BLOAT_MIN_JSONL_BYTES: u64 = 1024 * 1024;
fn check_db_bloat_vs_jsonl(
db_path: &Path,
jsonl_path: Option<&Path>,
checks: &mut Vec<CheckResult>,
) {
let Some(jsonl_path) = jsonl_path else {
push_check(checks, "db_bloat", CheckStatus::Ok, None, None);
return;
};
let (Ok(db_meta), Ok(jsonl_meta)) = (
fs::symlink_metadata(db_path),
fs::symlink_metadata(jsonl_path),
) else {
push_check(checks, "db_bloat", CheckStatus::Ok, None, None);
return;
};
if !db_meta.is_file() || !jsonl_meta.is_file() {
push_check(checks, "db_bloat", CheckStatus::Ok, None, None);
return;
}
let db_bytes = db_meta.len();
let jsonl_bytes = jsonl_meta.len();
if jsonl_bytes < DB_BLOAT_MIN_JSONL_BYTES {
push_check(checks, "db_bloat", CheckStatus::Ok, None, None);
return;
}
if db_bytes > jsonl_bytes.saturating_mul(DB_BLOAT_RATIO_THRESHOLD) {
push_check(
checks,
"db_bloat",
CheckStatus::Warn,
Some(format!(
"{} is {}x the size of {} ({}MB vs {}MB); VACUUM would likely reclaim significant space",
db_path.display(),
db_bytes / jsonl_bytes,
jsonl_path.display(),
db_bytes / (1024 * 1024),
jsonl_bytes / (1024 * 1024)
)),
Some(serde_json::json!({
"db_path": db_path.display().to_string(),
"jsonl_path": jsonl_path.display().to_string(),
"db_bytes": db_bytes,
"jsonl_bytes": jsonl_bytes,
"ratio": db_bytes / jsonl_bytes,
"threshold": DB_BLOAT_RATIO_THRESHOLD,
"remediation": "Run `VACUUM` against the selected SQLite database or run `br doctor --repair` if integrity warnings are present",
})),
);
} else {
push_check(checks, "db_bloat", CheckStatus::Ok, None, None);
}
}
const UTF8_BOM: &[u8] = &[0xEF, 0xBB, 0xBF];
fn path_is_inside_workspace(path: &Path, repo_root: &Path) -> bool {
let (Ok(path), Ok(repo_root)) = (fs::canonicalize(path), fs::canonicalize(repo_root)) else {
return false;
};
path.starts_with(repo_root)
}
fn check_jsonl_utf8_bom(jsonl_path: Option<&Path>, checks: &mut Vec<CheckResult>) {
use std::io::Read;
let Some(path) = jsonl_path else {
push_check(checks, "jsonl_bom", CheckStatus::Ok, None, None);
return;
};
let Ok(meta) = fs::symlink_metadata(path) else {
push_check(checks, "jsonl_bom", CheckStatus::Ok, None, None);
return;
};
if meta.file_type().is_symlink() || !meta.is_file() || meta.len() < 3 {
push_check(checks, "jsonl_bom", CheckStatus::Ok, None, None);
return;
}
let Ok(mut f) = fs::File::open(path) else {
push_check(checks, "jsonl_bom", CheckStatus::Ok, None, None);
return;
};
let mut head = [0u8; 3];
if f.read_exact(&mut head).is_err() {
push_check(checks, "jsonl_bom", CheckStatus::Ok, None, None);
return;
}
if head == UTF8_BOM {
push_check(
checks,
"jsonl_bom",
CheckStatus::Warn,
Some(format!(
"{} starts with a UTF-8 BOM (0xEF 0xBB 0xBF); JSONL parsers will fail on the first record",
path.display()
)),
Some(serde_json::json!({
"path": path.display().to_string(),
"remediation": "`br doctor --repair` strips the BOM when the selected JSONL is inside the workspace; otherwise strip it manually",
})),
);
} else {
push_check(checks, "jsonl_bom", CheckStatus::Ok, None, None);
}
}
fn fix_jsonl_utf8_bom_if_warned(
jsonl_path: Option<&Path>,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
let has_warning = report
.checks
.iter()
.any(|c| c.name == "jsonl_bom" && c.status == CheckStatus::Warn);
if !has_warning {
return false;
}
let Some(session) = session else {
if !ctx.is_json() {
ctx.warning("Skipping BOM strip: no doctor repair session (run-dir creation failed)");
}
return false;
};
let Some(path) = jsonl_path else {
return false;
};
let Ok(bytes) = fs::read(path) else {
return false;
};
if !bytes.starts_with(UTF8_BOM) {
return false;
}
if !path_is_inside_workspace(path, &session.ctx.repo_root) {
if !ctx.is_json() {
ctx.warning(&format!(
"Skipping BOM strip for external JSONL outside workspace: {}",
path.display()
));
}
return false;
}
let stripped = bytes[UTF8_BOM.len()..].to_vec();
session.set_fixer("doctor.jsonl_bom_strip");
session
.ctx
.capabilities
.write_scopes
.push(path.to_path_buf());
match chokepoint::mutate(
&session.ctx,
path,
Op::WriteFile {
content: stripped,
mode: None,
},
) {
Ok(result) if result.ok => {
if !ctx.is_json() {
ctx.info(&format!("Stripped UTF-8 BOM from {}", path.display()));
}
true
}
Ok(_) => false,
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!(
"Failed to strip BOM from {}: {err}",
path.display()
));
}
false
}
}
}
fn fix_jsonl_crlf_endings_if_warned(
jsonl_path: Option<&Path>,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
let has_warning = report
.checks
.iter()
.any(|c| c.name == "jsonl_crlf" && c.status == CheckStatus::Warn);
if !has_warning {
return false;
}
let Some(session) = session else {
if !ctx.is_json() {
ctx.warning(
"Skipping CRLF→LF conversion: no doctor repair session (run-dir creation failed)",
);
}
return false;
};
let Some(path) = jsonl_path else {
return false;
};
let Ok(bytes) = fs::read(path) else {
return false;
};
if !bytes.windows(2).any(|w| w == b"\r\n") {
return false;
}
if !path_is_inside_workspace(path, &session.ctx.repo_root) {
if !ctx.is_json() {
ctx.warning(&format!(
"Skipping CRLF→LF conversion for external JSONL outside workspace: {}",
path.display()
));
}
return false;
}
let mut converted = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len() && bytes[i] == b'\r' && bytes[i + 1] == b'\n' {
converted.push(b'\n');
i += 2;
} else {
converted.push(bytes[i]);
i += 1;
}
}
session.set_fixer("doctor.jsonl_crlf_to_lf");
session
.ctx
.capabilities
.write_scopes
.push(path.to_path_buf());
match chokepoint::mutate(
&session.ctx,
path,
Op::WriteFile {
content: converted,
mode: None,
},
) {
Ok(result) if result.ok => {
if !ctx.is_json() {
ctx.info(&format!("Converted CRLF→LF in {}", path.display()));
}
true
}
Ok(_) => false,
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!(
"Failed to convert CRLF→LF in {}: {err}",
path.display()
));
}
false
}
}
}
fn check_jsonl_crlf_endings(jsonl_path: Option<&Path>, checks: &mut Vec<CheckResult>) {
use std::io::Read;
let Some(path) = jsonl_path else {
push_check(checks, "jsonl_crlf", CheckStatus::Ok, None, None);
return;
};
let Ok(meta) = fs::symlink_metadata(path) else {
push_check(checks, "jsonl_crlf", CheckStatus::Ok, None, None);
return;
};
if meta.file_type().is_symlink() || !meta.is_file() || meta.len() == 0 {
push_check(checks, "jsonl_crlf", CheckStatus::Ok, None, None);
return;
}
let Ok(mut f) = fs::File::open(path) else {
push_check(checks, "jsonl_crlf", CheckStatus::Ok, None, None);
return;
};
let len_clamped = usize::try_from(meta.len()).unwrap_or(CRLF_SCAN_PREFIX_BYTES);
let mut buf = vec![0u8; CRLF_SCAN_PREFIX_BYTES.min(len_clamped)];
let Ok(n) = f.read(&mut buf) else {
push_check(checks, "jsonl_crlf", CheckStatus::Ok, None, None);
return;
};
let has_crlf = buf[..n].windows(2).any(|w| w == b"\r\n");
if has_crlf {
push_check(
checks,
"jsonl_crlf",
CheckStatus::Warn,
Some(format!(
"{} contains CRLF line endings (scanned first {} bytes); git diff and streaming JSONL parsers may misbehave",
path.display(),
n
)),
Some(serde_json::json!({
"path": path.display().to_string(),
"scanned_bytes": n,
"remediation": "Convert line endings to LF for the selected JSONL export",
})),
);
} else {
push_check(checks, "jsonl_crlf", CheckStatus::Ok, None, None);
}
}
fn check_jsonl_trailing_newline(jsonl_path: Option<&Path>, checks: &mut Vec<CheckResult>) {
use std::io::{Read, Seek, SeekFrom};
let Some(path) = jsonl_path else {
push_check(checks, "jsonl_eof_newline", CheckStatus::Ok, None, None);
return;
};
let Ok(meta) = fs::symlink_metadata(path) else {
push_check(checks, "jsonl_eof_newline", CheckStatus::Ok, None, None);
return;
};
if meta.file_type().is_symlink() || !meta.is_file() || meta.len() == 0 {
push_check(checks, "jsonl_eof_newline", CheckStatus::Ok, None, None);
return;
}
let Ok(mut f) = fs::File::open(path) else {
push_check(checks, "jsonl_eof_newline", CheckStatus::Ok, None, None);
return;
};
if f.seek(SeekFrom::End(-1)).is_err() {
push_check(checks, "jsonl_eof_newline", CheckStatus::Ok, None, None);
return;
}
let mut last = [0u8; 1];
if f.read_exact(&mut last).is_err() {
push_check(checks, "jsonl_eof_newline", CheckStatus::Ok, None, None);
return;
}
if last[0] == b'\n' {
push_check(checks, "jsonl_eof_newline", CheckStatus::Ok, None, None);
} else {
push_check(
checks,
"jsonl_eof_newline",
CheckStatus::Warn,
Some(format!(
"{} does not end with a newline; line-oriented tools may skip the last record",
path.display()
)),
Some(serde_json::json!({
"path": path.display().to_string(),
"last_byte": last[0],
"remediation": "`br doctor --repair` will append a single newline when the selected JSONL is inside the workspace; otherwise append one manually",
})),
);
}
}
fn fix_jsonl_trailing_newline_if_warned(
jsonl_path: Option<&Path>,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
use std::io::{Read, Seek, SeekFrom};
let has_warning = report
.checks
.iter()
.any(|c| c.name == "jsonl_eof_newline" && c.status == CheckStatus::Warn);
if !has_warning {
return false;
}
let Some(session) = session else {
if !ctx.is_json() {
ctx.warning(
"Skipping jsonl-trailing-newline fix: no doctor repair session (run-dir creation failed)",
);
}
return false;
};
let Some(path) = jsonl_path else {
return false;
};
let Ok(mut f) = fs::File::open(path) else {
return false;
};
if f.seek(SeekFrom::End(-1)).is_err() {
return false;
}
let mut last = [0u8; 1];
if f.read_exact(&mut last).is_err() {
return false;
}
drop(f);
if last[0] == b'\n' {
return false;
}
if !path_is_inside_workspace(path, &session.ctx.repo_root) {
if !ctx.is_json() {
ctx.warning(&format!(
"Skipping jsonl-trailing-newline fix for external JSONL outside workspace: {}",
path.display()
));
}
return false;
}
session.set_fixer("doctor.jsonl_trailing_newline_append");
session
.ctx
.capabilities
.write_scopes
.push(path.to_path_buf());
match chokepoint::mutate(
&session.ctx,
path,
Op::AppendFile {
content: b"\n".to_vec(),
},
) {
Ok(result) if result.ok => {
if !ctx.is_json() {
ctx.info(&format!("Appended trailing newline to {}", path.display()));
}
true
}
Ok(_) => false,
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!(
"Failed to append trailing newline to {}: {err}",
path.display()
));
}
false
}
}
}
const BR_HISTORY_SNAPSHOT_THRESHOLD: usize = 100;
fn check_br_history_size(beads_dir: &Path, checks: &mut Vec<CheckResult>) {
let history = beads_dir.join(".br_history");
let backups = match crate::sync::history::list_backups(&history, None) {
Ok(backups) => backups,
Err(err) => {
push_check(
checks,
"br_history.size",
CheckStatus::Warn,
Some(format!(
"Could not inspect history directory {}: {err}",
history.display()
)),
Some(serde_json::json!({
"history_dir": history.display().to_string(),
"error": err.to_string(),
"remediation": "Inspect .beads/.br_history/ permissions and symlink shape",
})),
);
return;
}
};
let snapshot_count = backups.len();
if snapshot_count > BR_HISTORY_SNAPSHOT_THRESHOLD {
push_check(
checks,
"br_history.size",
CheckStatus::Warn,
Some(format!(
"{} snapshot file(s) accumulated in {}; consider pruning",
snapshot_count,
history.display()
)),
Some(serde_json::json!({
"history_dir": history.display().to_string(),
"snapshot_count": snapshot_count,
"threshold": BR_HISTORY_SNAPSHOT_THRESHOLD,
"remediation": "Review and archive old snapshots; `br history` commands manage selectively",
})),
);
} else {
push_check(checks, "br_history.size", CheckStatus::Ok, None, None);
}
}
const JSONL_OVERSIZED_THRESHOLD_BYTES: u64 = 100 * 1024 * 1024;
fn check_jsonl_oversized(jsonl_path: Option<&Path>, checks: &mut Vec<CheckResult>) {
let Some(path) = jsonl_path else {
push_check(checks, "jsonl_size", CheckStatus::Ok, None, None);
return;
};
let Ok(meta) = fs::symlink_metadata(path) else {
push_check(checks, "jsonl_size", CheckStatus::Ok, None, None);
return;
};
if meta.file_type().is_symlink() || !meta.is_file() {
push_check(checks, "jsonl_size", CheckStatus::Ok, None, None);
return;
}
let bytes = meta.len();
if bytes > JSONL_OVERSIZED_THRESHOLD_BYTES {
push_check(
checks,
"jsonl_size",
CheckStatus::Warn,
Some(format!(
"{} is {} MB (>{} MB threshold); flushes will be slow and may pressure low-RAM hosts",
path.display(),
bytes / (1024 * 1024),
JSONL_OVERSIZED_THRESHOLD_BYTES / (1024 * 1024)
)),
Some(serde_json::json!({
"path": path.display().to_string(),
"size_bytes": bytes,
"threshold_bytes": JSONL_OVERSIZED_THRESHOLD_BYTES,
"remediation": "Close stale issues, archive old comments, or split the workspace",
})),
);
} else {
push_check(checks, "jsonl_size", CheckStatus::Ok, None, None);
}
}
fn check_jsonl_duplicate_ids(jsonl_path: Option<&Path>, checks: &mut Vec<CheckResult>) {
use std::collections::BTreeMap;
use std::io::{BufRead, BufReader};
let Some(path) = jsonl_path else {
push_check(checks, "jsonl.duplicate_ids", CheckStatus::Ok, None, None);
return;
};
let Ok(meta) = fs::symlink_metadata(path) else {
push_check(checks, "jsonl.duplicate_ids", CheckStatus::Ok, None, None);
return;
};
if meta.file_type().is_symlink() || !meta.is_file() || meta.len() == 0 {
push_check(checks, "jsonl.duplicate_ids", CheckStatus::Ok, None, None);
return;
}
let Ok(file) = fs::File::open(path) else {
push_check(checks, "jsonl.duplicate_ids", CheckStatus::Ok, None, None);
return;
};
let mut id_counts = BTreeMap::<String, u64>::new();
for line in BufReader::new(file)
.lines()
.map_while(std::result::Result::ok)
{
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(record) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if let Some(id) = record.get("id").and_then(serde_json::Value::as_str)
&& !id.is_empty()
{
*id_counts.entry(id.to_string()).or_default() += 1;
}
}
let duplicate_counts: Vec<(&String, u64)> = id_counts
.iter()
.filter_map(|(id, count)| (*count > 1).then_some((id, *count)))
.collect();
if duplicate_counts.is_empty() {
push_check(checks, "jsonl.duplicate_ids", CheckStatus::Ok, None, None);
return;
}
let total_dup_records: u64 = duplicate_counts.iter().map(|(_, count)| *count).sum();
let sample_ids: Vec<&str> = duplicate_counts
.iter()
.take(5)
.map(|(id, _)| id.as_str())
.collect();
push_check(
checks,
"jsonl.duplicate_ids",
CheckStatus::Warn,
Some(format!(
"{} contains {} duplicate id(s) across {} record(s); usually an unresolved merge artifact",
path.display(),
duplicate_counts.len(),
total_dup_records
)),
Some(serde_json::json!({
"path": path.display().to_string(),
"distinct_duplicate_ids": duplicate_counts.len(),
"total_duplicate_records": total_dup_records,
"sample_duplicate_ids": sample_ids,
"remediation": "If SQLite is authoritative, run `br sync --flush-only` to regenerate JSONL; if JSONL is authoritative, edit out stale duplicate records before import/rebuild",
})),
);
}
const ORPHAN_TMP_AGE_THRESHOLD_SECS: u64 = 60 * 60;
#[allow(clippy::case_sensitive_file_extension_comparisons)]
fn is_orphan_tmp_name(name: &str) -> bool {
name.ends_with(".tmp")
|| (name.contains(".tmp.")
&& name
.rsplit('.')
.next()
.is_some_and(|s| s.chars().all(|c| c.is_ascii_digit())))
}
fn orphan_tmp_entry(
entry: &fs::DirEntry,
now: std::time::SystemTime,
threshold: std::time::Duration,
) -> Option<(String, PathBuf)> {
let name_os = entry.file_name();
let name = name_os.to_str()?;
if !is_orphan_tmp_name(name) {
return None;
}
let file_type = entry.file_type().ok()?;
if !file_type.is_file() {
return None;
}
let meta = entry.metadata().ok()?;
let mtime = meta.modified().ok()?;
if now
.duration_since(mtime)
.map(|age| age > threshold)
.unwrap_or(false)
{
Some((name.to_string(), entry.path()))
} else {
None
}
}
fn check_orphan_tmp_files(beads_dir: &Path, checks: &mut Vec<CheckResult>) {
use std::time::{Duration, SystemTime};
let Ok(entries) = fs::read_dir(beads_dir) else {
push_check(checks, "tmp_files_orphan", CheckStatus::Ok, None, None);
return;
};
let now = SystemTime::now();
let threshold = Duration::from_secs(ORPHAN_TMP_AGE_THRESHOLD_SECS);
let mut orphans: Vec<String> = Vec::new();
for entry in entries.flatten() {
if let Some((name, _path)) = orphan_tmp_entry(&entry, now, threshold) {
orphans.push(name);
}
}
orphans.sort();
if orphans.is_empty() {
push_check(checks, "tmp_files_orphan", CheckStatus::Ok, None, None);
} else {
push_check(
checks,
"tmp_files_orphan",
CheckStatus::Warn,
Some(format!(
"{} orphan tmp file(s) older than {}s under {}",
orphans.len(),
ORPHAN_TMP_AGE_THRESHOLD_SECS,
beads_dir.display()
)),
Some(serde_json::json!({
"files": orphans,
"age_threshold_secs": ORPHAN_TMP_AGE_THRESHOLD_SECS,
"remediation": "Verify no peer process is writing, then run br doctor --repair to quarantine the tmp files",
})),
);
}
}
fn check_jsonl_world_writable(jsonl_path: Option<&Path>, checks: &mut Vec<CheckResult>) {
let Some(path) = jsonl_path else {
push_check(
checks,
"permissions.jsonl_world_writable",
CheckStatus::Ok,
None,
None,
);
return;
};
let Ok(meta) = fs::symlink_metadata(path) else {
push_check(
checks,
"permissions.jsonl_world_writable",
CheckStatus::Ok,
None,
None,
);
return;
};
if meta.file_type().is_symlink() {
push_check(
checks,
"permissions.jsonl_world_writable",
CheckStatus::Ok,
None,
None,
);
return;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = meta.permissions().mode();
if (mode & 0o002) != 0 {
push_check(
checks,
"permissions.jsonl_world_writable",
CheckStatus::Warn,
Some(format!(
"{} is world-writable (mode {:o}); anyone could inject issues that `br sync` reimports",
path.display(),
mode & 0o777
)),
Some(serde_json::json!({
"path": path.display().to_string(),
"mode_octal": format!("{:o}", mode & 0o777),
"remediation": format!("chmod o-w {}", path.display()),
})),
);
return;
}
}
push_check(
checks,
"permissions.jsonl_world_writable",
CheckStatus::Ok,
None,
None,
);
}
fn fix_jsonl_world_writable_if_warned(
jsonl_path: Option<&Path>,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
let has_warning = report
.checks
.iter()
.any(|c| c.name == "permissions.jsonl_world_writable" && c.status == CheckStatus::Warn);
if !has_warning {
return false;
}
let Some(path) = jsonl_path else {
if !ctx.is_json() {
ctx.warning(
"Skipping world-writable chmod: no JSONL path resolved (workspace not initialised)",
);
}
return false;
};
let Some(session) = session else {
if !ctx.is_json() {
ctx.warning(
"Skipping world-writable chmod: no doctor repair session (run-dir creation failed)",
);
}
return false;
};
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let Ok(meta) = fs::symlink_metadata(path) else {
return false;
};
if meta.file_type().is_symlink() || !meta.is_file() {
return false;
}
let current = meta.permissions().mode();
if (current & 0o002) == 0 {
return false;
}
if !path_is_inside_workspace(path, &session.ctx.repo_root) {
if !ctx.is_json() {
ctx.warning(&format!(
"Skipping world-writable chmod for external JSONL outside workspace: {}",
path.display()
));
}
return false;
}
let new_mode = current & !0o002;
session.set_fixer("doctor.jsonl_world_writable_chmod");
session
.ctx
.capabilities
.write_scopes
.push(path.to_path_buf());
match chokepoint::mutate(&session.ctx, path, Op::Chmod { mode: new_mode }) {
Ok(result) if result.ok => {
if !ctx.is_json() {
ctx.info(&format!(
"Stripped world-write bit from {} (mode {:o}→{:o})",
path.display(),
current & 0o777,
new_mode & 0o777
));
}
true
}
Ok(_) => false,
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!(
"Failed to chmod world-write bit off {}: {err}",
path.display()
));
}
false
}
}
}
#[cfg(not(unix))]
{
let _ = (path, session);
false
}
}
fn check_inner_gitignore_present(beads_dir: &Path, checks: &mut Vec<CheckResult>) {
let path = beads_dir.join(".gitignore");
let meta = match fs::symlink_metadata(&path) {
Ok(meta) => meta,
Err(err) if err.kind() == io::ErrorKind::NotFound => {
push_check(
checks,
"gitignore.beads_inner_present",
CheckStatus::Warn,
Some(format!(
"{} is missing; transient .beads/ state can leak into git history",
path.display()
)),
Some(serde_json::json!({
"path": path.display().to_string(),
"kind": "missing",
"expected_patterns": inner_gitignore_all_append_patterns(),
"remediation": format!(
"Create {} with at least: {}",
path.display(),
inner_gitignore_all_append_patterns().join(", ")
),
})),
);
return;
}
Err(_) => {
push_check(
checks,
"gitignore.beads_inner_present",
CheckStatus::Ok,
None,
None,
);
return;
}
};
if meta.file_type().is_symlink() {
push_check(
checks,
"gitignore.beads_inner_present",
CheckStatus::Warn,
Some(format!(
"{} is a symlink; git ignore files in the working tree must be regular files to reliably protect .beads/ state",
path.display()
)),
Some(serde_json::json!({
"path": path.display().to_string(),
"kind": "symlink",
"expected_patterns": inner_gitignore_all_append_patterns(),
"remediation": format!(
"Replace {} with a regular file containing at least: {}",
path.display(),
inner_gitignore_all_append_patterns().join(", ")
),
})),
);
return;
}
let Ok(contents) = fs::read_to_string(&path) else {
push_check(
checks,
"gitignore.beads_inner_present",
CheckStatus::Ok,
None,
None,
);
return;
};
let missing = inner_gitignore_missing_patterns(&contents);
if missing.is_empty() {
push_check(
checks,
"gitignore.beads_inner_present",
CheckStatus::Ok,
None,
None,
);
} else {
push_check(
checks,
"gitignore.beads_inner_present",
CheckStatus::Warn,
Some(format!(
"{} exists but is missing expected pattern(s): {}",
path.display(),
missing.join(", ")
)),
Some(serde_json::json!({
"path": path.display().to_string(),
"kind": "incomplete",
"missing_patterns": missing,
})),
);
}
}
struct InnerGitignoreExpectation {
append_pattern: &'static str,
probes: &'static [&'static str],
}
const INNER_GITIGNORE_EXPECTATIONS: &[InnerGitignoreExpectation] = &[
InnerGitignoreExpectation {
append_pattern: "*.db",
probes: &["beads.db"],
},
InnerGitignoreExpectation {
append_pattern: "*.db-journal",
probes: &["beads.db-journal"],
},
InnerGitignoreExpectation {
append_pattern: "*.db-shm",
probes: &["beads.db-shm"],
},
InnerGitignoreExpectation {
append_pattern: "*.db-wal*",
probes: &[
"beads.db-wal",
"beads.db-wal-cert",
"beads.db-wal-cert-head",
],
},
InnerGitignoreExpectation {
append_pattern: "*-fsqlite-ns-gate",
probes: &[
"beads.db-fsqlite-ns-gate",
".beads.db.schema-migration-20260101T000000.000000Z-0-0.vacuum-fsqlite-ns-gate",
],
},
InnerGitignoreExpectation {
append_pattern: "*-fsqlite-ns-use",
probes: &[
"beads.db-fsqlite-ns-use",
".beads.db.schema-migration-20260101T000000.000000Z-0-0.vacuum-fsqlite-ns-use",
],
},
InnerGitignoreExpectation {
append_pattern: "*.vacuum-wal-cert*",
probes: &[
".beads.db.schema-migration-20260101T000000.000000Z-0-0.vacuum-wal-cert",
".beads.db.schema-migration-20260101T000000.000000Z-0-0.vacuum-wal-cert-head",
],
},
InnerGitignoreExpectation {
append_pattern: "*.fsqlite-migration-state",
probes: &["beads.db.fsqlite-migration-state"],
},
InnerGitignoreExpectation {
append_pattern: ".write.lock",
probes: &[".write.lock"],
},
InnerGitignoreExpectation {
append_pattern: "*.tmp",
probes: &["probe.tmp"],
},
];
fn inner_gitignore_all_append_patterns() -> Vec<&'static str> {
INNER_GITIGNORE_EXPECTATIONS
.iter()
.map(|expectation| expectation.append_pattern)
.collect()
}
fn inner_gitignore_missing_patterns(contents: &str) -> Vec<&'static str> {
INNER_GITIGNORE_EXPECTATIONS
.iter()
.filter(|expectation| {
expectation
.probes
.iter()
.any(|probe| !inner_gitignore_ignores_probe(contents, probe))
})
.map(|expectation| expectation.append_pattern)
.collect()
}
fn inner_gitignore_ignores_probe(contents: &str, probe: &str) -> bool {
let mut ignored = false;
for raw_line in contents.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (negated, pattern) = line
.strip_prefix('!')
.map_or((false, line), |pattern| (true, pattern));
let pattern = pattern.strip_prefix('/').unwrap_or(pattern);
if pattern.is_empty() || pattern.ends_with('/') || pattern.contains('/') {
continue;
}
if gitignore_glob_matches(pattern, probe) {
ignored = !negated;
}
}
ignored
}
fn gitignore_glob_matches(pattern: &str, name: &str) -> bool {
let pattern = pattern.as_bytes();
let name = name.as_bytes();
let (mut p, mut n) = (0usize, 0usize);
let mut star: Option<(usize, usize)> = None;
while n < name.len() {
if p < pattern.len() && (pattern[p] == b'?' || pattern[p] == name[n]) {
p += 1;
n += 1;
} else if p < pattern.len() && pattern[p] == b'*' {
star = Some((p, n));
p += 1;
} else if let Some((star_p, star_n)) = star {
p = star_p + 1;
n = star_n + 1;
star = Some((star_p, star_n + 1));
} else {
return false;
}
}
while p < pattern.len() && pattern[p] == b'*' {
p += 1;
}
p == pattern.len()
}
fn fix_inner_gitignore_if_warned(
beads_dir: &Path,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
let has_warning = report
.checks
.iter()
.any(|c| c.name == "gitignore.beads_inner_present" && c.status == CheckStatus::Warn);
if !has_warning {
return false;
}
let Some(session) = session else {
if !ctx.is_json() {
ctx.warning(
"Skipping inner-gitignore repair: no doctor repair session (run-dir creation failed)",
);
}
return false;
};
let path = beads_dir.join(".gitignore");
let existing = match fs::symlink_metadata(&path) {
Ok(meta) if meta.file_type().is_symlink() => {
return false;
}
Ok(_) => fs::read_to_string(&path).unwrap_or_default(),
Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
Err(_) => return false,
};
let missing = inner_gitignore_missing_patterns(&existing);
if missing.is_empty() {
return false;
}
let mut content: Vec<u8> = Vec::new();
if !existing.is_empty() && !existing.ends_with('\n') {
content.push(b'\n');
}
for pattern in &missing {
content.extend_from_slice(pattern.as_bytes());
content.push(b'\n');
}
session.set_fixer("doctor.inner_gitignore_append");
match chokepoint::mutate(&session.ctx, &path, Op::AppendFile { content }) {
Ok(result) if result.ok => {
if !ctx.is_json() {
ctx.info(&format!(
"Appended missing pattern(s) to {}: {}",
path.display(),
missing.join(", ")
));
}
true
}
Ok(_) => false,
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!(
"Failed to append patterns to {}: {err}",
path.display()
));
}
false
}
}
}
fn br_binaries_in_path_str(path_var: &str) -> Vec<PathBuf> {
let mut found = Vec::new();
let mut seen = std::collections::HashSet::new();
for dir in std::env::split_paths(path_var) {
let candidate = dir.join("br");
let canonical = candidate.canonicalize().ok();
let key = canonical.clone().unwrap_or_else(|| candidate.clone());
if seen.contains(&key) {
continue;
}
if candidate.is_file() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(&candidate)
&& (meta.permissions().mode() & 0o111) != 0
{
found.push(candidate.clone());
seen.insert(key);
}
}
#[cfg(not(unix))]
{
found.push(candidate.clone());
seen.insert(key);
}
}
}
found
}
fn check_multiple_br_in_path(checks: &mut Vec<CheckResult>) {
let path_var = std::env::var("PATH").unwrap_or_default();
let binaries = br_binaries_in_path_str(&path_var);
if binaries.len() <= 1 {
push_check(checks, "br_path_dupes", CheckStatus::Ok, None, None);
return;
}
let display: Vec<String> = binaries.iter().map(|p| p.display().to_string()).collect();
push_check(
checks,
"br_path_dupes",
CheckStatus::Warn,
Some(format!(
"Found {} `br` executables on $PATH — operator may be confused which one runs",
binaries.len()
)),
Some(serde_json::json!({
"br_paths": display,
"remediation": "Reorder PATH so the canonical install resolves first, or remove stale copies",
})),
);
}
fn check_config_yaml_secret_mode(beads_dir: &Path, checks: &mut Vec<CheckResult>) {
const SECRET_KEYWORDS: &[&str] = &["token", "secret", "password", "api_key", "private_key"];
let config = beads_dir.join("config.yaml");
let Ok(meta) = fs::symlink_metadata(&config) else {
push_check(
checks,
"permissions.config_yaml_secrets",
CheckStatus::Ok,
None,
None,
);
return;
};
if meta.file_type().is_symlink() {
push_check(
checks,
"permissions.config_yaml_secrets",
CheckStatus::Ok,
None,
None,
);
return;
}
#[cfg(unix)]
let world_readable = {
use std::os::unix::fs::PermissionsExt;
let mode = meta.permissions().mode();
(mode & 0o004) != 0
};
#[cfg(not(unix))]
let world_readable = false;
if !world_readable {
push_check(
checks,
"permissions.config_yaml_secrets",
CheckStatus::Ok,
None,
None,
);
return;
}
let Ok(contents) = fs::read_to_string(&config) else {
push_check(
checks,
"permissions.config_yaml_secrets",
CheckStatus::Ok,
None,
None,
);
return;
};
let lower = contents.to_ascii_lowercase();
let matched: Vec<&str> = SECRET_KEYWORDS
.iter()
.copied()
.filter(|kw| lower.contains(kw))
.collect();
if matched.is_empty() {
push_check(
checks,
"permissions.config_yaml_secrets",
CheckStatus::Ok,
None,
None,
);
return;
}
let mode_bits = {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o777
}
#[cfg(not(unix))]
{
0u32
}
};
push_check(
checks,
"permissions.config_yaml_secrets",
CheckStatus::Warn,
Some(format!(
"{} is world-readable (mode {:o}) and appears to contain secret-shaped values; consider `chmod 0600 {}`",
config.display(),
mode_bits,
config.display()
)),
Some(serde_json::json!({
"path": config.display().to_string(),
"mode_octal": format!("{mode_bits:o}"),
"matched_keywords": matched,
"remediation": format!("chmod 0600 {}", config.display()),
})),
);
}
fn fix_config_yaml_secret_mode_if_warned(
beads_dir: &Path,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
let has_warning = report
.checks
.iter()
.any(|c| c.name == "permissions.config_yaml_secrets" && c.status == CheckStatus::Warn);
if !has_warning {
return false;
}
let Some(session) = session else {
if !ctx.is_json() {
ctx.warning(
"Skipping config.yaml secrets chmod: no doctor repair session (run-dir creation failed)",
);
}
return false;
};
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let path = beads_dir.join("config.yaml");
let Ok(meta) = fs::symlink_metadata(&path) else {
return false;
};
if meta.file_type().is_symlink() || !meta.is_file() {
return false;
}
let current = meta.permissions().mode();
if (current & 0o006) == 0 {
return false;
}
let new_mode = current & !0o006;
session.set_fixer("doctor.config_yaml_secret_chmod");
match chokepoint::mutate(&session.ctx, &path, Op::Chmod { mode: new_mode }) {
Ok(result) if result.ok => {
if !ctx.is_json() {
ctx.info(&format!(
"Stripped world-read/write bits from {} (mode {:o}→{:o})",
path.display(),
current & 0o777,
new_mode & 0o777
));
}
true
}
Ok(_) => false,
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!(
"Failed to chmod config.yaml secret mode {}: {err}",
path.display()
));
}
false
}
}
}
#[cfg(not(unix))]
{
let _ = (beads_dir, session);
false
}
}
fn check_db_sidecar_modes(db_path: &Path, checks: &mut Vec<CheckResult>) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
use std::os::unix::fs::MetadataExt;
let mut offenders = Vec::new();
for sidecar in fsqlite_namespace_sidecar_paths(db_path) {
let Ok(meta) = fs::symlink_metadata(&sidecar) else {
continue;
};
if !meta.is_file() || meta.file_type().is_symlink() {
continue;
}
let mode = meta.permissions().mode() & 0o7777;
if !SqliteStorage::namespace_sidecar_mode_is_admitted(mode, meta.gid(), db_path) {
offenders.push((sidecar, mode));
}
}
if offenders.is_empty() {
push_check(
checks,
"permissions.db_sidecars",
CheckStatus::Ok,
None,
None,
);
return;
}
let summary = offenders
.iter()
.map(|(path, mode)| format!("{} (mode {mode:04o})", path.display()))
.collect::<Vec<_>>()
.join(", ");
let database_mask = fs::symlink_metadata(db_path)
.ok()
.filter(|meta| meta.is_file() && !meta.file_type().is_symlink())
.map(|meta| meta.permissions().mode() & 0o077);
let filesystem_mask_suspected = database_mask
.is_some_and(|mask| offenders.iter().all(|(_, mode)| mode & 0o077 == mask));
let mut remediation = offenders
.iter()
.map(|(path, _)| format!("chmod 0600 {}", path.display()))
.collect::<Vec<_>>()
.join("; ");
if filesystem_mask_suspected {
remediation.push_str(
"; if chmod has no effect, the filesystem does not persist POSIX permission bits \
(a Windows drive under WSL mounted without the `metadata` option, FAT/exFAT): \
mount it with `metadata` or keep the .beads database on the Linux filesystem",
);
}
push_check(
checks,
"permissions.db_sidecars",
CheckStatus::Warn,
Some(format!(
"fsqlite namespace sidecar(s) carry group/other permission the engine refuses and \
block every database open until the mode is owner-only (0600): {summary}. {}",
SqliteStorage::engine_namespace_sidecar_rule()
)),
Some(serde_json::json!({
"sidecars": offenders
.iter()
.map(|(path, mode)| serde_json::json!({
"path": path.display().to_string(),
"mode_octal": format!("{mode:04o}"),
}))
.collect::<Vec<_>>(),
"required_mode_octal": "0600",
"engine_rule": SqliteStorage::engine_namespace_sidecar_rule(),
"filesystem_mask_suspected": filesystem_mask_suspected,
"remediation": remediation,
})),
);
}
#[cfg(not(unix))]
{
let _ = db_path;
push_check(
checks,
"permissions.db_sidecars",
CheckStatus::Ok,
None,
None,
);
}
}
fn fix_db_sidecar_modes_if_warned(
db_path: &Path,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
let has_warning = report
.checks
.iter()
.any(|c| c.name == "permissions.db_sidecars" && c.status == CheckStatus::Warn);
if !has_warning {
return false;
}
let Some(session) = session else {
if !ctx.is_json() {
ctx.warning(
"Skipping database sidecar chmod: no doctor repair session (run-dir creation failed)",
);
}
return false;
};
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let mut repaired_any = false;
for sidecar in fsqlite_namespace_sidecar_paths(db_path) {
let Ok(meta) = fs::symlink_metadata(&sidecar) else {
continue;
};
if meta.file_type().is_symlink() || !meta.is_file() {
continue;
}
let current = meta.permissions().mode();
if current.trailing_zeros() >= 6
|| SqliteStorage::namespace_sidecar_mode_is_admitted(
current & 0o7777,
meta.gid(),
db_path,
)
{
continue;
}
let new_mode = current & !0o077;
session.set_fixer("doctor.db_sidecar_mode_chmod");
match chokepoint::mutate(&session.ctx, &sidecar, Op::Chmod { mode: new_mode }) {
Ok(result) if result.ok => {
let observed = fs::symlink_metadata(&sidecar)
.map(|meta| meta.permissions().mode())
.unwrap_or(new_mode);
if observed & 0o077 != 0 {
if !ctx.is_json() {
ctx.warning(&format!(
"chmod on database sidecar {} returned success but the mode is still {:04o}: this filesystem does not persist POSIX permission bits (a Windows drive under WSL mounted without `metadata`, FAT/exFAT). Mount it with `metadata` or keep the .beads database on the Linux filesystem",
sidecar.display(),
observed & 0o7777
));
}
continue;
}
repaired_any = true;
if !ctx.is_json() {
ctx.info(&format!(
"Restored owner-only mode on {} ({:o}→{:o})",
sidecar.display(),
current & 0o777,
new_mode & 0o777
));
}
}
Ok(_) => {}
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!(
"Failed to chmod database sidecar {}: {err}",
sidecar.display()
));
}
}
}
}
repaired_any
}
#[cfg(not(unix))]
{
let _ = (db_path, session);
false
}
}
const DOCTOR_RUNS_THRESHOLD: usize = 50;
fn check_doctor_runs_dir_size(repo_root: &Path, checks: &mut Vec<CheckResult>) {
let runs_dir = repo_root.join(".doctor").join("runs");
let entries = match fs::read_dir(&runs_dir) {
Ok(e) => e,
Err(err) if err.kind() == io::ErrorKind::NotFound => {
push_check(checks, "doctor.runs_dir", CheckStatus::Ok, None, None);
return;
}
Err(_) => {
push_check(checks, "doctor.runs_dir", CheckStatus::Ok, None, None);
return;
}
};
let run_count = entries
.flatten()
.filter(|entry| entry.path().is_dir())
.count();
if run_count > DOCTOR_RUNS_THRESHOLD {
push_check(
checks,
"doctor.runs_dir",
CheckStatus::Warn,
Some(format!(
"{run_count} run directories accumulated in {}; consider pruning (operator-driven; doctor cannot auto-prune its own audit history)",
runs_dir.display()
)),
Some(serde_json::json!({
"runs_dir": runs_dir.display().to_string(),
"run_count": run_count,
"threshold": DOCTOR_RUNS_THRESHOLD,
"remediation": "Operator: review and prune via `find .doctor/runs/ -maxdepth 1 -mtime +30 | xargs mv -t .doctor/runs/quarantine/`",
})),
);
} else {
push_check(checks, "doctor.runs_dir", CheckStatus::Ok, None, None);
}
}
fn check_doctor_runs_creatable(repo_root: &Path, checks: &mut Vec<CheckResult>) {
let doctor_dir = repo_root.join(".doctor");
let Ok(meta) = fs::symlink_metadata(&doctor_dir) else {
push_check(checks, "doctor.runs_creatable", CheckStatus::Ok, None, None);
return;
};
if !meta.is_dir() {
push_check(checks, "doctor.runs_creatable", CheckStatus::Ok, None, None);
return;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = meta.permissions().mode();
if (mode & 0o200) == 0 {
push_check(
checks,
"doctor.runs_creatable",
CheckStatus::Warn,
Some(format!(
"{} is not writable by owner (mode {:o}); next `br doctor --repair` will fail to create a per-run audit dir",
doctor_dir.display(),
mode & 0o777
)),
Some(serde_json::json!({
"path": doctor_dir.display().to_string(),
"mode_octal": format!("{:o}", mode & 0o777),
"remediation": format!("`chmod u+w {}` or move it aside before re-running `--repair`", doctor_dir.display()),
})),
);
return;
}
}
push_check(checks, "doctor.runs_creatable", CheckStatus::Ok, None, None);
}
fn check_recovery_dir_writable(db_path: &Path, beads_dir: &Path, checks: &mut Vec<CheckResult>) {
let recovery_dir = config::recovery_dir_for_db_path(db_path, beads_dir);
let Ok(meta) = fs::symlink_metadata(&recovery_dir) else {
push_check(
checks,
"permissions.recovery_dir",
CheckStatus::Ok,
None,
None,
);
return;
};
if !meta.is_dir() {
push_check(
checks,
"permissions.recovery_dir",
CheckStatus::Ok,
None,
None,
);
return;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = meta.permissions().mode();
if (mode & 0o200) == 0 {
push_check(
checks,
"permissions.recovery_dir",
CheckStatus::Warn,
Some(format!(
"{} is not writable by owner (mode {:o}); next `br doctor --repair` will fail to quarantine the DB family before rebuild",
recovery_dir.display(),
mode & 0o777
)),
Some(serde_json::json!({
"path": recovery_dir.display().to_string(),
"mode_octal": format!("{:o}", mode & 0o777),
"remediation": format!("`chmod u+w {}` or move it aside before re-running `--repair`", recovery_dir.display()),
})),
);
return;
}
}
push_check(
checks,
"permissions.recovery_dir",
CheckStatus::Ok,
None,
None,
);
}
fn check_write_lock_writable(beads_dir: &Path, checks: &mut Vec<CheckResult>) {
let lock_path = beads_dir.join(".write.lock");
let Ok(meta) = fs::symlink_metadata(&lock_path) else {
push_check(
checks,
"permissions.write_lock",
CheckStatus::Ok,
None,
None,
);
return;
};
if meta.file_type().is_symlink() || !meta.is_file() {
push_check(
checks,
"permissions.write_lock",
CheckStatus::Ok,
Some(
".beads/.write.lock is not a regular file; permission probe skipped \
(the write_lock check reports the non-regular node itself)"
.to_string(),
),
None,
);
return;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = meta.permissions().mode();
if (mode & 0o200) == 0 {
push_check(
checks,
"permissions.write_lock",
CheckStatus::Warn,
Some(format!(
"{} is not writable by owner (mode {:o}); every mutating `br` invocation will fail to acquire the workspace lock",
lock_path.display(),
mode & 0o777
)),
Some(serde_json::json!({
"path": lock_path.display().to_string(),
"mode_octal": format!("{:o}", mode & 0o777),
"remediation": format!("`chmod u+w {}` (preserve the existing lock file)", lock_path.display()),
})),
);
return;
}
}
push_check(
checks,
"permissions.write_lock",
CheckStatus::Ok,
None,
None,
);
}
fn check_root_gitignore_writable(repo_root: &Path, checks: &mut Vec<CheckResult>) {
let gitignore = repo_root.join(".gitignore");
let Ok(meta) = fs::symlink_metadata(&gitignore) else {
push_check(
checks,
"permissions.root_gitignore",
CheckStatus::Ok,
None,
None,
);
return;
};
if meta.file_type().is_symlink() || !meta.is_file() {
push_check(
checks,
"permissions.root_gitignore",
CheckStatus::Ok,
None,
None,
);
return;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = meta.permissions().mode();
if (mode & 0o200) == 0 {
push_check(
checks,
"permissions.root_gitignore",
CheckStatus::Warn,
Some(format!(
"{} is not writable by owner (mode {:o}); `doctor --repair` will skip `.gitignore` repair until owner-write is restored",
gitignore.display(),
mode & 0o777
)),
Some(serde_json::json!({
"path": gitignore.display().to_string(),
"mode_octal": format!("{:o}", mode & 0o777),
"remediation": format!("`chmod u+w {}` or hand-edit before re-running `--repair`", gitignore.display()),
})),
);
return;
}
}
push_check(
checks,
"permissions.root_gitignore",
CheckStatus::Ok,
None,
None,
);
}
fn check_dirty_bitmap_divergence(conn: &Connection, checks: &mut Vec<CheckResult>) {
let Ok(rows) = conn.query(
"SELECT COUNT(*) FROM dirty_issues d LEFT JOIN issues i ON d.issue_id = i.id WHERE i.id IS NULL",
) else {
push_check(checks, "dirty_bitmap", CheckStatus::Ok, None, None);
return;
};
let orphan_count = rows
.first()
.and_then(|row| row.values().first().cloned())
.and_then(|v| match v {
SqliteValue::Integer(n) => Some(n),
_ => None,
})
.unwrap_or(0);
if orphan_count == 0 {
push_check(checks, "dirty_bitmap", CheckStatus::Ok, None, None);
return;
}
let sample: Vec<String> = match conn.query(
"SELECT d.issue_id FROM dirty_issues d LEFT JOIN issues i ON d.issue_id = i.id WHERE i.id IS NULL LIMIT 5",
) {
Ok(rows) => rows
.iter()
.filter_map(|row| row.values().first().cloned())
.filter_map(|v| match v {
SqliteValue::Text(s) => Some(s.to_string()),
_ => None,
})
.collect(),
Err(_) => Vec::new(),
};
push_check(
checks,
"dirty_bitmap",
CheckStatus::Warn,
Some(format!(
"{orphan_count} orphan row(s) in dirty_issues — issue_id has no matching issues row (FK guard probably bypassed during a delete)"
)),
Some(serde_json::json!({
"orphan_count": orphan_count,
"sample_issue_ids": sample,
"remediation": "br doctor --repair surgically prunes orphan dirty_issues rows via chokepointed DELETE (full JSONL rebuild remains the heavy hammer alternative)",
})),
);
}
const DIRTY_BITMAP_ORPHAN_PREDICATE: &str =
"NOT EXISTS (SELECT 1 FROM issues WHERE issues.id = dirty_issues.issue_id)";
fn fix_dirty_bitmap_orphans_if_warned(
db_path: &Path,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
let has_warning = report
.checks
.iter()
.any(|c| c.name == "dirty_bitmap" && c.status == CheckStatus::Warn);
if !has_warning {
return false;
}
let Some(session) = session else {
if !ctx.is_json() {
ctx.warning(
"Skipping dirty-bitmap orphan prune: no doctor repair session (run-dir creation failed)",
);
}
return false;
};
session.set_fixer("doctor.dirty_bitmap_orphan_prune");
let op = Op::DbExec {
sql: format!("DELETE FROM dirty_issues WHERE {DIRTY_BITMAP_ORPHAN_PREDICATE}"),
args: Vec::new(),
affected_tables: vec!["dirty_issues".to_string()],
affected_predicate: Some(DIRTY_BITMAP_ORPHAN_PREDICATE.to_string()),
};
match chokepoint::mutate(&session.ctx, db_path, op) {
Ok(result) if result.ok => {
if !ctx.is_json() {
ctx.info("Pruned orphan rows from dirty_issues");
}
true
}
Ok(_) => false,
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!("Failed to prune orphan dirty_issues rows: {err}"));
}
false
}
}
}
fn check_comments_orphans(conn: &Connection, checks: &mut Vec<CheckResult>) {
let Ok(rows) = conn.query(
"SELECT COUNT(*) FROM comments c LEFT JOIN issues i ON c.issue_id = i.id WHERE i.id IS NULL",
) else {
push_check(checks, "comments.orphans", CheckStatus::Ok, None, None);
return;
};
let orphan_count = rows
.first()
.and_then(|row| row.values().first().cloned())
.and_then(|v| match v {
SqliteValue::Integer(n) => Some(n),
_ => None,
})
.unwrap_or(0);
if orphan_count == 0 {
push_check(checks, "comments.orphans", CheckStatus::Ok, None, None);
return;
}
let sample: Vec<String> = match conn.query(
"SELECT c.issue_id FROM comments c LEFT JOIN issues i ON c.issue_id = i.id WHERE i.id IS NULL LIMIT 5",
) {
Ok(rows) => rows
.iter()
.filter_map(|row| row.values().first().cloned())
.filter_map(|v| match v {
SqliteValue::Text(s) => Some(s.to_string()),
_ => None,
})
.collect(),
Err(_) => Vec::new(),
};
push_check(
checks,
"comments.orphans",
CheckStatus::Warn,
Some(format!(
"{orphan_count} orphan row(s) in comments - issue_id has no matching issues row (FK guard probably bypassed during a delete)"
)),
Some(serde_json::json!({
"orphan_count": orphan_count,
"sample_issue_ids": sample,
"remediation": "br doctor --repair surgically prunes orphan comments rows via chokepointed DELETE",
})),
);
}
const COMMENTS_ORPHAN_PREDICATE: &str =
"NOT EXISTS (SELECT 1 FROM issues WHERE issues.id = comments.issue_id)";
fn fix_comments_orphans_if_warned(
db_path: &Path,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
let has_warning = report
.checks
.iter()
.any(|c| c.name == "comments.orphans" && c.status == CheckStatus::Warn);
if !has_warning {
return false;
}
let Some(session) = session else {
if !ctx.is_json() {
ctx.warning(
"Skipping comments-orphan prune: no doctor repair session (run-dir creation failed)",
);
}
return false;
};
session.set_fixer("doctor.comments_orphan_prune");
let op = Op::DbExec {
sql: format!("DELETE FROM comments WHERE {COMMENTS_ORPHAN_PREDICATE}"),
args: Vec::new(),
affected_tables: vec!["comments".to_string()],
affected_predicate: Some(COMMENTS_ORPHAN_PREDICATE.to_string()),
};
match chokepoint::mutate(&session.ctx, db_path, op) {
Ok(result) if result.ok => {
if !ctx.is_json() {
ctx.info("Pruned orphan rows from comments");
}
true
}
Ok(_) => false,
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!("Failed to prune orphan comments rows: {err}"));
}
false
}
}
}
fn check_labels_orphans(conn: &Connection, checks: &mut Vec<CheckResult>) {
let Ok(rows) = conn.query(
"SELECT COUNT(*) FROM labels l LEFT JOIN issues i ON l.issue_id = i.id WHERE i.id IS NULL",
) else {
push_check(checks, "labels.orphans", CheckStatus::Ok, None, None);
return;
};
let orphan_count = rows
.first()
.and_then(|row| row.values().first().cloned())
.and_then(|v| match v {
SqliteValue::Integer(n) => Some(n),
_ => None,
})
.unwrap_or(0);
if orphan_count == 0 {
push_check(checks, "labels.orphans", CheckStatus::Ok, None, None);
return;
}
let sample: Vec<String> = match conn.query(
"SELECT l.issue_id FROM labels l LEFT JOIN issues i ON l.issue_id = i.id WHERE i.id IS NULL LIMIT 5",
) {
Ok(rows) => rows
.iter()
.filter_map(|row| row.values().first().cloned())
.filter_map(|v| match v {
SqliteValue::Text(s) => Some(s.to_string()),
_ => None,
})
.collect(),
Err(_) => Vec::new(),
};
push_check(
checks,
"labels.orphans",
CheckStatus::Warn,
Some(format!(
"{orphan_count} orphan row(s) in labels — issue_id has no matching issues row (FK guard probably bypassed during a delete)"
)),
Some(serde_json::json!({
"orphan_count": orphan_count,
"sample_issue_ids": sample,
"remediation": "br doctor --repair surgically prunes orphan labels rows via chokepointed DELETE",
})),
);
}
const LABELS_ORPHAN_PREDICATE: &str =
"NOT EXISTS (SELECT 1 FROM issues WHERE issues.id = labels.issue_id)";
fn fix_labels_orphans_if_warned(
db_path: &Path,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
let has_warning = report
.checks
.iter()
.any(|c| c.name == "labels.orphans" && c.status == CheckStatus::Warn);
if !has_warning {
return false;
}
let Some(session) = session else {
if !ctx.is_json() {
ctx.warning(
"Skipping labels-orphan prune: no doctor repair session (run-dir creation failed)",
);
}
return false;
};
session.set_fixer("doctor.labels_orphan_prune");
let op = Op::DbExec {
sql: format!("DELETE FROM labels WHERE {LABELS_ORPHAN_PREDICATE}"),
args: Vec::new(),
affected_tables: vec!["labels".to_string()],
affected_predicate: Some(LABELS_ORPHAN_PREDICATE.to_string()),
};
match chokepoint::mutate(&session.ctx, db_path, op) {
Ok(result) if result.ok => {
if !ctx.is_json() {
ctx.info("Pruned orphan rows from labels");
}
true
}
Ok(_) => false,
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!("Failed to prune orphan labels rows: {err}"));
}
false
}
}
}
const DEPENDENCIES_ORPHAN_PREDICATE: &str = "issue_id NOT IN (SELECT id FROM issues) \
OR (depends_on_id NOT LIKE 'external:%' \
AND depends_on_id NOT IN (SELECT id FROM issues))";
fn check_dependencies_orphans(conn: &Connection, checks: &mut Vec<CheckResult>) {
let Ok(rows) = conn.query(&format!(
"SELECT COUNT(*) FROM dependencies WHERE {DEPENDENCIES_ORPHAN_PREDICATE}"
)) else {
push_check(checks, "dependencies.orphans", CheckStatus::Ok, None, None);
return;
};
let orphan_count = rows
.first()
.and_then(|row| row.values().first().cloned())
.and_then(|v| match v {
SqliteValue::Integer(n) => Some(n),
_ => None,
})
.unwrap_or(0);
if orphan_count == 0 {
push_check(checks, "dependencies.orphans", CheckStatus::Ok, None, None);
return;
}
let sample: Vec<String> = match conn.query(&format!(
"SELECT issue_id, depends_on_id FROM dependencies \
WHERE {DEPENDENCIES_ORPHAN_PREDICATE} \
ORDER BY issue_id, depends_on_id \
LIMIT 5"
)) {
Ok(rows) => rows
.iter()
.filter_map(|row| {
let issue_id = row.values().first().and_then(|v| match v {
SqliteValue::Text(s) => Some(s.as_str()),
_ => None,
})?;
let depends_on_id = row.values().get(1).and_then(|v| match v {
SqliteValue::Text(s) => Some(s.as_str()),
_ => None,
})?;
Some(format!("{issue_id} -> {depends_on_id}"))
})
.collect(),
Err(_) => Vec::new(),
};
push_check(
checks,
"dependencies.orphans",
CheckStatus::Warn,
Some(format!(
"{orphan_count} orphan row(s) in dependencies — issue_id or non-external depends_on_id has no matching issues row"
)),
Some(serde_json::json!({
"orphan_count": orphan_count,
"sample_edges": sample,
"remediation": "br doctor --repair surgically prunes orphan dependencies rows via chokepointed DELETE (external depends_on_id refs are preserved)",
})),
);
}
fn fix_dependencies_orphans_if_warned(
db_path: &Path,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
let has_warning = report
.checks
.iter()
.any(|c| c.name == "dependencies.orphans" && c.status == CheckStatus::Warn);
if !has_warning {
return false;
}
let Some(session) = session else {
if !ctx.is_json() {
ctx.warning(
"Skipping dependencies-orphan prune: no doctor repair session (run-dir creation failed)",
);
}
return false;
};
session.set_fixer("doctor.dependencies_orphan_prune");
let op = Op::DbExec {
sql: format!("DELETE FROM dependencies WHERE {DEPENDENCIES_ORPHAN_PREDICATE}"),
args: Vec::new(),
affected_tables: vec!["dependencies".to_string()],
affected_predicate: Some(DEPENDENCIES_ORPHAN_PREDICATE.to_string()),
};
match chokepoint::mutate(&session.ctx, db_path, op) {
Ok(result) if result.ok => {
if !ctx.is_json() {
ctx.info("Pruned orphan rows from dependencies");
}
true
}
Ok(_) => false,
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!("Failed to prune orphan dependencies rows: {err}"));
}
false
}
}
}
fn push_recoverable_anomalies_check(checks: &mut Vec<CheckResult>, findings: &[String]) {
if findings.is_empty() {
push_check(
checks,
"db.recoverable_anomalies",
CheckStatus::Ok,
None,
None,
);
} else if findings
.iter()
.all(|finding| blocked_cache_rebuild_finding(finding))
{
push_check(
checks,
"db.recoverable_anomalies",
CheckStatus::Warn,
Some(findings[0].clone()),
Some(serde_json::json!({ "findings": findings })),
);
} else {
push_check(
checks,
"db.recoverable_anomalies",
CheckStatus::Error,
Some(findings[0].clone()),
Some(serde_json::json!({ "findings": findings })),
);
}
}
const HISTORICAL_CYCLE_CLOSE_LABEL_PREFIX: &str = "audit-historical-cycle-close-";
fn is_historical_cycle_close_label(label: &str) -> bool {
let Some(date) = label
.trim()
.strip_prefix(HISTORICAL_CYCLE_CLOSE_LABEL_PREFIX)
else {
return false;
};
let bytes = date.as_bytes();
let has_date_shape = bytes.len() == 10
&& bytes[0].is_ascii_digit()
&& bytes[1].is_ascii_digit()
&& bytes[2].is_ascii_digit()
&& bytes[3].is_ascii_digit()
&& bytes[4] == b'-'
&& bytes[5].is_ascii_digit()
&& bytes[6].is_ascii_digit()
&& bytes[7] == b'-'
&& bytes[8].is_ascii_digit()
&& bytes[9].is_ascii_digit();
has_date_shape && NaiveDate::parse_from_str(date, "%Y-%m-%d").is_ok()
}
fn check_suspect_close_reasons(conn: &Connection, checks: &mut Vec<CheckResult>) {
let default_patterns: &[&str] = &[
"forced close due to cycle",
"due to dep cycle",
"due to dependency cycle",
"temporarily closed",
"wip close",
];
let default_allowlist: &[&str] = &[
"auto-closed by doctor",
"closed by epic close-eligible",
"merged into",
"superseded by",
];
let rows = match conn.query(
"SELECT i.id, i.close_reason,
COALESCE(GROUP_CONCAT(l.label, char(31)), '') AS labels
FROM issues i
LEFT JOIN labels l ON l.issue_id = i.id
WHERE i.status = 'closed' AND i.close_reason IS NOT NULL
GROUP BY i.id
ORDER BY i.id",
) {
Ok(rows) => rows,
Err(err) => {
push_check(
checks,
"audit.suspect_close_reasons",
CheckStatus::Warn,
Some(format!(
"Failed to query closed beads for close_reason audit: {err}"
)),
None,
);
return;
}
};
let mut matches: Vec<serde_json::Value> = Vec::new();
for row in rows {
let id = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let reason = row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let labels = row
.get(2)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
if id.is_empty() || reason.is_empty() {
continue;
}
let has_historical_label = labels.split('\x1f').any(is_historical_cycle_close_label);
if has_historical_label {
continue;
}
let reason_lower = reason.to_lowercase();
if default_allowlist.iter().any(|a| reason_lower.contains(a)) {
continue;
}
if let Some(matched) = default_patterns.iter().find(|p| reason_lower.contains(*p)) {
matches.push(serde_json::json!({
"bead_id": id,
"matched_pattern": matched,
"close_reason": reason,
"has_historical_label": false,
}));
}
}
if matches.is_empty() {
push_check(
checks,
"audit.suspect_close_reasons",
CheckStatus::Ok,
None,
None,
);
return;
}
let count = matches.len();
push_check(
checks,
"audit.suspect_close_reasons",
CheckStatus::Warn,
Some(format!(
"{count} closed bead(s) have audit-suspect close_reason text without an audit-historical-cycle-close-<YYYY-MM-DD> escape-hatch label"
)),
Some(serde_json::json!({
"patterns_used": default_patterns,
"allowlist_used": default_allowlist,
"matches": matches,
})),
);
}
fn check_workflow_statuses(conn: &Connection, beads_dir: &Path, checks: &mut Vec<CheckResult>) {
let policy = match crate::close_policy::load_for_beads_dir(beads_dir) {
Ok(policy) => policy,
Err(err) => {
push_check(
checks,
"policy.workflow_statuses",
CheckStatus::Warn,
Some(format!(
"Failed to load .beads/policy.yaml for workflow-status audit: {err}"
)),
None,
);
return;
}
};
let workflow = &policy.workflow;
if !workflow.is_enforced() {
return;
}
let rows =
match conn.query("SELECT id, status FROM issues WHERE status IS NOT NULL ORDER BY id") {
Ok(rows) => rows,
Err(err) => {
push_check(
checks,
"policy.workflow_statuses",
CheckStatus::Warn,
Some(format!(
"Failed to query issue statuses for workflow audit: {err}"
)),
None,
);
return;
}
};
let mut offenders: Vec<serde_json::Value> = Vec::new();
for row in rows {
let id = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let status = row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
if id.is_empty() || status.is_empty() {
continue;
}
if !workflow.allows(&status) {
offenders.push(serde_json::json!({
"bead_id": id,
"status": status,
}));
}
}
if offenders.is_empty() {
push_check(
checks,
"policy.workflow_statuses",
CheckStatus::Ok,
None,
None,
);
return;
}
let count = offenders.len();
push_check(
checks,
"policy.workflow_statuses",
CheckStatus::Warn,
Some(format!(
"{count} issue(s) have a status outside the strict workflow set ({}). \
Update each with `br update <id> --status <allowed>`.",
workflow.allowed_list()
)),
Some(serde_json::json!({
"allowed_statuses": workflow.statuses,
"offenders": offenders,
})),
);
}
fn check_recoverable_anomalies(conn: &Connection, checks: &mut Vec<CheckResult>) -> Result<()> {
let duplicate_schema_rows = conn.query(
"SELECT type, name, COUNT(*) AS row_count
FROM sqlite_master
WHERE name IN ('blocked_issues_cache', 'idx_blocked_cache_blocked_at')
GROUP BY type, name
HAVING COUNT(*) > 1
ORDER BY row_count DESC, name ASC
LIMIT 1",
)?;
let duplicate_config = conn.query(
"SELECT key, COUNT(*) AS row_count
FROM config
GROUP BY key
HAVING COUNT(*) > 1
ORDER BY row_count DESC, key ASC
LIMIT 1",
)?;
let duplicate_metadata = conn.query(
"SELECT key, COUNT(*) AS row_count
FROM metadata
GROUP BY key
HAVING COUNT(*) > 1
ORDER BY row_count DESC, key ASC
LIMIT 1",
)?;
let mut findings = Vec::new();
if let Some(row) = duplicate_schema_rows.first() {
let object_type = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("object");
let name = row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or("unknown");
let row_count = row.get(2).and_then(SqliteValue::as_integer).unwrap_or(2);
findings.push(format!(
"sqlite_master contains duplicate {object_type} entries for '{name}' ({row_count} rows)"
));
}
if let Some(row) = duplicate_config.first() {
let key = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("unknown");
let row_count = row.get(1).and_then(SqliteValue::as_integer).unwrap_or(2);
findings.push(format!(
"config contains duplicate rows for key '{key}' ({row_count} rows)"
));
}
if let Some(row) = duplicate_metadata.first() {
let key = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("unknown");
let row_count = row.get(1).and_then(SqliteValue::as_integer).unwrap_or(2);
findings.push(format!(
"metadata contains duplicate rows for key '{key}' ({row_count} rows)"
));
}
if latest_metadata_value(conn, "blocked_cache_state").as_deref() == Some("stale") {
findings.push(BLOCKED_CACHE_STALE_FINDING.to_string());
}
let blocked_cache_health = SqliteStorage::blocked_cache_projection_health(conn);
if blocked_cache_health.has_mismatch() {
findings.push(BLOCKED_CACHE_CONTENT_MISMATCH_FINDING.to_string());
}
let ready_projection_health = SqliteStorage::ready_projection_health(conn);
if ready_projection_health.has_mismatch() {
findings.push(READY_PROJECTION_CONTENT_MISMATCH_FINDING.to_string());
}
push_recoverable_anomalies_check(checks, &findings);
Ok(())
}
const NULL_DEFAULT_CHECKS: &[(&str, &str, &str)] = &[
(
"issues",
"description",
"UPDATE issues SET description = '' WHERE typeof(description) = 'null'",
),
(
"issues",
"design",
"UPDATE issues SET design = '' WHERE typeof(design) = 'null'",
),
(
"issues",
"acceptance_criteria",
"UPDATE issues SET acceptance_criteria = '' WHERE typeof(acceptance_criteria) = 'null'",
),
(
"issues",
"notes",
"UPDATE issues SET notes = '' WHERE typeof(notes) = 'null'",
),
(
"issues",
"status",
"UPDATE issues SET status = 'open' WHERE typeof(status) = 'null'",
),
(
"issues",
"priority",
"UPDATE issues SET priority = 2 WHERE typeof(priority) = 'null'",
),
(
"issues",
"issue_type",
"UPDATE issues SET issue_type = 'task' WHERE typeof(issue_type) = 'null'",
),
(
"issues",
"source_repo",
"UPDATE issues SET source_repo = '.' WHERE typeof(source_repo) = 'null'",
),
(
"issues",
"ephemeral",
"UPDATE issues SET ephemeral = 0 WHERE typeof(ephemeral) = 'null'",
),
(
"issues",
"pinned",
"UPDATE issues SET pinned = 0 WHERE typeof(pinned) = 'null'",
),
(
"issues",
"is_template",
"UPDATE issues SET is_template = 0 WHERE typeof(is_template) = 'null'",
),
(
"dependencies",
"type",
"UPDATE dependencies SET type = 'blocks' WHERE typeof(type) = 'null'",
),
(
"dependencies",
"created_by",
"UPDATE dependencies SET created_by = '' WHERE typeof(created_by) = 'null'",
),
(
"comments",
"author",
"UPDATE comments SET author = '' WHERE typeof(author) = 'null'",
),
(
"comments",
"text",
"UPDATE comments SET text = '' WHERE typeof(text) = 'null'",
),
(
"comments",
"created_at",
"UPDATE comments SET created_at = CURRENT_TIMESTAMP WHERE typeof(created_at) = 'null'",
),
(
"events",
"event_type",
"UPDATE events SET event_type = '' WHERE typeof(event_type) = 'null'",
),
(
"events",
"actor",
"UPDATE events SET actor = '' WHERE typeof(actor) = 'null'",
),
(
"events",
"created_at",
"UPDATE events SET created_at = CURRENT_TIMESTAMP WHERE typeof(created_at) = 'null'",
),
];
fn check_null_defaults(conn: &Connection, checks: &mut Vec<CheckResult>) {
let queries: &[(&str, &str, &str)] = NULL_DEFAULT_CHECKS;
let mut null_findings = Vec::new();
for (table, column, fix_sql) in queries {
let count_sql = format!("SELECT COUNT(*) FROM {table} WHERE typeof({column}) = 'null'");
if let Ok(row) = conn.query_row(&count_sql) {
let count = row.get(0).and_then(SqliteValue::as_integer).unwrap_or(0);
if count > 0 {
null_findings.push(serde_json::json!({
"table": table,
"column": column,
"null_count": count,
"fix_sql": fix_sql,
}));
}
}
}
if null_findings.is_empty() {
push_check(checks, "db.null_defaults", CheckStatus::Ok, None, None);
} else {
let first = &null_findings[0];
let table = first["table"].as_str().unwrap_or("?");
let column = first["column"].as_str().unwrap_or("?");
let count = first["null_count"].as_i64().unwrap_or(0);
push_check(
checks,
"db.null_defaults",
CheckStatus::Warn,
Some(format!(
"{table}.{column} has {count} NULL value(s); fix with: {}",
first["fix_sql"].as_str().unwrap_or("see details")
)),
Some(serde_json::json!({ "findings": null_findings })),
);
}
}
fn fix_null_defaults_if_warned(
db_path: &Path,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
let Some(finding) = report
.checks
.iter()
.find(|c| c.name == "db.null_defaults" && c.status == CheckStatus::Warn)
else {
return false;
};
let Some(session) = session else {
if !ctx.is_json() {
ctx.warning(
"Skipping null-defaults backfill: no doctor repair session (run-dir creation failed)",
);
}
return false;
};
let flagged: Vec<(String, String)> = finding
.details
.as_ref()
.and_then(|d| d.get("findings"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|f| {
Some((
f.get("table")?.as_str()?.to_string(),
f.get("column")?.as_str()?.to_string(),
))
})
.collect()
})
.unwrap_or_default();
if flagged.is_empty() {
return false;
}
session.set_fixer("doctor.null_defaults_backfill");
let mut any = false;
for &(table, column, fix_sql) in NULL_DEFAULT_CHECKS {
if !flagged
.iter()
.any(|(t, c)| t.as_str() == table && c.as_str() == column)
{
continue;
}
let predicate = format!("typeof({column}) = 'null'");
let op = Op::DbExec {
sql: fix_sql.to_string(),
args: Vec::new(),
affected_tables: vec![table.to_string()],
affected_predicate: Some(predicate),
};
match chokepoint::mutate(&session.ctx, db_path, op) {
Ok(result) if result.ok => any = true,
Ok(_) => {}
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!(
"Failed to backfill {table}.{column} default: {err}"
));
}
}
}
}
if any && !ctx.is_json() {
ctx.info("Backfilled schema defaults into NULL columns");
}
any
}
fn check_issue_write_probe(conn: &Connection, checks: &mut Vec<CheckResult>) {
let issue_id = match conn.query_row("SELECT id FROM issues ORDER BY id LIMIT 1") {
Ok(row) => row
.get(0)
.and_then(SqliteValue::as_text)
.map(ToString::to_string),
Err(FrankenError::QueryReturnedNoRows) => None,
Err(err) => {
push_check(
checks,
"db.write_probe",
CheckStatus::Error,
Some(format!("Failed to select probe issue: {err}")),
None,
);
return;
}
};
let Some(issue_id) = issue_id else {
push_check(
checks,
"db.write_probe",
CheckStatus::Ok,
Some("No issues available for rollback-only write probe".to_string()),
None,
);
return;
};
let begin_result = conn.execute("BEGIN IMMEDIATE");
if let Err(err) = begin_result {
let status = if err.is_transient() {
CheckStatus::Warn
} else {
CheckStatus::Error
};
push_check(
checks,
"db.write_probe",
status,
Some(format!("Failed to begin rollback-only write probe: {err}")),
Some(serde_json::json!({ "issue_id": issue_id })),
);
return;
}
let update_result = conn.execute_with_params(
"UPDATE issues SET priority = priority, status = status WHERE id = ?",
&[SqliteValue::from(issue_id.as_str())],
);
let rollback_result = conn.execute("ROLLBACK");
checks.push(build_issue_write_probe_check(
&issue_id,
update_result,
rollback_result,
));
}
fn sqlite_readonly_immutable_uri(db_path: &Path) -> String {
use std::fmt::Write as _;
let absolute_path = if db_path.is_absolute() {
db_path.to_path_buf()
} else {
std::env::current_dir().map_or_else(|_| db_path.to_path_buf(), |cwd| cwd.join(db_path))
};
let path = absolute_path.to_string_lossy();
let mut uri = String::with_capacity(path.len() + 32);
uri.push_str("file:");
for byte in path.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'/' | b'.' | b'_' | b'-' | b'~' => {
uri.push(char::from(byte));
}
#[cfg(windows)]
b'\\' => uri.push('/'),
#[cfg(windows)]
b':' => uri.push(':'),
_ => {
let _ = write!(&mut uri, "%{byte:02X}");
}
}
}
uri.push_str("?mode=ro&immutable=1");
uri
}
fn sqlite_cli_integrity_messages(db_path: &Path) -> Result<Vec<String>> {
let output = Command::new("sqlite3")
.arg(sqlite_readonly_immutable_uri(db_path))
.arg("PRAGMA integrity_check;")
.output()
.map_err(|err| BeadsError::Config(format!("failed to run sqlite3: {err}")))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let mut messages: Vec<String> = stdout
.lines()
.chain(stderr.lines())
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToString::to_string)
.collect();
if messages.is_empty() && !output.status.success() {
messages.push(format!(
"sqlite3 exited with status {}",
output.status.code().unwrap_or(-1)
));
}
if output.status.success() {
Ok(messages)
} else {
Err(BeadsError::Config(messages.join("; ")))
}
}
fn check_sqlite_cli_integrity(db_path: &Path, checks: &mut Vec<CheckResult>) {
match sqlite_cli_integrity_messages(db_path) {
Ok(messages) if messages.len() == 1 && messages[0].eq_ignore_ascii_case("ok") => {
push_check(
checks,
"sqlite3.integrity_check",
CheckStatus::Ok,
None,
None,
);
}
Ok(messages) if integrity_messages_only_benign(&messages) => {
push_check(
checks,
"sqlite3.integrity_check",
CheckStatus::Warn,
Some(messages.join("; ")),
(messages.len() > 1).then(|| serde_json::json!({ "messages": messages })),
);
}
Ok(messages) => {
push_check(
checks,
"sqlite3.integrity_check",
CheckStatus::Error,
Some(messages.join("; ")),
(messages.len() > 1).then(|| serde_json::json!({ "messages": messages })),
);
}
Err(BeadsError::Config(message))
if message.contains("No such file or directory")
|| message.contains("failed to run sqlite3") =>
{
push_check(
checks,
"sqlite3.integrity_check",
CheckStatus::Ok,
Some("sqlite3 not available; skipping orthogonal integrity validation".to_string()),
None,
);
}
Err(err) => {
push_check(
checks,
"sqlite3.integrity_check",
CheckStatus::Error,
Some(err.to_string()),
None,
);
}
}
}
fn integrity_check_messages(rows: &[Vec<SqliteValue>]) -> Vec<String> {
let mut messages = Vec::new();
for row in rows {
for value in row {
if let Some(text) = value.as_text() {
let trimmed = text.trim();
if !trimmed.is_empty() {
messages.push(trimmed.to_string());
}
}
}
}
if messages.is_empty() {
messages.push("integrity_check returned no diagnostic rows".to_string());
}
messages
}
fn merge_artifact_kind(name: &str) -> &'static str {
if name.contains(".left.jsonl") {
"merge-left"
} else if name.contains(".right.jsonl") {
"merge-right"
} else {
"stale-base-variant"
}
}
fn check_merge_artifacts(
beads_dir: &Path,
canonical_jsonl: &Path,
checks: &mut Vec<CheckResult>,
) -> Result<()> {
let mut files = Vec::new();
let mut artifacts = Vec::new();
for entry in beads_dir.read_dir()? {
let entry = entry?;
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
if name == "beads.base.jsonl" {
continue;
}
if name.contains(".base.jsonl")
|| name.contains(".left.jsonl")
|| name.contains(".right.jsonl")
{
let path = entry.path();
artifacts.push(serde_json::json!({
"path": path.display().to_string(),
"artifact_kind": merge_artifact_kind(name),
"conflict_markers_found": crate::health::jsonl_has_conflict_markers(&path),
}));
files.push(name.to_string());
}
}
if artifacts.is_empty() {
push_check(checks, "jsonl.merge_artifacts", CheckStatus::Ok, None, None);
} else {
push_check(
checks,
"jsonl.merge_artifacts",
CheckStatus::Warn,
Some("Merge artifacts detected in .beads/".to_string()),
Some(serde_json::json!({
"files": files,
"artifacts": artifacts,
"canonical_jsonl": canonical_jsonl.display().to_string(),
"recovery": [
{ "kind": "quarantine", "command": "br doctor --repair" },
{ "kind": "undo", "command": "br doctor undo <run-id>" },
],
})),
);
}
Ok(())
}
fn check_base_jsonl(beads_dir: &Path, checks: &mut Vec<CheckResult>) {
let base_path = beads_dir.join("beads.base.jsonl");
let meta = match fs::symlink_metadata(&base_path) {
Ok(m) => m,
Err(err) if err.kind() == io::ErrorKind::NotFound => {
push_check(checks, "base_jsonl", CheckStatus::Ok, None, None);
return;
}
Err(err) => {
push_check(
checks,
"base_jsonl",
CheckStatus::Warn,
Some(format!("Could not inspect {}: {err}", base_path.display())),
Some(serde_json::json!({
"path": base_path.display().to_string(),
"kind": "unreadable",
})),
);
return;
}
};
if meta.file_type().is_symlink() {
push_check(
checks,
"base_jsonl",
CheckStatus::Warn,
Some(format!(
"{} is a symlink — refusing to trust it as a merge anchor",
base_path.display()
)),
Some(serde_json::json!({
"path": base_path.display().to_string(),
"kind": "symlink",
})),
);
return;
}
let live = beads_dir.join("issues.jsonl");
let Ok(live_meta) = fs::symlink_metadata(&live) else {
push_check(checks, "base_jsonl", CheckStatus::Ok, None, None);
return;
};
if !live_meta.is_file() || live_meta.len() == 0 {
push_check(checks, "base_jsonl", CheckStatus::Ok, None, None);
return;
}
let (Ok(base_mtime), Ok(live_mtime)) = (meta.modified(), live_meta.modified()) else {
push_check(checks, "base_jsonl", CheckStatus::Ok, None, None);
return;
};
if base_mtime < live_mtime {
push_check(
checks,
"base_jsonl",
CheckStatus::Warn,
Some(format!(
"Merge anchor {} is older than the live JSONL — 3-way merges will diff against stale state",
base_path.display()
)),
Some(serde_json::json!({
"path": base_path.display().to_string(),
"kind": "stale",
"live_jsonl": live.display().to_string(),
})),
);
return;
}
push_check(checks, "base_jsonl", CheckStatus::Ok, None, None);
}
fn check_startup_cache(
beads_dir: &Path,
db_override: Option<&PathBuf>,
checks: &mut Vec<CheckResult>,
) {
let poisoned = config::doctor_inspect_startup_cache(beads_dir, db_override);
if poisoned.is_empty() {
push_check(checks, "startup_cache.health", CheckStatus::Ok, None, None);
return;
}
let cache_dir = config::doctor_startup_cache_dir();
let files: Vec<serde_json::Value> = poisoned
.iter()
.map(|p| {
let (kind, error) = match &p.kind {
config::PoisonedStartupCacheKind::Unreadable { error } => {
("unreadable", error.clone())
}
config::PoisonedStartupCacheKind::ParseError { error, .. } => {
("parse_error", error.clone())
}
};
serde_json::json!({
"path": p.path.to_string_lossy(),
"kind": kind,
"error": error,
})
})
.collect();
push_check(
checks,
"startup_cache.health",
CheckStatus::Warn,
Some(format!(
"{} poisoned startup-cache file(s) under {}",
poisoned.len(),
cache_dir.display()
)),
Some(serde_json::json!({
"poisoned": files,
"cache_dir": cache_dir.to_string_lossy(),
})),
);
}
fn check_root_gitignore(beads_dir: &Path, checks: &mut Vec<CheckResult>) {
let Some(project_root) = beads_dir.parent() else {
return;
};
let gitignore_path = project_root.join(".gitignore");
let content = match read_root_gitignore_content(&gitignore_path) {
Ok(Some(content)) => content,
Ok(None) => return,
Err(err) => {
push_check(
checks,
"gitignore.beads_inner",
CheckStatus::Warn,
Some(format!("Could not inspect root .gitignore: {err}")),
Some(serde_json::json!({
"gitignore_path": gitignore_path.display().to_string(),
})),
);
return;
}
};
let offending: Vec<String> = content
.lines()
.filter(|line| is_offending_root_gitignore_pattern(line))
.map(String::from)
.collect();
if offending.is_empty() {
push_check(checks, "gitignore.beads_inner", CheckStatus::Ok, None, None);
} else {
push_check(
checks,
"gitignore.beads_inner",
CheckStatus::Warn,
Some(format!(
"Root .gitignore excludes .beads/.gitignore — br's ignore rules are ineffective. \
Remove the offending line(s) from .gitignore to fix: {}",
offending.join(", ")
)),
Some(serde_json::json!({
"gitignore_path": gitignore_path.display().to_string(),
"offending_patterns": offending,
})),
);
}
}
fn check_routes_jsonl(beads_dir: &Path, checks: &mut Vec<CheckResult>) {
let routes_path = beads_dir.join("routes.jsonl");
if !routes_path.is_file() {
push_check(
checks,
"routes_jsonl",
CheckStatus::Ok,
Some("No routes.jsonl present (cross-project routing is optional)".to_string()),
None,
);
return;
}
let body = match fs::read_to_string(&routes_path) {
Ok(s) => s,
Err(err) => {
push_check(
checks,
"routes_jsonl",
CheckStatus::Warn,
Some(format!("Failed to read routes.jsonl: {err}")),
Some(serde_json::json!({
"path": routes_path.display().to_string(),
})),
);
return;
}
};
let mut malformed_lines: Vec<serde_json::Value> = Vec::new();
let mut valid_count: usize = 0;
for (idx, raw) in body.lines().enumerate() {
let line_no = idx + 1;
let line = raw.trim();
if config::routing::is_ignorable_route_jsonl_line(line) {
continue;
}
let value: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(err) => {
malformed_lines.push(serde_json::json!({
"line": line_no,
"reason": format!("parse_error: {err}"),
}));
continue;
}
};
let mut reasons = Vec::new();
match value.get("prefix") {
None => reasons.push("missing `prefix` field".to_string()),
Some(prefix) => match prefix.as_str() {
Some("") => reasons.push("empty `prefix` field".to_string()),
Some(_) => {}
None => reasons.push("non-string `prefix` field".to_string()),
},
}
match value.get("path") {
None => reasons.push("missing `path` field".to_string()),
Some(path) => match path.as_str() {
Some("") => reasons.push("empty `path` field".to_string()),
Some(_) => {}
None => reasons.push("non-string `path` field".to_string()),
},
}
if reasons.is_empty() {
valid_count += 1;
} else {
malformed_lines.push(serde_json::json!({
"line": line_no,
"reason": reasons.join("; "),
"reasons": reasons,
}));
}
}
if malformed_lines.is_empty() {
push_check(
checks,
"routes_jsonl",
CheckStatus::Ok,
Some(format!("{valid_count} routes parsed cleanly")),
Some(serde_json::json!({
"path": routes_path.display().to_string(),
"valid_count": valid_count,
})),
);
} else {
let bad = malformed_lines.len();
push_check(
checks,
"routes_jsonl",
CheckStatus::Warn,
Some(format!(
"{bad} malformed route line(s) in routes.jsonl ({valid_count} valid). Operator must rewrite manually; doctor never auto-rewrites routes."
)),
Some(serde_json::json!({
"path": routes_path.display().to_string(),
"valid_count": valid_count,
"malformed_lines": malformed_lines,
})),
);
}
}
fn check_routes_targets_resolve(beads_dir: &Path, checks: &mut Vec<CheckResult>) {
let route_sources = doctor_route_sources(beads_dir);
if route_sources.is_empty() {
push_check(
checks,
"routes.targets",
CheckStatus::Ok,
Some("No local or town routes.jsonl present (route targets are optional)".to_string()),
None,
);
return;
}
let mut unresolved_routes = Vec::new();
let mut inspected_paths = Vec::new();
let mut route_count = 0usize;
let mut resolved_count = 0usize;
for (routes_path, base_dir) in route_sources {
inspected_paths.push(routes_path.display().to_string());
match inspect_route_target_source(&routes_path, &base_dir, &mut unresolved_routes) {
Ok((source_route_count, source_resolved_count)) => {
route_count += source_route_count;
resolved_count += source_resolved_count;
}
Err(err) => {
push_check(
checks,
"routes.targets",
CheckStatus::Warn,
Some(format!(
"Could not resolve route targets because routes.jsonl is invalid: {err}"
)),
Some(serde_json::json!({
"path": routes_path.display().to_string(),
})),
);
return;
}
}
}
if unresolved_routes.is_empty() {
push_check(
checks,
"routes.targets",
CheckStatus::Ok,
Some(format!("{resolved_count} route target(s) resolved cleanly")),
Some(serde_json::json!({
"paths": inspected_paths,
"route_count": route_count,
"resolved_count": resolved_count,
})),
);
} else {
let unresolved_count = unresolved_routes.len();
push_check(
checks,
"routes.targets",
CheckStatus::Warn,
Some(format!(
"{unresolved_count} route target(s) failed to resolve ({resolved_count} resolved). Update routes.jsonl or the referenced project paths."
)),
Some(serde_json::json!({
"paths": inspected_paths,
"route_count": route_count,
"resolved_count": resolved_count,
"unresolved_routes": unresolved_routes,
})),
);
}
}
fn inspect_route_target_source(
routes_path: &Path,
base_dir: &Path,
unresolved_routes: &mut Vec<serde_json::Value>,
) -> Result<(usize, usize)> {
let routes = config::routing::load_routes(routes_path)?;
let mut resolved_count = 0usize;
for route in &routes {
if route.path.trim().is_empty() {
unresolved_routes.push(serde_json::json!({
"route_file": routes_path.display().to_string(),
"prefix": route.prefix.as_str(),
"path": route.path.as_str(),
"reason": "empty route path",
}));
continue;
}
let target_path = doctor_route_target_beads_dir(route, base_dir);
match config::routing::follow_redirects(&target_path, 10) {
Ok(final_path)
if final_path.is_dir()
&& final_path
.file_name()
.is_some_and(config::is_beads_dir_name) =>
{
resolved_count += 1;
}
Ok(final_path) => unresolved_routes.push(serde_json::json!({
"route_file": routes_path.display().to_string(),
"prefix": route.prefix.as_str(),
"path": route.path.as_str(),
"target": target_path.display().to_string(),
"resolved": final_path.display().to_string(),
"reason": "target is not a beads directory",
})),
Err(err) => unresolved_routes.push(serde_json::json!({
"route_file": routes_path.display().to_string(),
"prefix": route.prefix.as_str(),
"path": route.path.as_str(),
"target": target_path.display().to_string(),
"reason": err.to_string(),
})),
}
}
Ok((routes.len(), resolved_count))
}
fn doctor_route_sources(beads_dir: &Path) -> Vec<(PathBuf, PathBuf)> {
let project_root = beads_dir.parent().unwrap_or(beads_dir);
let mut sources = Vec::new();
let local_routes_path = beads_dir.join("routes.jsonl");
if local_routes_path.is_file() {
sources.push((local_routes_path, project_root.to_path_buf()));
}
if let Some(town_root) = config::routing::find_town_root(project_root) {
let town_beads_dir = town_root.join(".beads");
let town_routes_path = town_beads_dir.join("routes.jsonl");
if town_beads_dir != beads_dir && town_routes_path.is_file() {
sources.push((town_routes_path, town_root));
}
}
sources
}
fn doctor_route_target_beads_dir(route: &config::routing::RouteEntry, base_dir: &Path) -> PathBuf {
if route.path == "." {
return base_dir.join(".beads");
}
let path = PathBuf::from(&route.path);
let resolved = if path.is_absolute() {
path
} else {
base_dir.join(path)
};
if resolved.file_name().is_some_and(config::is_beads_dir_name) {
resolved
} else {
resolved.join(".beads")
}
}
fn check_rust_log_noisy(checks: &mut Vec<CheckResult>) {
let raw = std::env::var("RUST_LOG").ok();
let raw_ref = raw.as_deref();
let level = rust_log_volume(raw_ref);
match level {
RustLogVolume::Quiet => {
push_check(
checks,
"rust_log",
CheckStatus::Ok,
Some(match raw_ref {
None => {
"RUST_LOG unset; release default is quiet enough for --json".to_string()
}
Some(v) => format!("RUST_LOG={v} (quiet)"),
}),
Some(serde_json::json!({
"rust_log": raw_ref,
})),
);
}
RustLogVolume::Noisy { reason } => {
push_check(
checks,
"rust_log",
CheckStatus::Warn,
Some(format!(
"RUST_LOG={} would dump verbose tracing to stderr and break agents parsing --json. \
Run `export RUST_LOG=error` as documented in README.md.",
raw_ref.unwrap_or("(unset)"),
)),
Some(serde_json::json!({
"rust_log": raw_ref,
"reason": reason,
"recommended_fix": "export RUST_LOG=error",
})),
);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum RustLogVolume {
Quiet,
Noisy { reason: &'static str },
}
fn rust_log_volume(raw: Option<&str>) -> RustLogVolume {
let Some(value) = raw else {
return rust_log_default_volume();
};
let normalized = value.trim().to_ascii_lowercase();
if normalized.is_empty() {
return RustLogVolume::Quiet;
}
match normalized.as_str() {
"off" | "error" | "warn" => return RustLogVolume::Quiet,
"info" => {
return RustLogVolume::Noisy {
reason: "bare_level_info",
};
}
"debug" => {
return RustLogVolume::Noisy {
reason: "bare_level_debug",
};
}
"trace" => {
return RustLogVolume::Noisy {
reason: "bare_level_trace",
};
}
_ => {}
}
for segment in normalized.split(',') {
let seg = segment.trim();
if seg.is_empty() {
continue;
}
let level_part = seg.rsplit('=').next().unwrap_or(seg).trim();
match level_part {
"off" | "error" | "warn" => {}
"info" => {
return RustLogVolume::Noisy {
reason: "directive_info",
};
}
"debug" => {
return RustLogVolume::Noisy {
reason: "directive_debug",
};
}
"trace" => {
return RustLogVolume::Noisy {
reason: "directive_trace",
};
}
_ if !seg.contains('=') => {
return RustLogVolume::Noisy {
reason: "directive_target_only",
};
}
_ => {
return RustLogVolume::Noisy {
reason: "directive_unclassified",
};
}
}
}
RustLogVolume::Quiet
}
fn rust_log_default_volume() -> RustLogVolume {
if cfg!(debug_assertions) {
RustLogVolume::Noisy {
reason: "debug_build_default",
}
} else {
RustLogVolume::Quiet
}
}
#[cfg(unix)]
fn check_permissions_beads_dir(beads_dir: &Path, checks: &mut Vec<CheckResult>) {
use std::os::unix::fs::PermissionsExt;
let mut readonly: Vec<serde_json::Value> = Vec::new();
if let Ok(meta) = fs::metadata(beads_dir) {
let mode = meta.permissions().mode() & 0o777;
if mode & 0o200 == 0 {
readonly.push(serde_json::json!({
"path": beads_dir.display().to_string(),
"mode_octal": format!("{mode:03o}"),
"fix": format!("chmod u+w {}", beads_dir.display()),
"kind": "directory",
}));
}
} else {
push_check(
checks,
"permissions.beads_dir",
CheckStatus::Ok,
Some(format!(
"{} not stat-able; deferring to beads_dir check",
beads_dir.display(),
)),
None,
);
return;
}
for child_name in &["issues.jsonl", "beads.db"] {
let child = beads_dir.join(child_name);
let Ok(meta) = fs::metadata(&child) else {
continue;
};
let mode = meta.permissions().mode() & 0o777;
if mode & 0o200 == 0 {
readonly.push(serde_json::json!({
"path": child.display().to_string(),
"mode_octal": format!("{mode:03o}"),
"fix": format!("chmod u+w {}", child.display()),
"kind": "file",
}));
}
}
if readonly.is_empty() {
push_check(
checks,
"permissions.beads_dir",
CheckStatus::Ok,
Some("`.beads/` and critical children are user-writable".to_string()),
Some(serde_json::json!({
"beads_dir": beads_dir.display().to_string(),
})),
);
} else {
let paths: Vec<String> = readonly
.iter()
.filter_map(|e| {
e.get("path")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
})
.collect();
push_check(
checks,
"permissions.beads_dir",
CheckStatus::Warn,
Some(format!(
"{count} path(s) under .beads/ lack the user-write bit: {paths}. \
Operator must fix manually; doctor never auto-chmods.",
count = readonly.len(),
paths = paths.join(", "),
)),
Some(serde_json::json!({
"beads_dir": beads_dir.display().to_string(),
"readonly_paths": readonly,
})),
);
}
}
fn flatten_yaml_keys(value: &serde_yml::Value, prefix: &str, out: &mut Vec<String>) {
let Some(mapping) = value.as_mapping() else {
return;
};
for (key, child) in mapping {
let Some(key) = key.as_str() else {
continue;
};
let path = if prefix.is_empty() {
key.to_string()
} else {
format!("{prefix}.{key}")
};
if child.is_mapping() {
flatten_yaml_keys(child, &path, out);
} else {
out.push(path);
}
}
}
fn check_config_unknown_keys(beads_dir: &Path, checks: &mut Vec<CheckResult>) {
const NAME: &str = "config.unknown_keys";
let config_path = beads_dir.join("config.yaml");
let Ok(body) = fs::read_to_string(&config_path) else {
push_check(checks, NAME, CheckStatus::Ok, None, None);
return;
};
let Ok(parsed) = serde_yml::from_str::<serde_yml::Value>(&body) else {
push_check(checks, NAME, CheckStatus::Ok, None, None);
return;
};
let mut keys = Vec::new();
flatten_yaml_keys(&parsed, "", &mut keys);
let unknown: Vec<serde_json::Value> = keys
.iter()
.filter(|key| !config::is_known_config_key(key))
.map(|key| {
serde_json::json!({
"key": key,
"nearest": config::nearest_config_keys(key, 3),
})
})
.collect();
if unknown.is_empty() {
push_check(checks, NAME, CheckStatus::Ok, None, None);
return;
}
let listed = unknown
.iter()
.filter_map(|entry| entry["key"].as_str())
.collect::<Vec<_>>()
.join(", ");
push_check(
checks,
NAME,
CheckStatus::Warn,
Some(format!(
"config.yaml sets {} key(s) br does not read: {listed} (run `br config schema` for the keys it honors)",
unknown.len()
)),
Some(serde_json::json!({
"path": config_path.display().to_string(),
"unknown_keys": unknown,
})),
);
}
fn check_config_yaml(beads_dir: &Path, checks: &mut Vec<CheckResult>) {
let config_path = beads_dir.join("config.yaml");
if !config_path.is_file() {
push_check(
checks,
"config.yaml",
CheckStatus::Ok,
Some("No .beads/config.yaml present (project config is optional)".to_string()),
None,
);
return;
}
let body = match fs::read_to_string(&config_path) {
Ok(s) => s,
Err(err) => {
push_check(
checks,
"config.yaml",
CheckStatus::Warn,
Some(format!("Failed to read config.yaml: {err}")),
Some(serde_json::json!({
"path": config_path.display().to_string(),
})),
);
return;
}
};
if body.trim().is_empty() {
push_check(
checks,
"config.yaml",
CheckStatus::Ok,
Some("config.yaml is empty (all defaults)".to_string()),
Some(serde_json::json!({
"path": config_path.display().to_string(),
"bytes": 0_u64,
})),
);
return;
}
match serde_yml::from_str::<serde_yml::Value>(&body) {
Ok(_) => {
push_check(
checks,
"config.yaml",
CheckStatus::Ok,
Some(format!("config.yaml parses cleanly ({} bytes)", body.len())),
Some(serde_json::json!({
"path": config_path.display().to_string(),
"bytes": body.len(),
})),
);
}
Err(err) => {
push_check(
checks,
"config.yaml",
CheckStatus::Warn,
Some(format!(
"config.yaml is malformed YAML: {err}. Operator must fix manually; doctor never auto-rewrites config.",
)),
Some(serde_json::json!({
"path": config_path.display().to_string(),
"parse_error": err.to_string(),
"recommended_fix": format!(
"Open {} in an editor and fix the YAML; \
see the parse_error field for the precise location.",
config_path.display(),
),
})),
);
}
}
}
fn check_metadata_json(beads_dir: &Path, checks: &mut Vec<CheckResult>) {
let metadata_path = beads_dir.join("metadata.json");
if !metadata_path.is_file() {
push_metadata_json_missing(checks);
return;
}
let body = match fs::read_to_string(&metadata_path) {
Ok(s) => s,
Err(err) => return push_metadata_json_read_error(&metadata_path, &err, checks),
};
if body.trim().is_empty() {
push_metadata_json_empty(&metadata_path, checks);
return;
}
let Some(obj) = parse_metadata_json_object(&metadata_path, &body, checks) else {
return;
};
let database = metadata_declared_field(&obj, "database");
let jsonl_export = metadata_declared_field(&obj, "jsonl_export");
let drift = collect_metadata_json_drift(beads_dir, database, jsonl_export);
if drift.is_empty() {
push_metadata_json_ok(&metadata_path, body.len(), database, jsonl_export, checks);
} else {
push_metadata_json_drift(&metadata_path, &drift, checks);
}
}
fn push_metadata_json_missing(checks: &mut Vec<CheckResult>) {
push_check(
checks,
"metadata.json",
CheckStatus::Ok,
Some(
"No .beads/metadata.json present (br uses defaults: beads.db + issues.jsonl)"
.to_string(),
),
None,
);
}
#[cfg(not(unix))]
fn check_permissions_beads_dir(beads_dir: &Path, checks: &mut Vec<CheckResult>) {
if !beads_dir.exists() {
push_check(
checks,
"permissions.beads_dir",
CheckStatus::Ok,
Some(format!(
"{} not stat-able; deferring to beads_dir check",
beads_dir.display(),
)),
None,
);
return;
}
push_check(
checks,
"permissions.beads_dir",
CheckStatus::Ok,
Some("POSIX user-write bit check is not applicable on this platform".to_string()),
Some(serde_json::json!({
"beads_dir": beads_dir.display().to_string(),
"platform": std::env::consts::OS,
})),
);
}
fn push_metadata_json_read_error(
metadata_path: &Path,
err: &io::Error,
checks: &mut Vec<CheckResult>,
) {
push_check(
checks,
"metadata.json",
CheckStatus::Warn,
Some(format!("Failed to read metadata.json: {err}")),
Some(serde_json::json!({
"path": metadata_path.display().to_string(),
})),
);
}
fn push_metadata_json_empty(metadata_path: &Path, checks: &mut Vec<CheckResult>) {
push_check(
checks,
"metadata.json",
CheckStatus::Warn,
Some("metadata.json is empty".to_string()),
Some(serde_json::json!({
"path": metadata_path.display().to_string(),
"reason": "empty_file",
"recommended_fix": format!(
"Either delete {} so br uses defaults, or write a valid JSON object.",
metadata_path.display(),
),
})),
);
}
fn parse_metadata_json_object(
metadata_path: &Path,
body: &str,
checks: &mut Vec<CheckResult>,
) -> Option<serde_json::Map<String, serde_json::Value>> {
let value = serde_json::from_str::<serde_json::Value>(body)
.inspect_err(|err| push_metadata_json_parse_error(metadata_path, err, checks))
.ok()?;
let serde_json::Value::Object(obj) = value else {
push_check(
checks,
"metadata.json",
CheckStatus::Warn,
Some("metadata.json top-level value must be a JSON object".to_string()),
Some(serde_json::json!({
"path": metadata_path.display().to_string(),
"reason": "wrong_top_level_shape",
})),
);
return None;
};
Some(obj)
}
fn push_metadata_json_parse_error(
metadata_path: &Path,
err: &serde_json::Error,
checks: &mut Vec<CheckResult>,
) {
push_check(
checks,
"metadata.json",
CheckStatus::Warn,
Some(format!("metadata.json is malformed JSON: {err}")),
Some(serde_json::json!({
"path": metadata_path.display().to_string(),
"reason": "parse_error",
"parse_error": err.to_string(),
"recommended_fix": format!(
"Open {} in an editor and fix the JSON; see parse_error for the precise location.",
metadata_path.display(),
),
})),
);
}
fn metadata_declared_field<'a>(
obj: &'a serde_json::Map<String, serde_json::Value>,
field: &str,
) -> Option<&'a str> {
obj.get(field)
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
}
fn collect_metadata_json_drift(
beads_dir: &Path,
database: Option<&str>,
jsonl_export: Option<&str>,
) -> Vec<serde_json::Value> {
let mut drift = Vec::new();
if let Some(db_name) = database {
let db_path = resolve_metadata_database_target(beads_dir, db_name);
if !db_path.exists() {
drift.push(serde_json::json!({
"field": "database",
"value": db_name,
"expected_path": db_path.display().to_string(),
"reason": "target_missing",
}));
}
}
if let Some(jsonl_name) = jsonl_export {
let jsonl_path = resolve_metadata_jsonl_target(beads_dir, jsonl_name);
if !jsonl_path.exists() {
drift.push(serde_json::json!({
"field": "jsonl_export",
"value": jsonl_name,
"expected_path": jsonl_path.display().to_string(),
"reason": "target_missing",
}));
}
}
drift
}
fn push_metadata_json_ok(
metadata_path: &Path,
bytes: usize,
database: Option<&str>,
jsonl_export: Option<&str>,
checks: &mut Vec<CheckResult>,
) {
push_check(
checks,
"metadata.json",
CheckStatus::Ok,
Some(format!(
"metadata.json parses cleanly ({bytes} bytes); declared targets exist on disk"
)),
Some(serde_json::json!({
"path": metadata_path.display().to_string(),
"bytes": bytes,
"database": database,
"jsonl_export": jsonl_export,
})),
);
}
fn push_metadata_json_drift(
metadata_path: &Path,
drift: &[serde_json::Value],
checks: &mut Vec<CheckResult>,
) {
let fields: Vec<&str> = drift.iter().filter_map(|e| e["field"].as_str()).collect();
push_check(
checks,
"metadata.json",
CheckStatus::Warn,
Some(format!(
"metadata.json declares {n} field(s) ({fields}) pointing at files that don't exist; \
operator must reconcile by either renaming the on-disk file or editing metadata.json.",
n = drift.len(),
fields = fields.join(", "),
)),
Some(serde_json::json!({
"path": metadata_path.display().to_string(),
"drift": drift,
"recommended_fix": format!(
"Inspect {} and the listed expected_path entries; either rename the file or update metadata.json.",
metadata_path.display(),
),
})),
);
}
fn check_binary_version_mismatch(beads_dir: &Path, checks: &mut Vec<CheckResult>) {
let binary_version_str = env!("CARGO_PKG_VERSION");
let Some(repo_root) = find_beads_rust_repo_root(beads_dir) else {
push_check(
checks,
"binary_version",
CheckStatus::Ok,
Some(format!(
"Running br {binary_version_str}; no beads_rust Cargo.toml reachable from .beads/ — not flagging"
)),
Some(serde_json::json!({
"binary_version": binary_version_str,
"not_in_beads_rust_repo": true,
})),
);
return;
};
let cargo_toml_path = repo_root.join("Cargo.toml");
let Some(tree_version_str) = read_cargo_toml_version(&cargo_toml_path) else {
push_check(
checks,
"binary_version",
CheckStatus::Ok,
Some(format!(
"Running br {binary_version_str}; beads_rust Cargo.toml at {} has no readable version",
cargo_toml_path.display(),
)),
Some(serde_json::json!({
"binary_version": binary_version_str,
"cargo_toml": cargo_toml_path.display().to_string(),
})),
);
return;
};
let Ok(binary_version) = semver::Version::parse(binary_version_str) else {
push_check(
checks,
"binary_version",
CheckStatus::Ok,
Some(format!(
"Running br {binary_version_str}; binary version is not parseable semver"
)),
None,
);
return;
};
let Ok(tree_version) = semver::Version::parse(&tree_version_str) else {
push_check(
checks,
"binary_version",
CheckStatus::Ok,
Some(format!(
"Running br {binary_version_str}; Cargo.toml version {tree_version_str} is not parseable semver"
)),
None,
);
return;
};
if tree_version > binary_version {
push_check(
checks,
"binary_version",
CheckStatus::Warn,
Some(format!(
"Running br {binary_version_str} but beads_rust Cargo.toml at {} declares {tree_version_str}. \
Rebuild + reinstall to pick up the newer tree.",
cargo_toml_path.display(),
)),
Some(serde_json::json!({
"binary_version": binary_version_str,
"tree_version": tree_version_str,
"cargo_toml": cargo_toml_path.display().to_string(),
"repo_root": repo_root.display().to_string(),
"recommended_fix": format!(
"cd {} && cargo install --path . --locked",
repo_root.display(),
),
})),
);
} else {
push_check(
checks,
"binary_version",
CheckStatus::Ok,
Some(format!(
"Running br {binary_version_str}; matches (or is ahead of) Cargo.toml at {} ({})",
cargo_toml_path.display(),
tree_version_str,
)),
Some(serde_json::json!({
"binary_version": binary_version_str,
"tree_version": tree_version_str,
"cargo_toml": cargo_toml_path.display().to_string(),
})),
);
}
}
fn find_beads_rust_repo_root(start: &Path) -> Option<PathBuf> {
let mut current = start.parent()?.to_path_buf();
for _ in 0..32 {
let candidate = current.join("Cargo.toml");
if let Some(name) = read_cargo_toml_package_name(&candidate) {
return (name == "beads_rust").then_some(current);
}
let parent = current.parent()?;
if parent == current {
return None;
}
current = parent.to_path_buf();
}
None
}
fn read_cargo_toml_package_name(path: &Path) -> Option<String> {
let body = fs::read_to_string(path).ok()?;
parse_cargo_toml_package_field(&body, "name")
}
fn read_cargo_toml_version(path: &Path) -> Option<String> {
let body = fs::read_to_string(path).ok()?;
parse_cargo_toml_package_field(&body, "version")
}
fn parse_cargo_toml_package_field(body: &str, field: &str) -> Option<String> {
let mut in_package = false;
for raw in body.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(rest) = line.strip_prefix('[') {
let section = rest.trim_end_matches(']').trim();
in_package = section == "package";
continue;
}
if !in_package {
continue;
}
if let Some(rest) = line.strip_prefix(field) {
let rest = rest.trim_start();
if let Some(rest) = rest.strip_prefix('=') {
let rest = rest.trim();
let val_with_quotes = rest.split('#').next().unwrap_or("").trim();
if val_with_quotes.starts_with('"') && val_with_quotes.ends_with('"') {
return Some(val_with_quotes[1..val_with_quotes.len() - 1].to_string());
}
if val_with_quotes.starts_with('\'') && val_with_quotes.ends_with('\'') {
return Some(val_with_quotes[1..val_with_quotes.len() - 1].to_string());
}
}
}
}
None
}
const DEFAULT_STALE_LOCK_THRESHOLD_SECS: u64 = 300;
fn check_orphaned_write_lock(beads_dir: &Path, checks: &mut Vec<CheckResult>) {
let lock_path = beads_dir.join(".write.lock");
let Ok(meta) = fs::symlink_metadata(&lock_path) else {
push_write_lock_missing(checks);
return;
};
if !meta.file_type().is_file() {
push_write_lock_non_file(&lock_path, &meta, checks);
return;
}
let threshold_secs = stale_lock_threshold_secs();
let Ok(modified) = meta.modified() else {
push_write_lock_mtime_unreadable(&lock_path, checks);
return;
};
let Ok(age) = std::time::SystemTime::now().duration_since(modified) else {
push_write_lock_future_mtime(&lock_path, checks);
return;
};
let age_secs = age.as_secs();
if age_secs < threshold_secs {
push_write_lock_fresh(&lock_path, age_secs, threshold_secs, checks);
return;
}
match probe_write_lock_is_free(&lock_path) {
Some(true) => push_write_lock_free_despite_age(&lock_path, age_secs, checks),
Some(false) => push_write_lock_held_by_live_process(&lock_path, age_secs, checks),
None => {
push_write_lock_stale_unprobed(&lock_path, modified, age_secs, threshold_secs, checks);
}
}
}
#[allow(clippy::incompatible_msrv)]
fn probe_write_lock_is_free(lock_path: &Path) -> Option<bool> {
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(false)
.truncate(false)
.open(lock_path)
.ok()?;
match file.try_lock() {
Ok(()) => Some(true),
Err(std::fs::TryLockError::WouldBlock) => Some(false),
Err(std::fs::TryLockError::Error(_)) => None,
}
}
fn push_write_lock_missing(checks: &mut Vec<CheckResult>) {
push_check(
checks,
"write_lock",
CheckStatus::Ok,
Some("No .beads/.write.lock present (no writer contention)".to_string()),
None,
);
}
fn write_lock_node_kind(file_type: fs::FileType) -> &'static str {
if file_type.is_symlink() {
return "symlink";
}
if file_type.is_dir() {
return "directory";
}
#[cfg(unix)]
{
use std::os::unix::fs::FileTypeExt;
if file_type.is_fifo() {
return "fifo";
}
if file_type.is_socket() {
return "socket";
}
if file_type.is_block_device() {
return "block_device";
}
if file_type.is_char_device() {
return "char_device";
}
}
"unknown"
}
fn push_write_lock_non_file(lock_path: &Path, meta: &fs::Metadata, checks: &mut Vec<CheckResult>) {
let kind = write_lock_node_kind(meta.file_type());
push_check(
checks,
"write_lock",
CheckStatus::Error,
Some(format!(
".beads/.write.lock is a {kind}, not a regular file; a symlinked or otherwise \
non-regular lock node silently relocates or breaks workspace mutual exclusion. \
Verify no live writer is running, then move the node aside manually — \
br never modifies the lock node itself; the next mutating call recreates a \
regular lock file"
)),
Some(serde_json::json!({
"path": lock_path.display().to_string(),
"reason": "non_regular_lock_node",
"node_kind": kind,
})),
);
}
fn stale_lock_threshold_secs() -> u64 {
std::env::var("BR_DOCTOR_STALE_LOCK_THRESHOLD_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(DEFAULT_STALE_LOCK_THRESHOLD_SECS)
}
fn push_write_lock_mtime_unreadable(lock_path: &Path, checks: &mut Vec<CheckResult>) {
push_check(
checks,
"write_lock",
CheckStatus::Ok,
Some(
".beads/.write.lock present but mtime unreadable; cannot assess staleness".to_string(),
),
Some(serde_json::json!({
"path": lock_path.display().to_string(),
})),
);
}
fn push_write_lock_future_mtime(lock_path: &Path, checks: &mut Vec<CheckResult>) {
push_check(
checks,
"write_lock",
CheckStatus::Warn,
Some(
".beads/.write.lock has an mtime in the future (clock skew?); review manually"
.to_string(),
),
Some(serde_json::json!({
"path": lock_path.display().to_string(),
"reason": "mtime_in_future",
})),
);
}
fn push_write_lock_fresh(
lock_path: &Path,
age_secs: u64,
threshold_secs: u64,
checks: &mut Vec<CheckResult>,
) {
push_check(
checks,
"write_lock",
CheckStatus::Ok,
Some(format!(
".beads/.write.lock is {age_secs}s old (within {threshold_secs}s threshold); not stale"
)),
Some(serde_json::json!({
"path": lock_path.display().to_string(),
"age_secs": age_secs,
"threshold_secs": threshold_secs,
"reason": "persistent_advisory_inode",
})),
);
}
fn push_write_lock_free_despite_age(
lock_path: &Path,
age_secs: u64,
checks: &mut Vec<CheckResult>,
) {
push_check(
checks,
"write_lock",
CheckStatus::Ok,
Some(format!(
".beads/.write.lock file is {age_secs}s old but the advisory lock is FREE \
(non-blocking probe acquired it). Lock acquisition never updates mtime, \
so file age alone is not evidence of an orphan."
)),
Some(serde_json::json!({
"path": lock_path.display().to_string(),
"age_secs": age_secs,
"reason": "probe_acquired_free",
})),
);
}
fn push_write_lock_held_by_live_process(
lock_path: &Path,
age_secs: u64,
checks: &mut Vec<CheckResult>,
) {
push_check(
checks,
"write_lock",
CheckStatus::Ok,
Some(
".beads/.write.lock is currently held by a live process (non-blocking \
probe would block) — possibly this doctor run itself, which holds the \
workspace lock while checking; a held advisory lock is never an orphan"
.to_string(),
),
Some(serde_json::json!({
"path": lock_path.display().to_string(),
"age_secs": age_secs,
"reason": "persistent_advisory_inode",
"probe_result": "would_block_live_holder",
})),
);
}
fn push_write_lock_stale_unprobed(
lock_path: &Path,
modified: std::time::SystemTime,
age_secs: u64,
threshold_secs: u64,
checks: &mut Vec<CheckResult>,
) {
let mtime_rfc3339 = chrono::DateTime::<chrono::Utc>::from(modified)
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
push_check(
checks,
"write_lock",
CheckStatus::Warn,
Some(format!(
".beads/.write.lock is {age_secs}s old (threshold {threshold_secs}s) and the \
lock state could not be probed. Investigate whether a process holds it; do \
NOT move or rename the file — a holder keeps the old inode locked while the \
next writer would create and lock a new one, splitting mutual exclusion."
)),
Some(serde_json::json!({
"path": lock_path.display().to_string(),
"mtime": mtime_rfc3339,
"age_secs": age_secs,
"threshold_secs": threshold_secs,
"reason": "stale_mtime",
"probe": "failed",
"investigate_holders": "lsof -- .beads/.write.lock; pgrep -af 'br ' | grep -v doctor | grep -v grep",
"env_override": "BR_DOCTOR_STALE_LOCK_THRESHOLD_SECS",
})),
);
}
fn resolve_metadata_database_target(beads_dir: &Path, database: &str) -> PathBuf {
let candidate = PathBuf::from(database);
if candidate.is_absolute() {
candidate
} else {
crate::util::resolve_cache_dir(beads_dir).join(candidate)
}
}
fn resolve_metadata_jsonl_target(beads_dir: &Path, jsonl_export: &str) -> PathBuf {
let candidate = PathBuf::from(jsonl_export);
if candidate.is_absolute() {
candidate
} else {
beads_dir.join(candidate)
}
}
fn fix_root_gitignore_if_warned(
beads_dir: &Path,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
let has_warning = report
.checks
.iter()
.any(|c| c.name == "gitignore.beads_inner" && c.status == CheckStatus::Warn);
if !has_warning {
return false;
}
let root_gitignore_locked = report
.checks
.iter()
.any(|c| c.name == "permissions.root_gitignore" && matches!(c.status, CheckStatus::Warn));
if root_gitignore_locked {
if !ctx.is_json() {
ctx.warning("Skipping .gitignore repair: root .gitignore is not owner-writable");
}
return false;
}
let Some(project_root) = beads_dir.parent() else {
return false;
};
let gitignore_path = project_root.join(".gitignore");
let content = match read_root_gitignore_content(&gitignore_path) {
Ok(Some(content)) => content,
Ok(None) => return false,
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!("Skipping .gitignore repair: {err}"));
}
return false;
}
};
let filtered: Vec<&str> = content
.lines()
.filter(|line| !is_offending_root_gitignore_pattern(line))
.collect();
let mut new_content = filtered.join("\n");
if content.ends_with('\n') {
new_content.push('\n');
}
let write_result = if let Some(session) = session {
session.set_fixer("doctor.gitignore_repair");
chokepoint::mutate(
&session.ctx,
&gitignore_path,
Op::WriteFile {
content: new_content.into_bytes(),
mode: None,
},
)
.map(|_| ())
} else {
write_root_gitignore_atomically(&gitignore_path, new_content.as_bytes())
};
if let Err(err) = write_result {
if !ctx.is_json() {
ctx.warning(&format!("Failed to fix .gitignore: {err}"));
}
false
} else {
if !ctx.is_json() {
ctx.info(ROOT_GITIGNORE_REPAIR_MESSAGE);
}
true
}
}
fn fix_merge_artifacts_if_warned(
beads_dir: &Path,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
let has_warning = report
.checks
.iter()
.any(|c| c.name == "jsonl.merge_artifacts" && c.status == CheckStatus::Warn);
if !has_warning {
return false;
}
let Some(session) = session else {
if !ctx.is_json() {
ctx.warning(
"Skipping merge-artifact quarantine: no doctor repair session (run-dir creation failed)",
);
}
return false;
};
let mut artifacts: Vec<PathBuf> = Vec::new();
let entries = match beads_dir.read_dir() {
Ok(entries) => entries,
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!(
"Skipping merge-artifact quarantine: could not read {}: {err}",
beads_dir.display()
));
}
return false;
}
};
for entry in entries.flatten() {
let file_name = entry.file_name();
let Some(name) = file_name.to_str() else {
continue;
};
if name == "beads.base.jsonl" {
continue;
}
if name.contains(".base.jsonl")
|| name.contains(".left.jsonl")
|| name.contains(".right.jsonl")
{
artifacts.push(entry.path());
}
}
if artifacts.is_empty() {
return false;
}
session.set_fixer("doctor.merge_artifact_quarantine");
let mut quarantined = 0_usize;
for source in &artifacts {
let Some(name) = source.file_name() else {
continue;
};
let dest = session
.run
.root
.join("quarantine")
.join(".beads")
.join(name);
match chokepoint::mutate(&session.ctx, source, Op::Rename { to: dest.clone() }) {
Ok(result) if result.ok => {
quarantined += 1;
}
Ok(_) => {
if !ctx.is_json() {
ctx.warning(&format!(
"Merge-artifact quarantine no-op for {}",
source.display()
));
}
}
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!("Failed to quarantine {}: {err}", source.display()));
}
}
}
}
if quarantined > 0 && !ctx.is_json() {
ctx.info(&format!(
"Quarantined {quarantined} stuck merge artifact(s) under {}",
session.run.root.join("quarantine/.beads").display()
));
}
quarantined > 0
}
fn fix_startup_cache_if_warned(
beads_dir: &Path,
db_override: Option<&PathBuf>,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
let poisoned = config::doctor_inspect_startup_cache(beads_dir, db_override);
let cache_dir = config::doctor_startup_cache_dir();
fix_startup_cache_entries_if_warned(&poisoned, &cache_dir, report, ctx, session)
}
fn fix_startup_cache_entries_if_warned(
poisoned: &[config::PoisonedStartupCacheFile],
cache_dir: &Path,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
let has_warning = report
.checks
.iter()
.any(|c| c.name == "startup_cache.health" && c.status == CheckStatus::Warn);
if !has_warning {
return false;
}
let Some(session) = session else {
if !ctx.is_json() {
ctx.warning(
"Skipping startup-cache quarantine: no doctor repair session (run-dir creation failed)",
);
}
return false;
};
if poisoned.is_empty() {
return false;
}
if !session
.ctx
.capabilities
.write_scopes
.iter()
.any(|s| s == cache_dir)
{
session
.ctx
.capabilities
.write_scopes
.push(cache_dir.to_path_buf());
}
session.set_fixer("doctor.startup_cache_quarantine");
let mut quarantined = 0_usize;
for entry in poisoned {
let Some(name) = entry.path.file_name() else {
continue;
};
let dest = session
.run
.root
.join("quarantine")
.join("startup-cache")
.join(name);
match chokepoint::mutate(&session.ctx, &entry.path, Op::Rename { to: dest.clone() }) {
Ok(result) if result.ok => {
quarantined += 1;
}
Ok(_) => {
if !ctx.is_json() {
ctx.warning(&format!(
"Startup-cache quarantine no-op for {}",
entry.path.display()
));
}
}
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!(
"Failed to quarantine {}: {err}",
entry.path.display()
));
}
}
}
}
if quarantined > 0 && !ctx.is_json() {
ctx.info(&format!(
"Quarantined {quarantined} poisoned startup-cache file(s) under {}",
session.run.root.join("quarantine/startup-cache").display()
));
}
quarantined > 0
}
fn fix_recovery_artifacts_aged_if_warned(
beads_dir: &Path,
db_path: &Path,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
let has_warning = report
.checks
.iter()
.any(|c| c.name == "db.recovery_artifacts.aged" && c.status == CheckStatus::Warn);
if !has_warning {
return false;
}
let Some(session) = session else {
if !ctx.is_json() {
ctx.warning(
"Skipping recovery-artifact quarantine: no doctor repair session (run-dir creation failed)",
);
}
return false;
};
let aged = match recovery_artifacts_aged(beads_dir, db_path) {
Ok(items) => items,
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!(
"Failed to enumerate aged recovery artifacts: {err}"
));
}
return false;
}
};
if aged.is_empty() {
return false;
}
session.set_fixer("doctor.recovery_artifacts_aged_quarantine");
let mut quarantined = 0_usize;
for source in &aged {
let Some(name) = source.file_name() else {
continue;
};
let dest = session
.run
.root
.join("quarantine")
.join(".beads")
.join(".br_recovery")
.join(name);
match chokepoint::mutate(&session.ctx, source, Op::Rename { to: dest.clone() }) {
Ok(result) if result.ok => {
quarantined += 1;
}
Ok(_) => {
if !ctx.is_json() {
ctx.warning(&format!(
"Aged-artifact quarantine no-op for {}",
source.display()
));
}
}
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!("Failed to quarantine {}: {err}", source.display()));
}
}
}
}
if quarantined > 0 && !ctx.is_json() {
ctx.info(&format!(
"Quarantined {quarantined} aged recovery artifact(s) under {}",
session
.run
.root
.join("quarantine/.beads/.br_recovery")
.display()
));
}
quarantined > 0
}
fn fix_export_hash_cache_divergence_if_warned(
db_path: &Path,
jsonl_path: Option<&Path>,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
let has_warning = report
.checks
.iter()
.any(|c| c.name == "db.export_hash_cache" && c.status == CheckStatus::Warn);
if !has_warning {
return false;
}
let Some(jsonl) = jsonl_path else {
if !ctx.is_json() {
ctx.warning(
"Skipping export-hash-cache repair: no JSONL path available (authoritative source missing)",
);
}
return false;
};
let Some(session) = session else {
if !ctx.is_json() {
ctx.warning(
"Skipping export-hash-cache repair: no doctor repair session (run-dir creation failed)",
);
}
return false;
};
let Ok(computed) = crate::sync::compute_jsonl_hash(jsonl).inspect_err(|err| {
if !ctx.is_json() {
ctx.warning(&format!(
"Skipping export-hash-cache repair: failed to compute current JSONL hash: {err}"
));
}
}) else {
return false;
};
session.set_fixer("doctor.export_hash_cache_repair");
let op = Op::DbExec {
sql: "UPDATE metadata SET value = ?1 WHERE key = 'jsonl_content_hash'".to_string(),
args: vec![chokepoint::DbArg::Text(computed.clone())],
affected_tables: vec!["metadata".to_string()],
affected_predicate: Some("key = 'jsonl_content_hash'".to_string()),
};
match chokepoint::mutate(&session.ctx, db_path, op) {
Ok(result) if result.ok => {
if !ctx.is_json() {
ctx.info(&format!(
"Recomputed metadata.jsonl_content_hash = {}",
&computed[..16.min(computed.len())]
));
}
true
}
Ok(_) => false,
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!("Failed to repair export-hash cache: {err}"));
}
false
}
}
}
fn fix_base_jsonl_symlink_if_warned(
beads_dir: &Path,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
let symlink_finding = report.checks.iter().find(|c| {
c.name == "base_jsonl"
&& c.status == CheckStatus::Warn
&& c.details
.as_ref()
.and_then(|d| d.get("kind"))
.and_then(|v| v.as_str())
== Some("symlink")
});
let Some(_) = symlink_finding else {
return false;
};
let Some(session) = session else {
if !ctx.is_json() {
ctx.warning(
"Skipping base-jsonl symlink quarantine: no doctor repair session (run-dir creation failed)",
);
}
return false;
};
let source = beads_dir.join("beads.base.jsonl");
match fs::symlink_metadata(&source) {
Ok(meta) if meta.file_type().is_symlink() => {}
_ => return false,
}
let dest = session
.run
.root
.join("quarantine")
.join(".beads")
.join("beads.base.jsonl");
session.set_fixer("doctor.base_jsonl_symlink_quarantine");
match chokepoint::mutate(&session.ctx, &source, Op::Rename { to: dest.clone() }) {
Ok(result) if result.ok => {
if !ctx.is_json() {
ctx.info(&format!(
"Quarantined symlinked merge anchor to {}",
dest.display()
));
}
true
}
Ok(_) => false,
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!("Failed to quarantine base.jsonl symlink: {err}"));
}
false
}
}
}
fn fix_base_jsonl_stale_if_warned(
beads_dir: &Path,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
let stale_finding = report.checks.iter().find(|c| {
c.name == "base_jsonl"
&& c.status == CheckStatus::Warn
&& c.details
.as_ref()
.and_then(|d| d.get("kind"))
.and_then(|v| v.as_str())
== Some("stale")
});
if stale_finding.is_none() {
return false;
}
let Some(session) = session else {
if !ctx.is_json() {
ctx.warning(
"Skipping base-jsonl regeneration: no doctor repair session (run-dir creation failed)",
);
}
return false;
};
let live = beads_dir.join("issues.jsonl");
let anchor = beads_dir.join("beads.base.jsonl");
let live_bytes = match fs::read(&live) {
Ok(b) => b,
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!(
"Skipping base-jsonl regeneration: cannot read {}: {err}",
live.display()
));
}
return false;
}
};
if live_bytes.is_empty() {
return false;
}
if fs::read(&anchor).is_ok_and(|anchor_bytes| anchor_bytes == live_bytes) {
return false;
}
session.set_fixer("doctor.base_jsonl_regen");
match chokepoint::mutate(
&session.ctx,
&anchor,
Op::WriteFile {
content: live_bytes,
mode: None,
},
) {
Ok(result) if result.ok => {
if !ctx.is_json() {
ctx.info(&format!(
"Regenerated merge anchor {} from current JSONL",
anchor.display()
));
}
true
}
Ok(_) => false,
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!("Failed to regenerate base.jsonl anchor: {err}"));
}
false
}
}
}
fn fix_orphan_tmp_files_if_warned(
beads_dir: &Path,
report: &DoctorReport,
ctx: &OutputContext,
session: Option<&mut DoctorRepairSession>,
) -> bool {
use std::time::{Duration, SystemTime};
let has_warning = report
.checks
.iter()
.any(|c| c.name == "tmp_files_orphan" && c.status == CheckStatus::Warn);
if !has_warning {
return false;
}
let Some(session) = session else {
if !ctx.is_json() {
ctx.warning(
"Skipping orphan-tmp quarantine: no doctor repair session (run-dir creation failed)",
);
}
return false;
};
let Ok(entries) = fs::read_dir(beads_dir) else {
return false;
};
let now = SystemTime::now();
let threshold = Duration::from_secs(ORPHAN_TMP_AGE_THRESHOLD_SECS);
let mut orphans: Vec<PathBuf> = Vec::new();
for entry in entries.flatten() {
if let Some((_name, path)) = orphan_tmp_entry(&entry, now, threshold) {
orphans.push(path);
}
}
orphans.sort();
if orphans.is_empty() {
return false;
}
session.set_fixer("doctor.orphan_tmp_quarantine");
let mut quarantined = 0_usize;
for source in &orphans {
let Some(name) = source.file_name() else {
continue;
};
let dest = session
.run
.root
.join("quarantine")
.join(".beads")
.join(name);
match chokepoint::mutate(&session.ctx, source, Op::Rename { to: dest.clone() }) {
Ok(result) if result.ok => quarantined += 1,
Ok(_) => {
if !ctx.is_json() {
ctx.warning(&format!(
"Orphan-tmp quarantine no-op for {}",
source.display()
));
}
}
Err(err) => {
if !ctx.is_json() {
ctx.warning(&format!("Failed to quarantine {}: {err}", source.display()));
}
}
}
}
if quarantined > 0 && !ctx.is_json() {
ctx.info(&format!(
"Quarantined {quarantined} orphan tmp file(s) under {}",
session.run.root.join("quarantine/.beads").display()
));
}
quarantined > 0
}
fn read_root_gitignore_content(gitignore_path: &Path) -> Result<Option<String>> {
let metadata = match fs::symlink_metadata(gitignore_path) {
Ok(metadata) => metadata,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err.into()),
};
if metadata.file_type().is_symlink() {
return Err(BeadsError::Config(format!(
"refusing to inspect or repair symlinked root .gitignore: {}",
gitignore_path.display()
)));
}
if !metadata.is_file() {
return Err(BeadsError::Config(format!(
"root .gitignore is not a regular file: {}",
gitignore_path.display()
)));
}
Ok(Some(fs::read_to_string(gitignore_path)?))
}
fn root_gitignore_temp_path(gitignore_path: &Path, attempt: u32) -> PathBuf {
let pid = std::process::id();
let file_name = gitignore_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(".gitignore");
let temp_name = if attempt == 0 {
format!("{file_name}.{pid}.tmp")
} else {
format!("{file_name}.{pid}.{attempt}.tmp")
};
gitignore_path.with_file_name(temp_name)
}
fn create_root_gitignore_temp_file(gitignore_path: &Path) -> Result<(PathBuf, fs::File)> {
for attempt in 0..64_u32 {
let temp_path = root_gitignore_temp_path(gitignore_path, attempt);
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp_path)
{
Ok(file) => return Ok((temp_path, file)),
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
Err(err) => return Err(err.into()),
}
}
Err(BeadsError::Config(format!(
"Failed to allocate temp .gitignore file for {}",
gitignore_path.display()
)))
}
fn write_root_gitignore_atomically(gitignore_path: &Path, contents: &[u8]) -> Result<()> {
let permissions = fs::symlink_metadata(gitignore_path)?.permissions();
let (temp_path, mut temp_file) = create_root_gitignore_temp_file(gitignore_path)?;
if let Err(err) = fs::set_permissions(&temp_path, permissions) {
tracing::warn!(
path = %gitignore_path.display(),
error = %err,
"Failed to apply original .gitignore permissions before atomic rewrite"
);
}
if let Err(err) = temp_file
.write_all(contents)
.and_then(|()| temp_file.sync_all())
{
drop(temp_file);
let _ = fs::remove_file(&temp_path);
return Err(err.into());
}
drop(temp_file);
crate::util::durable_rename(&temp_path, gitignore_path).inspect_err(|_| {
let _ = fs::remove_file(&temp_path);
})?;
Ok(())
}
fn discover_jsonl(beads_dir: &Path) -> Option<PathBuf> {
let issues = beads_dir.join("issues.jsonl");
if issues.exists() {
return Some(issues);
}
let legacy = beads_dir.join("beads.jsonl");
if legacy.exists() {
return Some(legacy);
}
None
}
fn should_fallback_to_workspace_jsonl(beads_dir: &Path, paths: &config::ConfigPaths) -> bool {
let has_env_override = std::env::var("BEADS_JSONL").is_ok_and(|value| !value.trim().is_empty());
!has_env_override
&& paths.metadata.jsonl_export == "issues.jsonl"
&& paths.jsonl_path == beads_dir.join("issues.jsonl")
}
fn select_doctor_jsonl_path(beads_dir: &Path, paths: &config::ConfigPaths) -> Option<PathBuf> {
if paths.jsonl_path.exists() {
Some(paths.jsonl_path.clone())
} else if should_fallback_to_workspace_jsonl(beads_dir, paths) {
discover_jsonl(beads_dir)
} else {
Some(paths.jsonl_path.clone())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum JsonlCountState {
Available(usize),
Invalid,
Missing,
Unreadable,
}
struct IdDelta {
only_db: Vec<String>,
only_jsonl: Vec<String>,
both_count: usize,
}
impl IdDelta {
const PER_SIDE_PREVIEW_LIMIT: usize = 50;
fn to_json(&self) -> serde_json::Value {
let cap = Self::PER_SIDE_PREVIEW_LIMIT;
let mut only_db_preview = self.only_db.clone();
let mut only_jsonl_preview = self.only_jsonl.clone();
only_db_preview.sort();
only_jsonl_preview.sort();
only_db_preview.truncate(cap);
only_jsonl_preview.truncate(cap);
serde_json::json!({
"only_db_count": self.only_db.len(),
"only_jsonl_count": self.only_jsonl.len(),
"both_count": self.both_count,
"only_db": only_db_preview,
"only_jsonl": only_jsonl_preview,
"preview_limit": cap,
})
}
}
fn compute_db_jsonl_id_delta(conn: &Connection, jsonl_path: &Path) -> Result<IdDelta> {
use std::collections::HashSet;
use std::io::{BufRead, BufReader};
let rows = conn.query(
"SELECT id FROM issues \
WHERE (ephemeral = 0 OR ephemeral IS NULL) AND id NOT LIKE '%-wisp-%'",
)?;
let mut db_ids: HashSet<String> = HashSet::with_capacity(rows.len());
for row in &rows {
if let Some(id) = row.get(0).and_then(SqliteValue::as_text) {
db_ids.insert(id.to_string());
}
}
let file = std::fs::File::open(jsonl_path)?;
let reader = BufReader::with_capacity(2 * 1024 * 1024, file);
let mut jsonl_ids: HashSet<String> = HashSet::new();
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed)
&& let Some(id) = value.get("id").and_then(serde_json::Value::as_str)
{
if id.contains("-wisp-") {
continue;
}
jsonl_ids.insert(id.to_string());
}
}
let mut only_db: Vec<String> = db_ids.difference(&jsonl_ids).cloned().collect();
let mut only_jsonl: Vec<String> = jsonl_ids.difference(&db_ids).cloned().collect();
only_db.sort();
only_jsonl.sort();
let both_count = db_ids.intersection(&jsonl_ids).count();
Ok(IdDelta {
only_db,
only_jsonl,
both_count,
})
}
fn check_jsonl(path: &Path, checks: &mut Vec<CheckResult>) -> Result<JsonlCountState> {
let summary = validate_jsonl_issue_records(path)?;
if summary.invalid_count == 0 {
push_check(
checks,
"jsonl.parse",
CheckStatus::Ok,
Some(format!("Parsed {} records", summary.record_count)),
Some(serde_json::json!({
"path": path.display().to_string(),
"records": summary.record_count
})),
);
Ok(JsonlCountState::Available(summary.record_count))
} else {
let preview = summary.preview_messages();
push_check(
checks,
"jsonl.parse",
CheckStatus::Error,
Some(format!(
"Malformed or invalid issue records: {} ({})",
summary.invalid_count,
preview.join("; ")
)),
Some(serde_json::json!({
"path": path.display().to_string(),
"records": summary.record_count,
"invalid_lines": summary
.failures
.iter()
.map(|failure| failure.line)
.collect::<Vec<_>>(),
"invalid_count": summary.invalid_count,
"invalid_examples": summary
.failures
.iter()
.map(|failure| serde_json::json!({
"line": failure.line,
"error": failure.message
}))
.collect::<Vec<_>>()
})),
);
Ok(JsonlCountState::Invalid)
}
}
fn check_db_count(
conn: &Connection,
jsonl_count: JsonlCountState,
jsonl_path: Option<&Path>,
checks: &mut Vec<CheckResult>,
) -> Result<()> {
let db_count: i64 = conn.query_row(
"SELECT count(*) FROM issues WHERE (ephemeral = 0 OR ephemeral IS NULL) AND id NOT LIKE '%-wisp-%'",
)?
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or(0);
match jsonl_count {
JsonlCountState::Available(jsonl_count) => {
check_available_db_count(conn, db_count, jsonl_count, jsonl_path, checks);
}
JsonlCountState::Invalid => {
push_check(
checks,
"counts.db_vs_jsonl",
CheckStatus::Warn,
Some("JSONL is invalid; cannot compare counts".to_string()),
Some(serde_json::json!({ "db": db_count })),
);
}
JsonlCountState::Missing => {
push_check(
checks,
"counts.db_vs_jsonl",
CheckStatus::Warn,
Some("JSONL not found; cannot compare counts".to_string()),
Some(serde_json::json!({ "db": db_count })),
);
}
JsonlCountState::Unreadable => {
push_check(
checks,
"counts.db_vs_jsonl",
CheckStatus::Warn,
Some("JSONL unreadable; cannot compare counts".to_string()),
Some(serde_json::json!({ "db": db_count })),
);
}
}
Ok(())
}
fn check_available_db_count(
conn: &Connection,
db_count: i64,
jsonl_count: usize,
jsonl_path: Option<&Path>,
checks: &mut Vec<CheckResult>,
) {
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let db_count_usize = db_count as usize;
if db_count_usize == jsonl_count {
if push_matching_count_id_delta_check(conn, db_count, jsonl_count, jsonl_path, checks) {
return;
}
push_check(
checks,
"counts.db_vs_jsonl",
CheckStatus::Ok,
Some(format!("Both have {db_count} records")),
None,
);
return;
}
let mut details = serde_json::json!({
"db": db_count,
"jsonl": jsonl_count,
});
if let Some(path) = jsonl_path {
match compute_db_jsonl_id_delta(conn, path) {
Ok(delta) => details["id_delta"] = delta.to_json(),
Err(delta_err) => {
details["id_delta_error"] = serde_json::json!(delta_err.to_string());
}
}
}
push_check(
checks,
"counts.db_vs_jsonl",
CheckStatus::Warn,
Some("DB and JSONL counts differ".to_string()),
Some(details),
);
}
fn push_matching_count_id_delta_check(
conn: &Connection,
db_count: i64,
jsonl_count: usize,
jsonl_path: Option<&Path>,
checks: &mut Vec<CheckResult>,
) -> bool {
let Some(path) = jsonl_path else {
return false;
};
match compute_db_jsonl_id_delta(conn, path) {
Ok(delta) if !delta.only_db.is_empty() || !delta.only_jsonl.is_empty() => {
push_check(
checks,
"counts.db_vs_jsonl",
CheckStatus::Warn,
Some(format!(
"DB and JSONL counts match ({}) but id sets diverge: {} only in DB, {} only in JSONL",
db_count,
delta.only_db.len(),
delta.only_jsonl.len(),
)),
Some(serde_json::json!({
"db": db_count,
"jsonl": jsonl_count,
"id_delta": delta.to_json(),
})),
);
true
}
Ok(_) => false,
Err(delta_err) => {
push_check(
checks,
"counts.db_vs_jsonl",
CheckStatus::Warn,
Some(format!(
"DB and JSONL counts match ({db_count}) but id-set verification failed: {delta_err}"
)),
Some(serde_json::json!({
"db": db_count,
"jsonl": jsonl_count,
"id_delta_error": delta_err.to_string(),
})),
);
true
}
}
}
#[allow(clippy::too_many_lines)]
fn check_sync_jsonl_path(jsonl_path: &Path, beads_dir: &Path, checks: &mut Vec<CheckResult>) {
let check_name = "sync_jsonl_path";
if let Some(_name) = jsonl_path.file_name().and_then(|n| n.to_str()) {
let git_check = validate_no_git_path(jsonl_path);
if !git_check.is_allowed() {
let reason = git_check.rejection_reason().unwrap_or_default();
push_check(
checks,
check_name,
CheckStatus::Error,
Some(format!("JSONL path targets git internals: {reason}")),
Some(serde_json::json!({
"path": jsonl_path.display().to_string(),
"reason": reason,
"remediation": "Move JSONL file inside .beads/ directory"
})),
);
return;
}
let is_external = config::resolved_jsonl_path_is_external(beads_dir, jsonl_path);
if is_external {
match validate_sync_path_with_external(jsonl_path, beads_dir, true) {
Ok(()) => {
push_check(
checks,
check_name,
CheckStatus::Ok,
Some("Configured external JSONL path is valid for sync I/O".to_string()),
Some(serde_json::json!({
"path": jsonl_path.display().to_string(),
"beads_dir": beads_dir.display().to_string(),
"external": true
})),
);
}
Err(err) => {
push_check(
checks,
check_name,
CheckStatus::Error,
Some(format!("Configured external JSONL path is invalid: {err}")),
Some(serde_json::json!({
"path": jsonl_path.display().to_string(),
"beads_dir": beads_dir.display().to_string(),
"external": true
})),
);
}
}
return;
}
let path_validation = validate_sync_path(jsonl_path, beads_dir);
match path_validation {
PathValidation::Allowed => {
push_check(
checks,
check_name,
CheckStatus::Ok,
Some("JSONL path is within sync allowlist".to_string()),
Some(serde_json::json!({
"path": jsonl_path.display().to_string(),
"beads_dir": beads_dir.display().to_string()
})),
);
}
PathValidation::OutsideBeadsDir {
path,
beads_dir: bd,
} => {
push_check(
checks,
check_name,
CheckStatus::Warn,
Some("JSONL path is outside .beads/ directory".to_string()),
Some(serde_json::json!({
"path": path.display().to_string(),
"beads_dir": bd.display().to_string(),
"remediation": "Use --allow-external-jsonl flag or move JSONL inside .beads/"
})),
);
}
PathValidation::DisallowedExtension { path, extension } => {
push_check(
checks,
check_name,
CheckStatus::Error,
Some(format!("JSONL path has disallowed extension: {extension}")),
Some(serde_json::json!({
"path": path.display().to_string(),
"extension": extension,
"remediation": "Use a .jsonl extension for JSONL files"
})),
);
}
PathValidation::TraversalAttempt { path } => {
push_check(
checks,
check_name,
CheckStatus::Error,
Some("JSONL path contains traversal sequences".to_string()),
Some(serde_json::json!({
"path": path.display().to_string(),
"remediation": "Remove '..' sequences from path"
})),
);
}
PathValidation::SymlinkEscape { path, target } => {
push_check(
checks,
check_name,
CheckStatus::Error,
Some("JSONL path is a symlink pointing outside .beads/".to_string()),
Some(serde_json::json!({
"symlink": path.display().to_string(),
"target": target.display().to_string(),
"remediation": "Remove symlink and use a regular file inside .beads/"
})),
);
}
PathValidation::CanonicalizationFailed { path, error } => {
push_check(
checks,
check_name,
CheckStatus::Warn,
Some(format!("Could not verify JSONL path: {error}")),
Some(serde_json::json!({
"path": path.display().to_string(),
"error": error
})),
);
}
PathValidation::NonRegularFile { path } => {
push_check(
checks,
check_name,
CheckStatus::Error,
Some("JSONL path is not a regular file".to_string()),
Some(serde_json::json!({
"path": path.display().to_string(),
"remediation": "Replace the path with a regular .jsonl file"
})),
);
}
PathValidation::GitPathAttempt { path } => {
push_check(
checks,
check_name,
CheckStatus::Error,
Some("JSONL path targets git internals".to_string()),
Some(serde_json::json!({
"path": path.display().to_string(),
"remediation": "Move JSONL file inside .beads/ directory"
})),
);
}
}
} else {
push_check(
checks,
check_name,
CheckStatus::Error,
Some("Invalid JSONL path (not valid UTF-8)".to_string()),
Some(serde_json::json!({
"path": jsonl_path.display().to_string(),
"remediation": "Ensure the path is valid UTF-8"
})),
);
}
}
#[allow(clippy::unnecessary_wraps)]
fn check_sync_conflict_markers(jsonl_path: &Path, checks: &mut Vec<CheckResult>) {
let check_name = "sync_conflict_markers";
if !jsonl_path.exists() {
return;
}
match scan_conflict_markers(jsonl_path) {
Ok(markers) => {
if markers.is_empty() {
push_check(
checks,
check_name,
CheckStatus::Ok,
Some("No merge conflict markers found".to_string()),
None,
);
} else {
let preview: Vec<serde_json::Value> = markers
.iter()
.take(5)
.map(|m| {
serde_json::json!({
"line": m.line,
"type": format!("{:?}", m.marker_type),
"branch": m.branch.as_deref().unwrap_or("")
})
})
.collect();
push_check(
checks,
check_name,
CheckStatus::Error,
Some(format!(
"Found {} merge conflict marker(s) in JSONL",
markers.len()
)),
Some(serde_json::json!({
"path": jsonl_path.display().to_string(),
"count": markers.len(),
"markers_preview": preview,
"remediation": "Resolve git merge conflicts in the JSONL file before running sync"
})),
);
}
}
Err(e) => {
push_check(
checks,
check_name,
CheckStatus::Warn,
Some(format!("Could not scan for conflict markers: {e}")),
Some(serde_json::json!({
"path": jsonl_path.display().to_string(),
"error": e.to_string()
})),
);
}
}
}
#[allow(clippy::too_many_lines)]
fn check_sync_metadata(
conn: &Connection,
db_path: &Path,
jsonl_path: Option<&Path>,
checks: &mut Vec<CheckResult>,
) {
let last_import = latest_metadata_value(conn, "last_import_time");
let last_export = latest_metadata_value(conn, "last_export_time");
let jsonl_hash = latest_metadata_value(conn, "jsonl_content_hash");
let dirty_count: i64 = conn
.query_row("SELECT count(*) FROM dirty_issues")
.ok()
.and_then(|row| row.get(0).and_then(SqliteValue::as_integer))
.unwrap_or(0);
let mut details = serde_json::json!({
"dirty_issues": dirty_count
});
if let Some(ts) = &last_import {
details["last_import"] = serde_json::json!(ts);
}
if let Some(ts) = &last_export {
details["last_export"] = serde_json::json!(ts);
}
if let Some(hash) = &jsonl_hash {
details["jsonl_hash"] = serde_json::json!(&hash[..16.min(hash.len())]);
}
let (jsonl_exists, jsonl_newer, db_newer) = if let Some(p) = jsonl_path {
match SqliteStorage::open(db_path).and_then(|storage| compute_staleness(&storage, p)) {
Ok(staleness) => (
staleness.jsonl_exists,
staleness.jsonl_newer,
staleness.db_newer,
),
Err(err) => {
tracing::warn!(
error = %err,
"compute_staleness failed in doctor; falling back to dirty-count only"
);
(p.exists(), false, dirty_count > 0)
}
}
} else {
(false, false, dirty_count > 0)
};
details["jsonl_exists"] = serde_json::json!(jsonl_exists);
details["jsonl_newer"] = serde_json::json!(jsonl_newer);
details["db_newer"] = serde_json::json!(db_newer);
details["pending_import"] = serde_json::json!(jsonl_newer);
details["pending_export"] = serde_json::json!(db_newer);
match (jsonl_newer, db_newer) {
(false, false) => {
push_check(
checks,
"sync.metadata",
CheckStatus::Ok,
Some("Database and JSONL are in sync".to_string()),
Some(details),
);
}
(true, false) => {
let jsonl_has_importable_content = jsonl_path
.and_then(|p| std::fs::metadata(p).ok())
.is_some_and(|meta| meta.len() > 0);
if jsonl_has_importable_content {
push_check(
checks,
"sync.metadata",
CheckStatus::Warn,
Some("External changes pending import".to_string()),
Some(details),
);
} else {
push_check(
checks,
"sync.metadata",
CheckStatus::Ok,
Some(
"External changes pending import (empty JSONL, nothing to import)"
.to_string(),
),
Some(details),
);
}
}
(false, true) => {
let message = if last_export.is_none() && dirty_count > 0 {
"Local changes exist but no export is recorded; consider running sync --flush-only"
} else {
"Local changes pending export"
};
push_check(
checks,
"sync.metadata",
CheckStatus::Warn,
Some(message.to_string()),
Some(details),
);
}
(true, true) => {
push_check(
checks,
"sync.metadata",
CheckStatus::Warn,
Some("Database and JSONL have diverged (merge required)".to_string()),
Some(details),
);
}
}
}
#[allow(clippy::too_many_lines)]
fn execute_repair_indexes(
beads_dir: &Path,
paths: &config::ConfigPaths,
ctx: &OutputContext,
args: &DoctorArgs,
cli: &config::CliOverrides,
) -> Result<()> {
let write_authority = if let Some(authority) =
cli.database_family_write_authority_for(beads_dir, &paths.db_path)
{
if let Err(err) = authority.verify_database_authority() {
emit_concurrency_lost(beads_dir, &err, ctx, "--repair-indexes");
crate::shutdown::exit_process(DoctorExitCode::ConcurrencyLost.as_i32());
}
Arc::clone(authority)
} else {
match acquire_doctor_database_write_authority(beads_dir, &paths.db_path, Some(0)) {
Ok(authority) => authority,
Err(err) => {
emit_concurrency_lost(beads_dir, &err, ctx, "--repair-indexes");
crate::shutdown::exit_process(DoctorExitCode::ConcurrencyLost.as_i32());
}
}
};
match refuse_gates::run_all(beads_dir, &paths.db_path) {
GateOutcome::Allow => {}
GateOutcome::Refuse {
code: _,
reason,
evidence,
} => {
emit_refused_unsafe("--repair-indexes", &reason, &evidence, ctx);
crate::shutdown::exit_process(DoctorExitCode::RefusedUnsafe.as_i32());
}
}
if !args.dry_run {
refuse_doctor_mutation_if_merge_pending(
"--repair-indexes",
&paths.db_path,
&write_authority,
ctx,
);
}
let snapshot_path = paths.db_path.with_extension("db.pre-repair-indexes");
let wal_path = PathBuf::from(format!("{}-wal", paths.db_path.to_string_lossy()));
let shm_path = PathBuf::from(format!("{}-shm", paths.db_path.to_string_lossy()));
if args.dry_run {
ctx.info(&format!(
"[dry-run] Would snapshot {} -> {} and REINDEX every user index on the issues family",
paths.db_path.display(),
snapshot_path.display(),
));
return Ok(());
}
checkpoint_and_snapshot_repair_indexes(&paths.db_path, &snapshot_path, &write_authority)?;
write_authority.verify_database_authority()?;
let conn = Connection::open(paths.db_path.to_string_lossy().into_owned())?;
write_authority.verify_database_authority()?;
let rows = match conn.query(
"SELECT name FROM sqlite_master \
WHERE type = 'index' \
AND name NOT LIKE 'sqlite_autoindex_%' \
AND sql IS NOT NULL \
ORDER BY name",
) {
Ok(rows) => rows,
Err(err) => {
close_repair_indexes_connection(conn, "after index enumeration failed");
return Err(err.into());
}
};
let index_names: Vec<String> = rows
.iter()
.filter_map(|row| row.get(0).and_then(SqliteValue::as_text).map(String::from))
.collect();
if index_names.is_empty() {
ctx.info("doctor --repair-indexes: no user indexes found; nothing to do");
conn.close()?;
write_authority.verify_database_authority()?;
return Ok(());
}
if let Err(err) = conn.execute("BEGIN IMMEDIATE") {
close_repair_indexes_connection(conn, "after BEGIN IMMEDIATE failed");
return Err(err.into());
}
let reindex_result: Result<usize> = (|| {
write_authority.verify_database_authority()?;
let mut reindexed_count = 0;
for name in &index_names {
conn.execute(&format!("REINDEX {}", quote_sql_identifier(name)))?;
reindexed_count += 1;
}
write_authority.verify_database_authority()?;
conn.execute("COMMIT")?;
write_authority.verify_database_authority()?;
Ok(reindexed_count)
})();
match reindex_result {
Ok(reindexed_count) => {
conn.close()?;
write_authority.verify_database_authority()?;
ctx.success(&format!(
"doctor --repair-indexes: REINDEX completed on {reindexed_count} user indexes (pre-snapshot retained at {})",
snapshot_path.display(),
));
Ok(())
}
Err(err) => {
let _ = conn.execute("ROLLBACK");
tracing::warn!(
error = %err,
snapshot = %snapshot_path.display(),
"doctor --repair-indexes: REINDEX failed; rolling back from pre-snapshot"
);
close_repair_indexes_connection(conn, "before restoring pre-snapshot");
restore_repair_indexes_snapshot(
paths,
&snapshot_path,
[&wal_path, &shm_path],
&err,
&write_authority,
)?;
Err(err)
}
}
}
fn checkpoint_and_snapshot_repair_indexes(
db_path: &Path,
snapshot_path: &Path,
write_authority: &Arc<crate::sync::DatabaseFamilyWriteLock>,
) -> Result<()> {
write_authority.verify_database_authority()?;
let conn = Connection::open(db_path.to_string_lossy().into_owned())?;
let checkpoint_complete = match wal_checkpoint_truncate_complete(&conn) {
Ok(complete) => complete,
Err(checkpoint_err) => {
tracing::warn!(
error = %checkpoint_err,
"doctor --repair-indexes: wal_checkpoint(TRUNCATE) before snapshot failed; will also snapshot WAL/SHM sidecars to preserve full pre-state"
);
false
}
};
close_repair_indexes_connection(conn, "after pre-snapshot WAL checkpoint");
write_authority.verify_database_authority()?;
let wal_snap = PathBuf::from(format!("{}-wal", snapshot_path.to_string_lossy()));
let shm_snap = PathBuf::from(format!("{}-shm", snapshot_path.to_string_lossy()));
for stale in [&wal_snap, &shm_snap] {
match std::fs::remove_file(stale) {
Ok(()) => tracing::debug!(
path = %stale.display(),
"doctor --repair-indexes: cleared stale sidecar snapshot from a previous run"
),
Err(rm_err) if rm_err.kind() == std::io::ErrorKind::NotFound => {}
Err(rm_err) => {
return Err(BeadsError::Internal {
message: format!(
"doctor --repair-indexes: failed to remove stale sidecar snapshot {}; refusing to continue because restore could incorrectly use it as pre-state: {rm_err}",
stale.display(),
),
});
}
}
}
ensure_repair_indexes_snapshot_target_safe(snapshot_path)?;
std::fs::copy(db_path, snapshot_path).map_err(|err| BeadsError::Internal {
message: format!(
"doctor --repair-indexes: pre-snapshot backup failed ({}): {err}",
snapshot_path.display(),
),
})?;
write_authority.verify_database_authority()?;
if !checkpoint_complete {
let wal_live = PathBuf::from(format!("{}-wal", db_path.to_string_lossy()));
let shm_live = PathBuf::from(format!("{}-shm", db_path.to_string_lossy()));
for (live, snap, kind) in [(&wal_live, &wal_snap, "WAL"), (&shm_live, &shm_snap, "SHM")] {
if live.exists()
&& let Err(copy_err) = std::fs::copy(live, snap)
{
return Err(BeadsError::Internal {
message: format!(
"doctor --repair-indexes: post-checkpoint-failure {kind} snapshot ({}) failed: {copy_err}",
snap.display(),
),
});
}
}
}
write_authority.verify_database_authority()?;
Ok(())
}
fn ensure_repair_indexes_snapshot_target_safe(snapshot_path: &Path) -> Result<()> {
match fs::symlink_metadata(snapshot_path) {
Ok(metadata) if metadata.file_type().is_symlink() => Err(BeadsError::Internal {
message: format!(
"doctor --repair-indexes: refusing to write pre-snapshot backup through symlink {}",
snapshot_path.display(),
),
}),
Ok(metadata) if !metadata.file_type().is_file() => Err(BeadsError::Internal {
message: format!(
"doctor --repair-indexes: refusing to overwrite non-file pre-snapshot target {}",
snapshot_path.display(),
),
}),
Ok(_) => Ok(()),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(BeadsError::Internal {
message: format!(
"doctor --repair-indexes: failed to inspect pre-snapshot target {}: {err}",
snapshot_path.display(),
),
}),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct WalCheckpointStats {
busy: i64,
log_frames: i64,
checkpointed_frames: i64,
}
impl WalCheckpointStats {
const fn complete(self) -> bool {
self.busy == 0 && (self.log_frames < 0 || self.checkpointed_frames >= self.log_frames)
}
}
fn wal_checkpoint_truncate_complete(conn: &Connection) -> std::result::Result<bool, FrankenError> {
let rows = conn.query("PRAGMA wal_checkpoint(TRUNCATE)")?;
let Some(row) = rows.first() else {
return Ok(false);
};
Ok(wal_checkpoint_stats_from_row(row).is_some_and(WalCheckpointStats::complete))
}
fn wal_checkpoint_stats_from_row(row: &Row) -> Option<WalCheckpointStats> {
Some(WalCheckpointStats {
busy: sqlite_value_i64(row.get(0))?,
log_frames: sqlite_value_i64(row.get(1))?,
checkpointed_frames: sqlite_value_i64(row.get(2))?,
})
}
const fn sqlite_value_i64(value: Option<&SqliteValue>) -> Option<i64> {
match value {
Some(SqliteValue::Integer(value)) => Some(*value),
_ => None,
}
}
fn restore_repair_indexes_snapshot(
paths: &config::ConfigPaths,
snapshot_path: &Path,
sidecars: [&PathBuf; 2],
original_err: &BeadsError,
write_authority: &Arc<crate::sync::DatabaseFamilyWriteLock>,
) -> Result<()> {
write_authority.verify_database_authority()?;
std::fs::copy(snapshot_path, &paths.db_path).map_err(|copy_err| BeadsError::Internal {
message: format!(
"doctor --repair-indexes: REINDEX failed and pre-snapshot restore also failed: original={original_err}, restore={copy_err}",
),
})?;
write_authority.verify_database_authority()?;
for sidecar in sidecars {
let sidecar_snapshot = PathBuf::from(format!(
"{}-{}",
snapshot_path.display(),
sidecar_suffix(sidecar).unwrap_or("orphan"),
));
if sidecar_snapshot.exists() {
match std::fs::copy(&sidecar_snapshot, sidecar) {
Ok(_) => tracing::debug!(
snapshot = %sidecar_snapshot.display(),
live = %sidecar.display(),
"doctor --repair-indexes: restored sidecar from pre-checkpoint snapshot"
),
Err(copy_err) => {
return Err(BeadsError::Internal {
message: format!(
"doctor --repair-indexes: restored DB snapshot but failed to restore sidecar snapshot {} -> {}: original={original_err}, restore_sidecar={copy_err}",
sidecar_snapshot.display(),
sidecar.display(),
),
});
}
}
} else {
match std::fs::remove_file(sidecar) {
Ok(()) => tracing::debug!(
path = %sidecar.display(),
"doctor --repair-indexes: cleared sidecar after restore (checkpoint succeeded; live sidecar held only post-REINDEX frames)"
),
Err(rm_err) if rm_err.kind() == std::io::ErrorKind::NotFound => {}
Err(rm_err) => tracing::warn!(
error = %rm_err,
path = %sidecar.display(),
"doctor --repair-indexes: failed to remove sidecar after restore; next open may replay stale WAL frames"
),
}
}
}
write_authority.verify_database_authority()?;
Ok(())
}
fn sidecar_suffix(sidecar: &Path) -> Option<&'static str> {
let s = sidecar.to_string_lossy();
if s.ends_with("-wal") {
Some("wal")
} else if s.ends_with("-shm") {
Some("shm")
} else {
None
}
}
fn close_repair_indexes_connection(conn: Connection, context: &str) {
if let Err(close_err) = conn.close() {
tracing::warn!(
error = %close_err,
context,
"doctor --repair-indexes: failed to close connection"
);
}
}
fn quote_sql_identifier(name: &str) -> String {
let mut quoted = String::with_capacity(name.len() + 2);
quoted.push('"');
for c in name.chars() {
if matches!(c, '"') {
quoted.push('"');
}
quoted.push(c);
}
quoted.push('"');
quoted
}
fn resolve_doctor_no_db(beads_dir: &Path, cli: &config::CliOverrides) -> bool {
let Ok(startup) = config::load_startup_config(beads_dir) else {
return cli.no_db.unwrap_or(false);
};
let merged = config::ConfigLayer::merge_layers(&[startup, cli.as_layer()]);
config::no_db_from_layer(&merged).unwrap_or(false)
}
#[cfg(test)]
fn collect_doctor_report(beads_dir: &Path, paths: &config::ConfigPaths) -> Result<DoctorRun> {
collect_doctor_report_with_mode(beads_dir, paths, DoctorInspectionMode::Full)
}
#[cfg(test)]
fn collect_doctor_report_with_mode(
beads_dir: &Path,
paths: &config::ConfigPaths,
mode: DoctorInspectionMode,
) -> Result<DoctorRun> {
collect_doctor_report_with_mode_and_db_override(beads_dir, paths, None, mode, false)
}
fn collect_doctor_report_for_cli(
beads_dir: &Path,
paths: &config::ConfigPaths,
cli: &config::CliOverrides,
) -> Result<DoctorRun> {
let no_db = resolve_doctor_no_db(beads_dir, cli);
collect_doctor_report_with_mode_and_db_override(
beads_dir,
paths,
cli.db.as_ref(),
DoctorInspectionMode::Full,
no_db,
)
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ExplainObservation {
NoWorkspace,
NotObserved,
Check(serde_json::Value),
}
pub(crate) fn observe_check_for_explain(
cli: &config::CliOverrides,
check_name: &str,
finding_id: &str,
) -> Result<ExplainObservation> {
let Some(beads_dir) = config::discover_optional_beads_dir_with_cli(cli)? else {
return Ok(ExplainObservation::NoWorkspace);
};
let paths = config::resolve_paths(&beads_dir, cli.db.as_ref())?;
let run = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
let observed = run.report.checks.iter().find(|check| {
check.name == check_name
|| check
.details
.as_ref()
.and_then(|details| details.get("finding_id"))
.and_then(serde_json::Value::as_str)
== Some(finding_id)
});
match observed {
Some(check) => Ok(ExplainObservation::Check(
serde_json::to_value(check).map_err(BeadsError::Json)?,
)),
None => Ok(ExplainObservation::NotObserved),
}
}
fn collect_doctor_report_with_mode_and_db_override(
beads_dir: &Path,
paths: &config::ConfigPaths,
db_override: Option<&PathBuf>,
mode: DoctorInspectionMode,
no_db: bool,
) -> Result<DoctorRun> {
let mut checks = Vec::new();
let live_database_inspectable =
!paths.db_path.exists() || check_namespace_identity(&paths.db_path, &mut checks);
check_merge_artifacts(beads_dir, &paths.jsonl_path, &mut checks)?;
check_base_jsonl(beads_dir, &mut checks);
let repo_root = beads_dir.parent().unwrap_or(beads_dir);
check_doctor_runs_dir_size(repo_root, &mut checks);
check_doctor_runs_creatable(repo_root, &mut checks);
if !no_db {
check_recovery_dir_writable(&paths.db_path, beads_dir, &mut checks);
}
check_write_lock_writable(beads_dir, &mut checks);
check_root_gitignore_writable(repo_root, &mut checks);
check_db_sidecar_modes(&paths.db_path, &mut checks);
check_config_yaml_secret_mode(beads_dir, &mut checks);
check_multiple_br_in_path(&mut checks);
check_inner_gitignore_present(beads_dir, &mut checks);
check_orphan_tmp_files(beads_dir, &mut checks);
check_br_history_size(beads_dir, &mut checks);
check_root_gitignore(beads_dir, &mut checks);
check_routes_jsonl(beads_dir, &mut checks);
check_rust_log_noisy(&mut checks);
check_permissions_beads_dir(beads_dir, &mut checks);
check_config_yaml(beads_dir, &mut checks);
check_config_unknown_keys(beads_dir, &mut checks);
check_metadata_json(beads_dir, &mut checks);
check_binary_version_mismatch(beads_dir, &mut checks);
check_orphaned_write_lock(beads_dir, &mut checks);
check_routes_targets_resolve(beads_dir, &mut checks);
check_startup_cache(beads_dir, db_override, &mut checks);
if live_database_inspectable {
check_pending_sync_merge(&paths.db_path, &mut checks);
} else {
push_check(
&mut checks,
"sync.merge_pending",
CheckStatus::Error,
Some("Pending sync-merge state is unknown: live database inspection was skipped to preserve the namespace identity evidence".to_string()),
Some(serde_json::json!({ "inspected": false, "blocked_by": "db.namespace_identity" })),
);
}
let (jsonl_path, jsonl_count) = inspect_doctor_jsonl(beads_dir, paths, mode, &mut checks);
if no_db {
let skipped = [
"db.recovery_dir_writable",
"db.bloat_vs_jsonl",
"db.wal_oversized",
"db.inspect",
];
push_check(
&mut checks,
"db.no_db_mode",
CheckStatus::Ok,
Some(format!(
"--no-db (JSONL-only): skipped {} DB-backed check(s)",
skipped.len()
)),
Some(serde_json::json!({ "skipped_checks": skipped })),
);
} else {
check_db_bloat_vs_jsonl(&paths.db_path, jsonl_path.as_deref(), &mut checks);
check_wal_oversized(&paths.db_path, &mut checks);
inspect_doctor_database(
beads_dir,
&paths.db_path,
jsonl_path.as_deref(),
jsonl_count,
mode,
live_database_inspectable,
&mut checks,
);
}
let classification = classify_doctor_checks(&paths.db_path, &paths.jsonl_path, &checks);
let reliability_audit = classification.audit_record("doctor.inspect");
let ok = match mode {
DoctorInspectionMode::Full => !has_error(&checks),
DoctorInspectionMode::Quick => !has_non_ok(&checks),
};
emit_doctor_reliability_audit("inspect", ok, &reliability_audit, &checks);
Ok(DoctorRun {
report: DoctorReport {
ok,
workspace_health: Some(classification.health.to_string()),
reliability_audit: Some(reliability_audit),
checks,
},
jsonl_path,
})
}
fn inspect_doctor_jsonl(
beads_dir: &Path,
paths: &config::ConfigPaths,
mode: DoctorInspectionMode,
checks: &mut Vec<CheckResult>,
) -> (Option<PathBuf>, JsonlCountState) {
let jsonl_path = select_doctor_jsonl_path(beads_dir, paths);
check_jsonl_world_writable(jsonl_path.as_deref(), checks);
check_jsonl_crlf_endings(jsonl_path.as_deref(), checks);
check_jsonl_utf8_bom(jsonl_path.as_deref(), checks);
check_jsonl_trailing_newline(jsonl_path.as_deref(), checks);
check_jsonl_oversized(jsonl_path.as_deref(), checks);
check_jsonl_duplicate_ids(jsonl_path.as_deref(), checks);
let jsonl_count = if let Some(path) = jsonl_path.as_ref() {
check_sync_jsonl_path(path, beads_dir, checks);
check_sync_conflict_markers(path, checks);
match check_jsonl(path, checks) {
Ok(count) => count,
Err(err) => {
push_check(
checks,
"jsonl.parse",
CheckStatus::Error,
Some(format!("Failed to read JSONL: {err}")),
Some(serde_json::json!({ "path": path.display().to_string() })),
);
JsonlCountState::Unreadable
}
}
} else {
push_check(
checks,
"jsonl.parse",
CheckStatus::Warn,
Some("No JSONL file found (.beads/issues.jsonl or .beads/beads.jsonl)".to_string()),
None,
);
JsonlCountState::Missing
};
if matches!(mode, DoctorInspectionMode::Full)
&& matches!(jsonl_count, JsonlCountState::Available(_))
&& let Some(path) = jsonl_path.as_ref()
{
check_dependency_graph_jsonl(path, checks);
}
(jsonl_path, jsonl_count)
}
fn check_dependency_graph_jsonl(path: &Path, checks: &mut Vec<CheckResult>) {
let issues = match read_jsonl_issues_for_graph(path) {
Ok(issues) => issues,
Err(err) => {
push_check(
checks,
"dep.dead_closed_blocking_edges",
CheckStatus::Warn,
Some(format!("Could not read JSONL for dependency audit: {err}")),
Some(serde_json::json!({ "path": path.display().to_string() })),
);
return;
}
};
let mut status_by_id: std::collections::HashMap<String, crate::model::Status> =
std::collections::HashMap::with_capacity(issues.len());
for issue in &issues {
status_by_id.insert(issue.id.clone(), issue.status.clone());
}
let blocker_fate = |target: &str| -> BlockerFate {
if target.starts_with("external:") {
return BlockerFate::Live;
}
match status_by_id.get(target) {
Some(status) if status.is_terminal() => BlockerFate::Satisfied,
Some(_) => BlockerFate::Live,
None => BlockerFate::Dangling,
}
};
let mut dead_edge_issues: Vec<serde_json::Value> = Vec::new();
let mut dangling_edge_issue_ids: Vec<String> = Vec::new();
let mut fully_unblocked_issues: Vec<FullyUnblockedIssue> = Vec::new();
for issue in &issues {
if issue.status.is_terminal() || issue.status.is_draft() {
continue;
}
let blocking_targets: Vec<&str> = issue
.dependencies
.iter()
.filter(|dep| {
matches!(
dep.dep_type,
crate::model::DependencyType::Blocks
| crate::model::DependencyType::ConditionalBlocks
| crate::model::DependencyType::WaitsFor
)
})
.map(|dep| dep.depends_on_id.as_str())
.filter(|target| *target != issue.id)
.collect();
if blocking_targets.is_empty() {
continue;
}
let mut satisfied: Vec<&str> = Vec::new();
let mut dangling: Vec<&str> = Vec::new();
for target in &blocking_targets {
match blocker_fate(target) {
BlockerFate::Live => {}
BlockerFate::Satisfied => satisfied.push(target),
BlockerFate::Dangling => dangling.push(target),
}
}
let dead_count = satisfied.len() + dangling.len();
if dead_count > 0 {
let mut dead: Vec<&str> = Vec::with_capacity(dead_count);
dead.extend(&satisfied);
dead.extend(&dangling);
dead_edge_issues.push(serde_json::json!({
"id": issue.id,
"dead_blockers": dead,
"satisfied_blockers": satisfied,
"dangling_blockers": dangling,
}));
if !dangling.is_empty() {
dangling_edge_issue_ids.push(issue.id.clone());
}
}
if dead_count == blocking_targets.len() {
fully_unblocked_issues.push(classify_fully_unblocked(issue));
}
}
emit_dead_closed_blocking_edges(&dead_edge_issues, &dangling_edge_issue_ids, checks);
emit_fully_unblocked_open(&fully_unblocked_issues, checks);
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum BlockerFate {
Live,
Satisfied,
Dangling,
}
struct FullyUnblockedIssue {
id: String,
excluded_reasons: Vec<&'static str>,
stale_blocked: bool,
}
fn classify_fully_unblocked(issue: &crate::model::Issue) -> FullyUnblockedIssue {
use crate::model::Status;
let mut excluded_reasons: Vec<&'static str> = Vec::new();
let mut stale_blocked = false;
match &issue.status {
Status::Open | Status::Closed | Status::Tombstone | Status::Draft => {}
Status::InProgress => excluded_reasons.push("claimed (status in_progress)"),
Status::Blocked => stale_blocked = true,
Status::Deferred => excluded_reasons.push("status deferred"),
Status::Pinned => excluded_reasons.push("status pinned"),
Status::Custom(_) => excluded_reasons.push("custom status"),
}
if issue
.defer_until
.is_some_and(|until| until > chrono::Utc::now())
{
excluded_reasons.push("defer_until in the future");
}
if issue.pinned {
excluded_reasons.push("pinned");
}
if issue.ephemeral {
excluded_reasons.push("ephemeral");
}
if issue.id.contains("-wisp-") {
excluded_reasons.push("wisp");
}
if issue.is_template {
excluded_reasons.push("template");
}
FullyUnblockedIssue {
id: issue.id.clone(),
excluded_reasons,
stale_blocked,
}
}
fn read_jsonl_issues_for_graph(path: &Path) -> Result<Vec<crate::model::Issue>> {
use std::io::BufRead;
let file = std::fs::File::open(path)?;
let reader = std::io::BufReader::new(file);
let mut issues = Vec::new();
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(issue) = serde_json::from_str::<crate::model::Issue>(trimmed) {
issues.push(issue);
}
}
Ok(issues)
}
fn emit_dead_closed_blocking_edges(
dead_edge_issues: &[serde_json::Value],
dangling_edge_issue_ids: &[String],
checks: &mut Vec<CheckResult>,
) {
if dead_edge_issues.is_empty() {
push_check(
checks,
"dep.dead_closed_blocking_edges",
CheckStatus::Ok,
None,
None,
);
return;
}
if dangling_edge_issue_ids.is_empty() {
push_check(
checks,
"dep.dead_closed_blocking_edges",
CheckStatus::Ok,
Some(format!(
"{} open issue(s) have blocking edges whose blockers are closed (satisfied dependencies — the normal result of completing work)",
dead_edge_issues.len()
)),
Some(serde_json::json!({
"count": dead_edge_issues.len(),
"issues": dead_edge_issues,
"dangling_count": 0,
"note": "Satisfied edges record completed dependencies and need no action; removing them would delete dependency history.",
})),
);
return;
}
push_check(
checks,
"dep.dead_closed_blocking_edges",
CheckStatus::Warn,
Some(format!(
"{} open issue(s) have dangling blocking edges (blocker absent from JSONL): {}",
dangling_edge_issue_ids.len(),
dangling_edge_issue_ids.join(", ")
)),
Some(serde_json::json!({
"count": dead_edge_issues.len(),
"issues": dead_edge_issues,
"dangling_count": dangling_edge_issue_ids.len(),
"remediation": "Remove or update the dangling `blocks`/dependency edges (e.g. `br dep remove`) so each blocker reflects an existing issue. Edges whose blockers are merely closed (`satisfied_blockers`) are history and should be left alone.",
})),
);
}
fn emit_fully_unblocked_open(
fully_unblocked: &[FullyUnblockedIssue],
checks: &mut Vec<CheckResult>,
) {
if fully_unblocked.is_empty() {
push_check(
checks,
"dep.fully_unblocked_open",
CheckStatus::Ok,
None,
None,
);
return;
}
let ids: Vec<&str> = fully_unblocked
.iter()
.map(|issue| issue.id.as_str())
.collect();
let stale_blocked: Vec<&str> = fully_unblocked
.iter()
.filter(|issue| issue.stale_blocked)
.map(|issue| issue.id.as_str())
.collect();
let ready: Vec<&str> = fully_unblocked
.iter()
.filter(|issue| !issue.stale_blocked && issue.excluded_reasons.is_empty())
.map(|issue| issue.id.as_str())
.collect();
let excluded: Vec<serde_json::Value> = fully_unblocked
.iter()
.filter(|issue| !issue.stale_blocked && !issue.excluded_reasons.is_empty())
.map(|issue| {
serde_json::json!({
"id": issue.id,
"reasons": issue.excluded_reasons,
})
})
.collect();
let details = serde_json::json!({
"count": fully_unblocked.len(),
"issues": ids,
"ready": ready,
"excluded": excluded,
"stale_blocked": stale_blocked,
});
if stale_blocked.is_empty() {
push_check(
checks,
"dep.fully_unblocked_open",
CheckStatus::Ok,
Some(format!(
"{} open issue(s) have all blockers completed ({} ready to work, {} deliberately excluded from ready)",
fully_unblocked.len(),
ready.len(),
excluded.len()
)),
Some(details),
);
return;
}
let mut details = details;
if let Some(map) = details.as_object_mut() {
map.insert(
"remediation".to_string(),
serde_json::Value::String(
"These issues have status `blocked` but no live blocker remains — update their status (e.g. `br update <id> --status open`) so they surface as ready.".to_string(),
),
);
}
push_check(
checks,
"dep.fully_unblocked_open",
CheckStatus::Warn,
Some(format!(
"{} open issue(s) still have status `blocked` although every blocker is closed or absent: {}",
stale_blocked.len(),
stale_blocked.join(", ")
)),
Some(details),
);
}
fn check_read_only_open_observational(db_path: &Path, checks: &mut Vec<CheckResult>) -> Result<()> {
const NAME: &str = "db.read_only_open_observational";
let probe = crate::storage::sqlite::probe_read_only_open_is_observational(db_path)?;
if let Some(reason) = &probe.skipped {
push_check(
checks,
NAME,
CheckStatus::Ok,
Some(format!("Read-only open probe skipped: {reason}")),
Some(serde_json::json!({ "skipped": reason })),
);
return Ok(());
}
if !probe.opened {
push_check(
checks,
NAME,
CheckStatus::Ok,
Some(
"Read-only current-schema open is not applicable (schema is not current or a sidecar repair is pending)"
.to_string(),
),
Some(serde_json::json!({
"opened": false,
"copied_bytes": probe.copied_bytes,
})),
);
return Ok(());
}
if probe.diffs.is_empty() {
push_check(
checks,
NAME,
CheckStatus::Ok,
None,
Some(serde_json::json!({
"opened": true,
"copied_bytes": probe.copied_bytes,
})),
);
return Ok(());
}
push_check(
checks,
NAME,
CheckStatus::Warn,
Some(format!(
"Read-only open changed {} database-family artifact(s) outside the WAL-index reader-mark exemption",
probe.diffs.len()
)),
Some(serde_json::json!({
"opened": true,
"copied_bytes": probe.copied_bytes,
"diffs": probe.diffs,
})),
);
Ok(())
}
fn inspect_doctor_database(
beads_dir: &Path,
db_path: &Path,
jsonl_path: Option<&Path>,
jsonl_count: JsonlCountState,
mode: DoctorInspectionMode,
live_database_inspectable: bool,
checks: &mut Vec<CheckResult>,
) {
if let Err(err) = check_recovery_artifacts(beads_dir, db_path, checks) {
push_inspection_error(
checks,
"db.recovery_artifacts",
"Failed to inspect preserved recovery artifacts",
&err,
);
}
if let Err(err) = check_recovery_artifacts_aged(beads_dir, db_path, checks) {
push_inspection_error(
checks,
"db.recovery_artifacts.aged",
"Failed to inspect aged recovery artifacts",
&err,
);
}
if let Err(err) = check_foreign_recovery_debris(beads_dir, db_path, checks) {
push_inspection_error(
checks,
"db.foreign_recovery_debris",
"Failed to inspect foreign recovery debris",
&err,
);
}
if let Err(err) = check_database_sidecars(db_path, checks) {
push_inspection_error(
checks,
"db.sidecars",
"Failed to inspect database sidecars",
&err,
);
}
if db_path.exists() {
if !live_database_inspectable {
push_check(
checks,
"db.read_only_open_observational",
CheckStatus::Warn,
Some(
"Live open probe skipped to preserve the namespace identity evidence"
.to_string(),
),
Some(serde_json::json!({ "opened": false, "blocked_by": "db.namespace_identity" })),
);
} else if let Err(err) = check_read_only_open_observational(db_path, checks) {
push_inspection_error(
checks,
"db.read_only_open_observational",
"Failed to probe the read-only open contract",
&err,
);
}
inspect_existing_doctor_database(db_path, jsonl_path, jsonl_count, mode, checks);
} else {
push_check(
checks,
"db.exists",
CheckStatus::Error,
Some(format!("Missing database file at {}", db_path.display())),
Some(serde_json::json!({ "path": db_path.display().to_string() })),
);
}
}
fn inspect_existing_doctor_database(
db_path: &Path,
jsonl_path: Option<&Path>,
jsonl_count: JsonlCountState,
mode: DoctorInspectionMode,
checks: &mut Vec<CheckResult>,
) {
if let Err(err) = config::with_database_family_snapshot(db_path, |snapshot_db_path| {
let conn = Connection::open(snapshot_db_path.to_string_lossy().into_owned())?;
let _ = conn.execute("PRAGMA busy_timeout=30000");
if let Err(err) = required_schema_checks(&conn, checks) {
push_inspection_error(
checks,
"schema.inspect",
"Failed to inspect database schema",
&err,
);
}
if mode == DoctorInspectionMode::Full
&& let Err(err) = check_recoverable_anomalies(&conn, checks)
{
push_inspection_error(
checks,
"db.recoverable_anomalies",
"Failed to inspect recoverable anomalies",
&err,
);
}
check_null_defaults(&conn, checks);
check_integrity(&conn, checks);
check_export_hash_cache_divergence(&conn, jsonl_path, checks);
let real_beads_dir = db_path.parent().unwrap_or(db_path);
check_base_jsonl_missing_post_flush(&conn, real_beads_dir, jsonl_path, checks);
check_dirty_bitmap_divergence(&conn, checks);
check_comments_orphans(&conn, checks);
check_labels_orphans(&conn, checks);
check_dependencies_orphans(&conn, checks);
check_suspect_close_reasons(&conn, checks);
check_workflow_statuses(&conn, real_beads_dir, checks);
if mode == DoctorInspectionMode::Full {
if let Err(err) = check_db_count(&conn, jsonl_count, jsonl_path, checks) {
push_inspection_error(
checks,
"counts.db_vs_jsonl",
"Failed to compare database and JSONL counts",
&err,
);
}
check_sync_metadata(&conn, snapshot_db_path, jsonl_path, checks);
check_issue_write_probe(&conn, checks);
}
conn.close()?;
Ok(())
}) {
push_check(
checks,
"db.open",
CheckStatus::Error,
Some(format!("Failed to open DB snapshot for inspection: {err}")),
Some(serde_json::json!({ "path": db_path.display().to_string() })),
);
}
if mode == DoctorInspectionMode::Full {
check_sqlite_cli_integrity(db_path, checks);
}
}
#[allow(clippy::too_many_lines)]
pub fn execute(args: &DoctorArgs, cli: &config::CliOverrides, ctx: &OutputContext) -> Result<()> {
if args.selftest {
return crate::cli::commands::doctor_subsystems::selftest::execute(args, ctx);
}
if args.bundle.is_some() {
return crate::cli::commands::doctor_subsystems::bundle::execute(args, cli, ctx);
}
if let Some(sub) = &args.subcommand {
let undo_write_authority = if let crate::cli::DoctorSubcommand::Undo(undo) = sub
&& !undo.dry_run
&& let Some(beads_dir) = config::discover_optional_beads_dir_with_cli(cli)?
{
let paths = config::resolve_paths(&beads_dir, cli.db.as_ref())?;
let write_authority = if let Some(authority) =
cli.database_family_write_authority_for(&beads_dir, &paths.db_path)
{
if let Err(err) = authority.verify_database_authority() {
emit_concurrency_lost(&beads_dir, &err, ctx, "doctor undo");
crate::shutdown::exit_process(DoctorExitCode::ConcurrencyLost.as_i32());
}
Arc::clone(authority)
} else {
match acquire_doctor_database_write_authority(
&beads_dir,
&paths.db_path,
cli.lock_timeout,
) {
Ok(authority) => authority,
Err(err) => {
emit_concurrency_lost(&beads_dir, &err, ctx, "doctor undo");
crate::shutdown::exit_process(DoctorExitCode::ConcurrencyLost.as_i32());
}
}
};
refuse_doctor_mutation_if_merge_pending(
"doctor undo",
&paths.db_path,
&write_authority,
ctx,
);
Some(write_authority)
} else {
None
};
let result =
crate::cli::commands::doctor_subsystems::surface::dispatch_subcommand(sub, cli, ctx);
drop(undo_write_authority);
return result;
}
let Some(beads_dir) = config::discover_optional_beads_dir_with_cli(cli)? else {
let mut checks = Vec::new();
push_check(
&mut checks,
"beads_dir",
CheckStatus::Error,
Some("Missing .beads directory (run `br init`)".to_string()),
None,
);
let report = DoctorReport {
ok: !has_error(&checks),
workspace_health: None,
reliability_audit: None,
checks,
};
print_report(&report, ctx)?;
crate::shutdown::exit_process(DoctorExitCode::NoInput.as_i32());
};
let paths = match config::resolve_paths(&beads_dir, cli.db.as_ref()) {
Ok(paths) => paths,
Err(err) => {
let mut checks = Vec::new();
check_config_yaml(&beads_dir, &mut checks);
check_metadata_json(&beads_dir, &mut checks);
push_check(
&mut checks,
"metadata",
CheckStatus::Error,
Some(format!("Failed to read metadata.json: {err}")),
None,
);
let report = DoctorReport {
ok: !has_error(&checks),
workspace_health: None,
reliability_audit: None,
checks,
};
print_report(&report, ctx)?;
crate::shutdown::exit_process(1);
}
};
set_engine_block(engine_block(&beads_dir, &paths.db_path));
let repair_write_authority: Option<Arc<crate::sync::DatabaseFamilyWriteLock>> = if args.repair
&& !args.robot_triage
{
if let Some(authority) = cli.database_family_write_authority_for(&beads_dir, &paths.db_path)
{
if let Err(err) = authority.verify_database_authority() {
emit_concurrency_lost(&beads_dir, &err, ctx, "--repair");
crate::shutdown::exit_process(DoctorExitCode::ConcurrencyLost.as_i32());
}
Some(Arc::clone(authority))
} else {
let timeout_ms = cli.lock_timeout.unwrap_or(0);
match crate::sync::blocking_database_family_write_lock_with_timeout(
&beads_dir,
&paths.db_path,
Some(timeout_ms),
) {
Ok(authority) => {
let authority = Arc::new(authority);
if let Err(err) = authority
.bind_database_inode_for_mutation()
.and_then(|_| authority.verify_database_authority())
{
emit_concurrency_lost(&beads_dir, &err, ctx, "--repair");
crate::shutdown::exit_process(DoctorExitCode::ConcurrencyLost.as_i32());
}
Some(authority)
}
Err(err) => {
emit_concurrency_lost(&beads_dir, &err, ctx, "--repair");
crate::shutdown::exit_process(DoctorExitCode::ConcurrencyLost.as_i32());
}
}
}
} else {
None
};
if args.repair && !args.robot_triage {
match refuse_gates::run_all(&beads_dir, &paths.db_path) {
GateOutcome::Allow => {}
GateOutcome::Refuse {
code: _,
reason,
evidence,
} => {
emit_refused_unsafe("--repair", &reason, &evidence, ctx);
crate::shutdown::exit_process(DoctorExitCode::RefusedUnsafe.as_i32());
}
}
}
if args.repair && !args.dry_run && !args.robot_triage {
let write_authority = repair_write_authority
.as_ref()
.expect("repair write authority acquired before pending-merge gate");
refuse_doctor_mutation_if_merge_pending("--repair", &paths.db_path, write_authority, ctx);
}
if args.repair_indexes && !args.robot_triage {
return execute_repair_indexes(&beads_dir, &paths, ctx, args, cli);
}
let inspection_mode = if args.quick && !args.repair && !args.robot_triage {
DoctorInspectionMode::Quick
} else {
DoctorInspectionMode::Full
};
let no_db = resolve_doctor_no_db(&beads_dir, cli);
let mut initial = collect_doctor_report_with_mode_and_db_override(
&beads_dir,
&paths,
cli.db.as_ref(),
inspection_mode,
no_db,
)?;
if args.robot_triage {
emit_flat_robot_triage(&initial.report);
return Ok(());
}
let mut session: Option<DoctorRepairSession> = if args.repair {
let repo_root = beads_dir.parent().unwrap_or(&beads_dir);
Some(DoctorRepairSession::new(repo_root, args.dry_run).map_err(|err| {
BeadsError::Config(format!(
"Cannot run doctor --repair without a reversible repair session: failed to create doctor run directory ({err}). No repair writes were applied. Set {} to a writable directory if the workspace is read-only.",
run_dir::ENV_RUNS_DIR
))
})?)
} else {
None
};
let fixer_filter = FixerFilter::from_args(&args.only, &args.skip);
let gitignore_repaired =
if args.repair && fixer_filter.allows("fm-configs-gitignore-leaking-beads") {
let repaired =
fix_root_gitignore_if_warned(&beads_dir, &initial.report, ctx, session.as_mut());
if repaired {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
repaired
} else {
false
};
let merge_artifacts_repaired =
if args.repair && fixer_filter.allows("fm-state_files-merge-artifact-stuck") {
let repaired =
fix_merge_artifacts_if_warned(&beads_dir, &initial.report, ctx, session.as_mut());
if repaired {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
repaired
} else {
false
};
let startup_cache_repaired =
if args.repair && fixer_filter.allows("fm-configs-startup-cache-poisoned") {
let repaired = fix_startup_cache_if_warned(
&beads_dir,
cli.db.as_ref(),
&initial.report,
ctx,
session.as_mut(),
);
if repaired {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
repaired
} else {
false
};
let recovery_aged_repaired =
if args.repair && fixer_filter.allows("fm-state_files-recovery-artifacts-orphaned") {
let repaired = fix_recovery_artifacts_aged_if_warned(
&beads_dir,
&paths.db_path,
&initial.report,
ctx,
session.as_mut(),
);
if repaired {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
repaired
} else {
false
};
let export_hash_repaired =
if args.repair && fixer_filter.allows("fm-caches_indexes-export-hash-cache-divergence") {
let repaired = fix_export_hash_cache_divergence_if_warned(
&paths.db_path,
initial.jsonl_path.as_deref(),
&initial.report,
ctx,
session.as_mut(),
);
if repaired {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
repaired
} else {
false
};
let base_jsonl_symlink_repaired = if args.repair
&& fixer_filter.allows("fm-state_files-base-jsonl-missing-or-stale")
{
let repaired =
fix_base_jsonl_symlink_if_warned(&beads_dir, &initial.report, ctx, session.as_mut());
if repaired {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
repaired
} else {
false
};
let orphan_tmp_repaired =
if args.repair && fixer_filter.allows("fm-state_files-orphan-tmp-files") {
let repaired =
fix_orphan_tmp_files_if_warned(&beads_dir, &initial.report, ctx, session.as_mut());
if repaired {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
repaired
} else {
false
};
let jsonl_eof_newline_repaired =
if args.repair && fixer_filter.allows("fm-state_files-jsonl-missing-trailing-newline") {
let repaired = fix_jsonl_trailing_newline_if_warned(
initial.jsonl_path.as_deref(),
&initial.report,
ctx,
session.as_mut(),
);
if repaired {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
repaired
} else {
false
};
let _ = jsonl_eof_newline_repaired;
let jsonl_bom_repaired =
if args.repair && fixer_filter.allows("fm-state_files-jsonl-utf8-bom-prefix") {
let repaired = fix_jsonl_utf8_bom_if_warned(
initial.jsonl_path.as_deref(),
&initial.report,
ctx,
session.as_mut(),
);
if repaired {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
repaired
} else {
false
};
let _ = jsonl_bom_repaired;
let jsonl_crlf_repaired =
if args.repair && fixer_filter.allows("fm-state_files-jsonl-crlf-line-endings") {
let repaired = fix_jsonl_crlf_endings_if_warned(
initial.jsonl_path.as_deref(),
&initial.report,
ctx,
session.as_mut(),
);
if repaired {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
repaired
} else {
false
};
let _ = jsonl_crlf_repaired;
let base_jsonl_stale_repaired =
if args.repair && fixer_filter.allows("fm-state_files-base-jsonl-missing-or-stale") {
let repaired =
fix_base_jsonl_stale_if_warned(&beads_dir, &initial.report, ctx, session.as_mut());
if repaired {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
repaired
} else {
false
};
let jsonl_world_writable_repaired =
if args.repair && fixer_filter.allows("fm-permissions-jsonl-world-writable") {
let repaired = fix_jsonl_world_writable_if_warned(
initial.jsonl_path.as_deref(),
&initial.report,
ctx,
session.as_mut(),
);
if repaired {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
repaired
} else {
false
};
let _ = jsonl_world_writable_repaired;
let config_yaml_secret_mode_repaired =
if args.repair && fixer_filter.allows("fm-permissions-config-yaml-mode-leaks-secrets") {
let repaired = fix_config_yaml_secret_mode_if_warned(
&beads_dir,
&initial.report,
ctx,
session.as_mut(),
);
if repaired {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
repaired
} else {
false
};
let _ = config_yaml_secret_mode_repaired;
let db_sidecar_mode_repaired = if args.repair
&& fixer_filter.allows("fm-permissions-db-sidecar-mode-too-open")
{
let repaired =
fix_db_sidecar_modes_if_warned(&paths.db_path, &initial.report, ctx, session.as_mut());
if repaired {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
repaired
} else {
false
};
let _ = db_sidecar_mode_repaired;
let inner_gitignore_repaired =
if args.repair && fixer_filter.allows("fm-configs-gitignore-leaking-beads") {
let repaired =
fix_inner_gitignore_if_warned(&beads_dir, &initial.report, ctx, session.as_mut());
if repaired {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
repaired
} else {
false
};
let _ = inner_gitignore_repaired;
let dirty_bitmap_orphans_repaired =
if args.repair && fixer_filter.allows("fm-caches_indexes-dirty-bitmap-divergence") {
let repaired = fix_dirty_bitmap_orphans_if_warned(
&paths.db_path,
&initial.report,
ctx,
session.as_mut(),
);
if repaired {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
repaired
} else {
false
};
let _ = dirty_bitmap_orphans_repaired;
let comments_orphans_repaired = if args.repair
&& fixer_filter.allows("fm-caches_indexes-comments-orphans")
{
let repaired =
fix_comments_orphans_if_warned(&paths.db_path, &initial.report, ctx, session.as_mut());
if repaired {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
repaired
} else {
false
};
let _ = comments_orphans_repaired;
let labels_orphans_repaired = if args.repair
&& fixer_filter.allows("fm-caches_indexes-labels-orphans")
{
let repaired =
fix_labels_orphans_if_warned(&paths.db_path, &initial.report, ctx, session.as_mut());
if repaired {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
repaired
} else {
false
};
let _ = labels_orphans_repaired;
let dependencies_orphans_repaired =
if args.repair && fixer_filter.allows("fm-caches_indexes-dependencies-orphans") {
let repaired = fix_dependencies_orphans_if_warned(
&paths.db_path,
&initial.report,
ctx,
session.as_mut(),
);
if repaired {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
repaired
} else {
false
};
let _ = dependencies_orphans_repaired;
let wal_checkpoint_repaired =
if args.repair && fixer_filter.allows("fm-state_files-wal-oversized") {
let write_authority =
repair_write_authority
.as_ref()
.ok_or_else(|| BeadsError::SyncConflict {
message: "WAL repair reached mutation without database-family authority"
.to_string(),
})?;
let repaired = fix_wal_oversized_if_warned_under_write_authority(
&paths.db_path,
&initial.report,
ctx,
session.as_mut(),
write_authority,
);
if repaired {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
repaired
} else {
false
};
let _ = wal_checkpoint_repaired;
let null_defaults_repaired =
if args.repair && fixer_filter.allows("fm-schemas-missing-required-column") {
let repaired =
fix_null_defaults_if_warned(&paths.db_path, &initial.report, ctx, session.as_mut());
if repaired {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
repaired
} else {
false
};
let _ = null_defaults_repaired;
let db_bloat_vacuum_repaired = if args.repair
&& args.unsafe_auto_fix
&& fixer_filter.allows("fm-caches_indexes-db-bloat-vs-jsonl")
{
let write_authority =
repair_write_authority
.as_ref()
.ok_or_else(|| BeadsError::SyncConflict {
message: "VACUUM repair reached mutation without database-family authority"
.to_string(),
})?;
let repaired = fix_db_bloat_via_vacuum_if_warned_under_write_authority(
&paths.db_path,
&initial.report,
ctx,
session.as_mut(),
write_authority,
);
if repaired {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
repaired
} else {
false
};
let _ = db_bloat_vacuum_repaired;
let jsonl_was_mutated = jsonl_bom_repaired
|| jsonl_crlf_repaired
|| jsonl_eof_newline_repaired
|| jsonl_world_writable_repaired;
let export_hash_reconciled = if args.repair
&& jsonl_was_mutated
&& fixer_filter.allows("fm-caches_indexes-export-hash-cache-divergence")
{
let reconciled = fix_export_hash_cache_divergence_if_warned(
&paths.db_path,
initial.jsonl_path.as_deref(),
&initial.report,
ctx,
session.as_mut(),
);
if reconciled {
initial = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
reconciled
} else {
false
};
let _ = export_hash_reconciled;
if args.repair && (fixer_filter.has_only() || fixer_filter.has_skip()) {
tracing::info!(
target: "br::doctor::filter",
only = ?args.only,
skip = ?args.skip,
"doctor --repair filter active"
);
}
let early_repair = EarlyRepairSummary {
gitignore: gitignore_repaired,
merge_artifacts: merge_artifacts_repaired,
startup_cache: startup_cache_repaired,
recovery_aged: recovery_aged_repaired,
export_hash: export_hash_repaired,
base_jsonl_symlink: base_jsonl_symlink_repaired,
base_jsonl_stale: base_jsonl_stale_repaired,
orphan_tmp: orphan_tmp_repaired,
jsonl_eof_newline: jsonl_eof_newline_repaired,
jsonl_bom: jsonl_bom_repaired,
jsonl_crlf: jsonl_crlf_repaired,
jsonl_world_writable: jsonl_world_writable_repaired,
config_yaml_secret_mode: config_yaml_secret_mode_repaired,
inner_gitignore: inner_gitignore_repaired,
dirty_bitmap_orphans: dirty_bitmap_orphans_repaired,
comments_orphans: comments_orphans_repaired,
labels_orphans: labels_orphans_repaired,
dependencies_orphans: dependencies_orphans_repaired,
wal_checkpoint: wal_checkpoint_repaired,
null_defaults: null_defaults_repaired,
db_bloat_vacuum: db_bloat_vacuum_repaired,
};
if !args.repair {
if args.quick {
initial
.report
.checks
.retain(|c| !is_quick_suppressed_doctor_check(&c.name));
}
initial.report.ok = !has_non_ok(&initial.report.checks);
print_report(&initial.report, ctx)?;
if !initial.report.ok {
crate::shutdown::exit_process(DoctorExitCode::FindingsPresent.as_i32());
}
return Ok(());
}
let repair_write_authority =
repair_write_authority
.as_ref()
.ok_or_else(|| {
BeadsError::SyncConflict {
message:
"doctor --repair reached mutation planning without database-family write authority"
.to_string(),
}
})?;
let mut local_repair = LocalRepairResult::default();
if initial.report.ok {
let has_blocked_cache_rebuild = report_has_blocked_cache_rebuild_finding(&initial.report)
&& fixer_filter.allows(FM_BLOCKED_CACHE_STALE);
let has_partial_index_warnings = report_has_partial_index_warnings(&initial.report)
&& fixer_filter.allows(FM_PARTIAL_INDEX_STALE);
let has_warn_page_anomalies = report_has_warn_level_page_anomaly(&initial.report)
&& fixer_filter.allows(FM_SQLITE_PAGE_MALFORMED);
if has_blocked_cache_rebuild || has_partial_index_warnings || has_warn_page_anomalies {
local_repair = if has_blocked_cache_rebuild {
repair_recoverable_db_state_under_write_authority(
&beads_dir,
&paths.db_path,
&initial.report,
session.as_mut(),
&fixer_filter,
repair_write_authority,
)
} else {
LocalRepairResult::default()
};
if has_partial_index_warnings {
repair_partial_indexes_under_write_authority(
&paths.db_path,
&mut local_repair,
session.as_mut(),
repair_write_authority,
);
}
if has_warn_page_anomalies {
repair_via_vacuum(
&paths.db_path,
&mut local_repair,
session.as_mut(),
repair_write_authority,
);
}
let post_warning_repair = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
let verified = warning_repair_verified(
&post_warning_repair.report,
has_blocked_cache_rebuild,
has_partial_index_warnings,
);
let repair_message = repair_outcome_message_from_parts(
early_repair.messages(),
Some(&local_repair),
has_partial_index_warnings.then_some(REINDEX_INCOMPLETE_MESSAGE),
);
let recovery_audit = early_repair.prepend_actions_to_audit(local_repair_audit_record(
"doctor.warn_repair",
if verified {
"verified"
} else if has_warn_page_anomalies {
"needs_jsonl_rebuild"
} else {
"verification_failed"
},
&local_repair,
(!verified).then(|| {
if has_warn_page_anomalies {
"local warning repair did not clear page-level integrity warnings"
.to_string()
} else {
"local warning repair did not clear all requested warnings".to_string()
}
}),
));
emit_recovery_audit_record(&recovery_audit);
if verified {
if ctx.is_json() {
ctx.json(&serde_json::json!({
"report": initial.report,
"repaired": early_repair.applied() || local_repair.applied(),
"local_repair": local_repair,
"recovery_audit": recovery_audit,
"message": repair_message,
"post_repair": post_warning_repair.report,
"verified": true,
}));
} else {
print_report(&initial.report, ctx)?;
ctx.info(&repair_message);
ctx.info("Post-repair verification:");
print_report(&post_warning_repair.report, ctx)?;
}
return Ok(());
}
if !ctx.is_json() {
ctx.info(&repair_message);
ctx.info(
"Local warning repair did not clear all integrity warnings; rebuilding DB from JSONL...",
);
}
} else {
let verified = repair_report_verified(&initial.report);
let recovery_audit = early_repair.audit_record();
emit_recovery_audit_record(&recovery_audit);
if ctx.is_json() {
ctx.json(&serde_json::json!({
"report": initial.report,
"repaired": early_repair.applied(),
"recovery_audit": recovery_audit,
"message": repair_outcome_message_from_parts(early_repair.messages(), None, None),
"post_repair": initial.report,
"verified": verified,
}));
} else {
print_report(&initial.report, ctx)?;
ctx.info(&repair_outcome_message_from_parts(
early_repair.messages(),
None,
None,
));
}
return Ok(());
}
}
let has_blocked_cache_rebuild = report_has_blocked_cache_rebuild_finding(&initial.report);
let has_sidecar_anomaly = report_has_sidecar_anomaly(&initial.report);
if !local_repair.applied()
&& filter_allows_recoverable_db_state_repair(
&fixer_filter,
has_blocked_cache_rebuild,
has_sidecar_anomaly,
)
{
local_repair = repair_recoverable_db_state_under_write_authority(
&beads_dir,
&paths.db_path,
&initial.report,
session.as_mut(),
&fixer_filter,
repair_write_authority,
);
}
if !local_repair.indexes_reindexed
&& fixer_filter.allows(FM_PARTIAL_INDEX_STALE)
&& report_has_partial_index_warnings(&initial.report)
{
repair_partial_indexes_under_write_authority(
&paths.db_path,
&mut local_repair,
session.as_mut(),
repair_write_authority,
);
}
if fixer_filter.allows(FM_SQLITE_PAGE_MALFORMED) && report_has_page_corruption(&initial.report)
{
repair_via_vacuum(
&paths.db_path,
&mut local_repair,
session.as_mut(),
repair_write_authority,
);
}
let mut after_local_repair = if local_repair.applied() {
collect_doctor_report_for_cli(&beads_dir, &paths, cli)?
} else {
initial.clone()
};
if !local_repair.vacuumed
&& fixer_filter.allows(FM_SQLITE_PAGE_MALFORMED)
&& report_has_warn_level_page_anomaly(&after_local_repair.report)
{
tracing::info!(
path = %paths.db_path.display(),
"Post-repair report has WARN-level page anomalies; running VACUUM to clean up orphaned pages"
);
repair_via_vacuum(
&paths.db_path,
&mut local_repair,
session.as_mut(),
repair_write_authority,
);
if local_repair.vacuumed {
after_local_repair = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
}
}
if repair_report_verified(&after_local_repair.report) {
let write_probe_ok = write_probe_after_repair(&paths.db_path, repair_write_authority);
if !write_probe_ok {
let recovery_audit = early_repair.prepend_actions_to_audit(local_repair_audit_record(
"doctor.local_repair",
"write_probe_failed",
&local_repair,
Some("rollback-only write probe failed after local repair".to_string()),
));
emit_recovery_audit_record(&recovery_audit);
tracing::warn!(
"Post-repair write probe failed — local repair insufficient, \
falling through to full JSONL rebuild"
);
} else {
let repair_message = repair_outcome_message_from_parts(
early_repair.messages(),
Some(&local_repair),
None,
);
let recovery_audit = early_repair.prepend_actions_to_audit(local_repair_audit_record(
"doctor.local_repair",
"verified",
&local_repair,
None,
));
emit_recovery_audit_record(&recovery_audit);
if ctx.is_json() {
ctx.json(&serde_json::json!({
"report": initial.report,
"repaired": early_repair.applied() || local_repair.applied(),
"local_repair": local_repair,
"recovery_audit": recovery_audit,
"message": repair_message,
"post_repair": after_local_repair.report,
"verified": true,
}));
} else {
print_report(&initial.report, ctx)?;
ctx.info(&repair_message);
ctx.info("Post-repair verification:");
print_report(&after_local_repair.report, ctx)?;
}
return Ok(());
}
} else if local_repair.applied() {
let reason = if after_local_repair.report.ok {
"local repair did not clear page-level integrity warnings"
} else {
"local repair did not clear doctor errors"
};
let recovery_audit = early_repair.prepend_actions_to_audit(local_repair_audit_record(
"doctor.local_repair",
"needs_jsonl_rebuild",
&local_repair,
Some(reason.to_string()),
));
emit_recovery_audit_record(&recovery_audit);
}
if !filter_allows_jsonl_rebuild(&fixer_filter) {
let recovery_audit = early_repair.prepend_actions_to_audit(jsonl_rebuild_audit_record(
"doctor.jsonl_rebuild",
"refused",
None,
Some(JSONL_REBUILD_FILTERED_REASON.to_string()),
));
emit_recovery_audit_record(&recovery_audit);
if ctx.is_json() {
ctx.json(&serde_json::json!({
"ok": false,
"exit_code": DoctorExitCode::RefusedUnsafe.as_i32(),
"code": DoctorExitCode::RefusedUnsafe.as_str(),
"report": initial.report,
"repaired": early_repair.applied(),
"recovery_audit": recovery_audit,
"message": "JSONL rebuild refused: filtered out by --only/--skip",
}));
} else {
print_report(&initial.report, ctx)?;
ctx.error("Refusing JSONL rebuild: filtered out by --only/--skip");
}
crate::shutdown::exit_process(DoctorExitCode::RefusedUnsafe.as_i32());
}
let Some(jsonl_path) = initial.jsonl_path.as_ref() else {
let recovery_audit = early_repair.prepend_actions_to_audit(jsonl_rebuild_audit_record(
"doctor.jsonl_rebuild",
"refused",
None,
Some("no JSONL file found to rebuild from".to_string()),
));
emit_recovery_audit_record(&recovery_audit);
return Err(BeadsError::Config(
"Cannot repair: no JSONL file found to rebuild from".to_string(),
));
};
if let Some(reason) = repeated_jsonl_rebuild_refusal_reason(
&beads_dir,
&paths.db_path,
args.allow_repeated_repair,
)? {
let recovery_audit = early_repair.prepend_actions_to_audit(jsonl_rebuild_audit_record(
"doctor.jsonl_rebuild",
"refused",
None,
Some(reason.clone()),
));
emit_recovery_audit_record(&recovery_audit);
return Err(BeadsError::Config(reason));
}
if !ctx.is_json() {
print_report(&initial.report, ctx)?;
ctx.info("Repairing: rebuilding DB from JSONL...");
}
let repair_result = match repair_database_from_jsonl(
&beads_dir,
&paths.db_path,
jsonl_path,
cli,
!ctx.is_json(),
session.as_mut(),
) {
Ok(result) => result,
Err(err) => {
let outcome = jsonl_rebuild_failure_outcome(&err);
let recovery_audit = early_repair.prepend_actions_to_audit(jsonl_rebuild_audit_record(
"doctor.jsonl_rebuild",
outcome,
None,
Some(err.to_string()),
));
emit_recovery_audit_record(&recovery_audit);
if jsonl_rebuild_error_is_self_describing(outcome) {
return Err(err);
}
return Err(BeadsError::Config(jsonl_rebuild_failure_message(&err)));
}
};
let post_repair = collect_doctor_report_for_cli(&beads_dir, &paths, cli)?;
let post_repair_verified = jsonl_rebuild_repair_verified(
&post_repair.report,
&repair_result.preserved_dirty_issue_ids,
);
let verification_failure_marker = if post_repair_verified {
None
} else {
let marker_session = session.as_mut().ok_or_else(|| {
BeadsError::Config(
"Cannot write JSONL rebuild verification marker without a reversible repair session"
.to_string(),
)
})?;
Some(write_jsonl_rebuild_verification_failed_marker(
&beads_dir,
&paths.db_path,
&post_repair,
&repair_result,
marker_session,
)?)
};
let verification_failure_reason = verification_failure_marker.as_ref().map(|path| {
format!(
"post-repair verification failed; evidence marker written to '{}'",
path.display()
)
});
let recovery_audit = early_repair.prepend_actions_to_audit(jsonl_rebuild_audit_record(
"doctor.jsonl_rebuild",
if post_repair_verified {
"verified"
} else {
"verification_failed"
},
Some(&repair_result),
verification_failure_reason.clone(),
));
emit_recovery_audit_record(&recovery_audit);
if ctx.is_json() {
ctx.json(&serde_json::json!({
"report": initial.report,
"repaired": true,
"local_repair": local_repair,
"recovery_audit": recovery_audit,
"imported": repair_result.imported,
"skipped": repair_result.skipped,
"fk_violations_cleaned": repair_result.fk_violations_cleaned,
"preserved_tombstones": repair_result.preserved_tombstones,
"preserved_dirty_issues": repair_result.preserved_dirty_issues,
"preserved_dirty_issue_ids": &repair_result.preserved_dirty_issue_ids,
"preserved_history": &repair_result.preserved_history,
"history_preservation_warnings": &repair_result.history_preservation_warnings,
"verified_backups": &repair_result.verified_backups,
"post_repair": post_repair.report,
"verified": post_repair_verified,
"recovery_failure_marker": verification_failure_marker
.as_ref()
.map(|path| path.display().to_string()),
}));
} else {
ctx.info(&format!(
"Repair complete: imported {}, skipped {}",
repair_result.imported, repair_result.skipped
));
if repair_result.preserved_dirty_issues > 0 {
ctx.info(&format!(
"Preserved {} unflushed local issue(s) across the rebuild; run `br sync --flush-only` to export them",
repair_result.preserved_dirty_issues
));
}
if !repair_result.preserved_history.is_empty() {
let summary = repair_result
.preserved_history
.iter()
.map(|entry| {
if entry.skipped > 0 {
format!(
"{} {} (+{} orphaned skipped)",
entry.restored, entry.table, entry.skipped
)
} else {
format!("{} {}", entry.restored, entry.table)
}
})
.collect::<Vec<_>>()
.join(", ");
ctx.info(&format!(
"Preserved DB-only history across the rebuild: {summary}"
));
}
for warning in &repair_result.history_preservation_warnings {
ctx.warning(&format!("History NOT fully preserved: {warning}"));
}
if let Some(reason) = verification_failure_reason.as_deref() {
ctx.warning(reason);
}
ctx.info("Post-repair verification:");
print_report(&post_repair.report, ctx)?;
}
if !post_repair_verified {
return Err(BeadsError::Config(
"Repair completed, but post-repair verification still found issues".to_string(),
));
}
Ok(())
}
fn emit_flat_robot_triage(report: &DoctorReport) {
use crate::cli::commands::doctor_subsystems::surface::{
TriageFinding, build_triage_envelope, emit_robot_triage,
};
let mut healthy = 0usize;
let mut warn = 0usize;
let mut error = 0usize;
let mut findings: Vec<TriageFinding> = Vec::new();
for c in &report.checks {
match c.status {
CheckStatus::Ok => healthy += 1,
CheckStatus::Warn => {
warn += 1;
findings.push(TriageFinding {
id: c.name.clone(),
severity: "P2".to_string(),
message: c.message.clone().unwrap_or_default(),
});
}
CheckStatus::Error => {
error += 1;
findings.push(TriageFinding {
id: c.name.clone(),
severity: "P0".to_string(),
message: c.message.clone().unwrap_or_default(),
});
}
}
}
let envelope = build_triage_envelope(healthy, warn, error, findings);
emit_robot_triage(&envelope);
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::franken_sync::Connection;
use crate::health::{AnomalyClass, WorkspaceHealth};
use crate::model::{Issue, IssueType, Priority, Status};
use crate::storage::SqliteStorage;
use chrono::Utc;
use std::collections::BTreeMap;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::{NamedTempFile, TempDir};
fn find_check<'a>(checks: &'a [CheckResult], name: &str) -> Option<&'a CheckResult> {
checks.iter().find(|check| check.name == name)
}
fn backdate_file_two_hours(path: &Path) {
let two_hours_ago =
std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 60 * 60);
let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
f.set_times(std::fs::FileTimes::new().set_modified(two_hours_ago))
.unwrap();
}
fn sample_issue(id: &str, title: &str) -> Issue {
Issue {
id: id.to_string(),
content_hash: None,
title: title.to_string(),
description: None,
design: None,
acceptance_criteria: None,
notes: None,
status: Status::Open,
priority: Priority::MEDIUM,
issue_type: IssueType::Task,
assignee: None,
owner: None,
estimated_minutes: None,
created_at: Utc::now(),
created_by: None,
updated_at: Utc::now(),
closed_at: None,
close_reason: None,
closed_by_session: None,
bypassed_policy: None,
bypass_reason: None,
policy_gates_fired: None,
due_at: None,
defer_until: None,
external_ref: None,
source_system: None,
source_repo: None,
source_repo_path: None,
agent_context: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: Vec::new(),
dependencies: Vec::new(),
comments: Vec::new(),
}
}
fn create_sample_issue(storage: &mut SqliteStorage, id: &str, title: &str) {
storage
.create_issue(&sample_issue(id, title), "tester")
.unwrap();
}
fn install_valid_pending_merge_receipt(db_path: &Path) -> SyncMergePendingReceipt {
let mut storage = SqliteStorage::open(db_path).expect("open pending-receipt fixture");
let database_before =
crate::sync::capture_sync_database_witness(&storage).expect("capture database before");
let intent = crate::sync::SyncMergeIntent {
schema_version: 2,
database_authority_sha256: "1".repeat(64),
jsonl_authority_sha256: "2".repeat(64),
jsonl_path_sha256: "3".repeat(64),
jsonl_before: crate::sync::JsonlSourceStateWitness::Missing,
jsonl_before_content_sha256: None,
base_authority_sha256: "4".repeat(64),
base_before: crate::sync::JsonlSourceStateWitness::Missing,
base_before_content_sha256: None,
resolution: "manual".to_string(),
actor: "doctor-test".to_string(),
event_attribution: crate::storage::EventAttribution::default(),
capacity_policy: crate::close_policy::CapacityPolicy::default(),
retention_days: None,
export_as_of: chrono::DateTime::parse_from_rfc3339("2026-07-27T00:00:00Z")
.expect("fixed export timestamp")
.with_timezone(&Utc),
changed_kept_issue_ids: Vec::new(),
kept_issue_witnesses: Vec::new(),
deleted_issue_ids: Vec::new(),
note_witnesses: Vec::new(),
database_before,
};
let database_after =
crate::sync::capture_sync_merge_core_witness(&storage).expect("capture database after");
let receipt = SyncMergePendingReceipt::new(
intent,
"2026-07-27T00:00:00Z".to_string(),
database_after,
"5".repeat(64),
0,
&[],
Vec::new(),
)
.expect("construct receipt");
receipt.validate().expect("fixture receipt must validate");
storage
.set_metadata(
crate::sync::METADATA_SYNC_MERGE_PENDING,
&serde_json::to_string(&receipt).expect("serialize receipt"),
)
.expect("persist pending receipt");
receipt
}
#[test]
fn read_only_open_probe_check_is_ok_on_a_healthy_database() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
drop(SqliteStorage::open(&db_path).unwrap());
let mut checks = Vec::new();
check_read_only_open_observational(&db_path, &mut checks).unwrap();
assert_eq!(checks.len(), 1);
assert_eq!(checks[0].name, "db.read_only_open_observational");
assert!(
matches!(checks[0].status, CheckStatus::Ok),
"expected Ok, got {:?}: {:?}",
checks[0].status,
checks[0].message
);
let details = checks[0].details.as_ref().expect("details");
assert_eq!(details["opened"], serde_json::Value::Bool(true));
assert_eq!(
details["finding_id"],
serde_json::Value::String(
"fm-state_files-read-only-open-not-observational".to_string()
)
);
}
fn database_family_bytes(db_path: &Path) -> BTreeMap<String, Option<Vec<u8>>> {
crate::storage::sqlite::database_family_snapshot(db_path).expect("read database family")
}
fn assert_database_family_read_only(
before: &BTreeMap<String, Option<Vec<u8>>>,
after: &BTreeMap<String, Option<Vec<u8>>>,
context: &str,
) {
let diffs = crate::storage::sqlite::database_family_read_only_diffs(before, after);
assert!(
diffs.is_empty(),
"{context}: read-only contract violated: {diffs:#?}"
);
}
#[test]
fn pending_sync_merge_read_only_inspector_accepts_valid_v2_receipt() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let receipt = install_valid_pending_merge_receipt(&db_path);
let state = inspect_pending_sync_merge_at_path(&db_path)
.expect("inspect pending receipt")
.expect("pending receipt must be visible");
assert_eq!(state.condition, PendingSyncMergeCondition::Valid);
assert_eq!(
state.receipt_id.as_deref(),
Some(receipt.receipt_id.as_str())
);
assert_eq!(state.phase.as_deref(), Some("database_committed"));
assert_eq!(state.resolution.as_deref(), Some("manual"));
assert_eq!(state.expected_jsonl_issue_count, Some(0));
}
#[test]
fn pending_sync_merge_authority_inspector_is_coherent_and_byte_identical() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let receipt = install_valid_pending_merge_receipt(&db_path);
let authority = Arc::new(
crate::sync::blocking_database_family_write_lock_with_timeout(
temp.path(),
&db_path,
Some(1_000),
)
.unwrap(),
);
authority.bind_database_inode_for_mutation().unwrap();
let before = database_family_bytes(&db_path);
let state = inspect_pending_sync_merge_under_authority(&db_path, &authority)
.expect("inspect under authority")
.expect("pending receipt must remain visible");
assert_eq!(
state.receipt_id.as_deref(),
Some(receipt.receipt_id.as_str())
);
assert_database_family_read_only(
&before,
&database_family_bytes(&db_path),
"read-only authority inspection",
);
}
#[test]
fn pending_sync_merge_authority_inspector_treats_missing_database_as_absent() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("missing.db");
let authority = Arc::new(
crate::sync::blocking_database_family_write_lock_with_timeout(
temp.path(),
&db_path,
Some(1_000),
)
.unwrap(),
);
let state = inspect_pending_sync_merge_under_authority(&db_path, &authority)
.expect("inspect missing database under authority");
assert!(
state.is_none(),
"a definitively missing database cannot contain a pending merge receipt"
);
assert!(
!db_path.exists(),
"read-only pending-state inspection must not initialize a missing database"
);
}
#[test]
fn pending_sync_merge_authority_inspector_surfaces_schema_mismatch_for_stale_schema() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
{
let storage = SqliteStorage::open(&db_path).unwrap();
storage.execute_raw("PRAGMA user_version = 16").unwrap();
}
let authority = Arc::new(
crate::sync::blocking_database_family_write_lock_with_timeout(
temp.path(),
&db_path,
Some(1_000),
)
.unwrap(),
);
let err = inspect_pending_sync_merge_under_authority(&db_path, &authority).unwrap_err();
assert!(
matches!(err, BeadsError::SchemaMismatch { found: 16, .. }),
"stale schema must surface as SchemaMismatch, not engine contention: {err}"
);
}
#[test]
fn pending_sync_merge_read_only_inspector_distinguishes_legacy_and_malformed() {
let legacy = TempDir::new().unwrap();
let legacy_db = legacy.path().join("beads.db");
let mut storage = SqliteStorage::open(&legacy_db).unwrap();
storage
.set_metadata(METADATA_SYNC_MERGE_PENDING_LEGACY, "legacy-receipt")
.unwrap();
drop(storage);
let legacy_state = inspect_pending_sync_merge_at_path(&legacy_db)
.unwrap()
.expect("legacy state");
assert_eq!(legacy_state.condition, PendingSyncMergeCondition::Legacy);
assert_eq!(
legacy_state.metadata_key,
METADATA_SYNC_MERGE_PENDING_LEGACY
);
let malformed = TempDir::new().unwrap();
let malformed_db = malformed.path().join("beads.db");
let mut storage = SqliteStorage::open(&malformed_db).unwrap();
storage
.set_metadata(crate::sync::METADATA_SYNC_MERGE_PENDING, "{")
.unwrap();
drop(storage);
let malformed_state = inspect_pending_sync_merge_at_path(&malformed_db)
.unwrap()
.expect("malformed state");
assert_eq!(
malformed_state.condition,
PendingSyncMergeCondition::Malformed
);
assert!(
malformed_state.diagnostic.contains("not valid JSON"),
"{malformed_state:?}"
);
}
#[test]
fn pending_sync_merge_read_only_inspector_rejects_dual_legacy_and_current_keys() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.set_metadata(METADATA_SYNC_MERGE_PENDING_LEGACY, "legacy-receipt")
.unwrap();
storage
.set_metadata(crate::sync::METADATA_SYNC_MERGE_PENDING, "{}")
.unwrap();
drop(storage);
let state = inspect_pending_sync_merge_at_path(&db_path)
.unwrap()
.expect("dual-key state");
assert_eq!(state.condition, PendingSyncMergeCondition::Malformed);
assert!(
state
.metadata_key
.contains(METADATA_SYNC_MERGE_PENDING_LEGACY),
"{state:?}"
);
assert!(
state
.metadata_key
.contains(crate::sync::METADATA_SYNC_MERGE_PENDING),
"{state:?}"
);
assert!(
state.diagnostic.contains("both legacy (1) and current (1)"),
"{state:?}"
);
}
#[test]
fn pending_sync_merge_read_only_inspector_rejects_duplicate_and_empty_rows() {
let duplicate = TempDir::new().unwrap();
let duplicate_db = duplicate.path().join("beads.db");
drop(SqliteStorage::open(&duplicate_db).unwrap());
let conn = Connection::open(duplicate_db.to_string_lossy().into_owned()).unwrap();
for value in ["{}", "{}"] {
conn.execute_with_params(
"INSERT INTO metadata (key, value) VALUES (?, ?)",
&[
SqliteValue::from(crate::sync::METADATA_SYNC_MERGE_PENDING),
SqliteValue::from(value),
],
)
.unwrap();
}
conn.close().unwrap();
let duplicate_state = inspect_pending_sync_merge_at_path(&duplicate_db)
.unwrap()
.expect("duplicate state");
assert_eq!(
duplicate_state.condition,
PendingSyncMergeCondition::Malformed
);
assert!(
duplicate_state.diagnostic.contains("duplicate receipts"),
"{duplicate_state:?}"
);
let duplicate_legacy = TempDir::new().unwrap();
let duplicate_legacy_db = duplicate_legacy.path().join("beads.db");
drop(SqliteStorage::open(&duplicate_legacy_db).unwrap());
let conn = Connection::open(duplicate_legacy_db.to_string_lossy().into_owned()).unwrap();
for value in ["legacy-a", "legacy-b"] {
conn.execute_with_params(
"INSERT INTO metadata (key, value) VALUES (?, ?)",
&[
SqliteValue::from(METADATA_SYNC_MERGE_PENDING_LEGACY),
SqliteValue::from(value),
],
)
.unwrap();
}
conn.close().unwrap();
let duplicate_legacy_state = inspect_pending_sync_merge_at_path(&duplicate_legacy_db)
.unwrap()
.expect("duplicate legacy state");
assert_eq!(
duplicate_legacy_state.condition,
PendingSyncMergeCondition::Malformed
);
assert_eq!(
duplicate_legacy_state.metadata_key,
METADATA_SYNC_MERGE_PENDING_LEGACY
);
assert!(
duplicate_legacy_state
.diagnostic
.contains("duplicate receipts"),
"{duplicate_legacy_state:?}"
);
let empty = TempDir::new().unwrap();
let empty_db = empty.path().join("beads.db");
let mut storage = SqliteStorage::open(&empty_db).unwrap();
storage
.set_metadata(crate::sync::METADATA_SYNC_MERGE_PENDING, " ")
.unwrap();
drop(storage);
let empty_state = inspect_pending_sync_merge_at_path(&empty_db)
.unwrap()
.expect("empty state");
assert_eq!(empty_state.condition, PendingSyncMergeCondition::Malformed);
assert!(
empty_state.diagnostic.contains("NULL or empty"),
"{empty_state:?}"
);
}
#[test]
fn doctor_pending_merge_check_is_dedicated_and_never_repairs() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
install_valid_pending_merge_receipt(&db_path);
let mut checks = Vec::new();
check_pending_sync_merge(&db_path, &mut checks);
let check = find_check(&checks, "sync.merge_pending").expect("dedicated pending check");
assert_eq!(check.status, CheckStatus::Warn);
let details = check.details.as_ref().expect("structured pending details");
assert_eq!(details["pending"], true);
assert_eq!(details["state"]["condition"], "valid");
assert!(
details["remediation"]
.as_str()
.is_some_and(|text| text.contains("br sync --merge")),
"{details}"
);
}
fn insert_dependency_row(conn: &Connection, issue_id: &str, depends_on_id: &str) {
conn.execute_with_params(
"INSERT INTO dependencies(issue_id, depends_on_id, type) VALUES (?1, ?2, ?3)",
&[
SqliteValue::Text(issue_id.into()),
SqliteValue::Text(depends_on_id.into()),
SqliteValue::Text("blocks".into()),
],
)
.unwrap();
}
fn issue_with_blockers(id: &str, status: Status, blocked_by: &[&str]) -> Issue {
let mut issue = sample_issue(id, id);
issue.status = status;
issue.dependencies = blocked_by
.iter()
.map(|target| crate::model::Dependency {
issue_id: id.to_string(),
depends_on_id: (*target).to_string(),
dep_type: crate::model::DependencyType::Blocks,
created_at: Utc::now(),
created_by: None,
metadata: None,
thread_id: None,
})
.collect();
issue
}
fn write_issues_jsonl(path: &Path, issues: &[Issue]) {
let mut body = String::new();
for issue in issues {
body.push_str(&serde_json::to_string(issue).unwrap());
body.push('\n');
}
fs::write(path, body).unwrap();
}
#[test]
#[allow(clippy::too_many_lines)]
fn test_dep_graph_jsonl_flags_dead_edges_and_fully_unblocked() {
let temp = TempDir::new().unwrap();
let jsonl = temp.path().join("issues.jsonl");
let issues = vec![
issue_with_blockers("bd-a", Status::Open, &["bd-closed"]),
issue_with_blockers("bd-b", Status::Open, &["bd-open"]),
issue_with_blockers("bd-c", Status::Open, &["bd-missing"]),
issue_with_blockers("bd-d", Status::Open, &["bd-closed", "bd-open"]),
{
let mut closed = sample_issue("bd-closed", "bd-closed");
closed.status = Status::Closed;
closed.closed_at = Some(Utc::now());
closed
},
sample_issue("bd-open", "bd-open"),
];
write_issues_jsonl(&jsonl, &issues);
let mut checks = Vec::new();
check_dependency_graph_jsonl(&jsonl, &mut checks);
let dead = find_check(&checks, "dep.dead_closed_blocking_edges").expect("dead-edge check");
assert_eq!(dead.status, CheckStatus::Warn, "{dead:?}");
let details = dead.details.as_ref().unwrap();
assert_eq!(
details.get("count").and_then(serde_json::Value::as_u64),
Some(3),
"expected 3 issues with dead edges: {dead:?}"
);
assert_eq!(
details
.get("dangling_count")
.and_then(serde_json::Value::as_u64),
Some(1),
"only bd-c has a dangling blocker: {dead:?}"
);
let message = dead.message.as_deref().unwrap();
assert!(message.contains("bd-c"), "{message}");
assert!(!message.contains("bd-a"), "{message}");
let issues = details.get("issues").and_then(serde_json::Value::as_array);
let entry = |id: &str| -> &serde_json::Value {
issues
.unwrap()
.iter()
.find(|item| item.get("id").and_then(serde_json::Value::as_str) == Some(id))
.unwrap_or_else(|| panic!("no dead-edge entry for {id}: {dead:?}"))
};
assert_eq!(
entry("bd-a").get("satisfied_blockers").unwrap(),
&serde_json::json!(["bd-closed"])
);
assert_eq!(
entry("bd-a").get("dangling_blockers").unwrap(),
&serde_json::json!([])
);
assert_eq!(
entry("bd-c").get("dangling_blockers").unwrap(),
&serde_json::json!(["bd-missing"])
);
assert_eq!(
entry("bd-c").get("satisfied_blockers").unwrap(),
&serde_json::json!([])
);
assert_eq!(
entry("bd-d").get("dead_blockers").unwrap(),
&serde_json::json!(["bd-closed"])
);
let unblocked =
find_check(&checks, "dep.fully_unblocked_open").expect("fully-unblocked check");
assert_eq!(unblocked.status, CheckStatus::Ok, "{unblocked:?}");
let udetails = unblocked.details.as_ref().unwrap();
let unblocked_ids: Vec<String> = udetails
.get("issues")
.and_then(serde_json::Value::as_array)
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert!(
unblocked_ids.contains(&"bd-a".to_string()),
"{unblocked_ids:?}"
);
assert!(
unblocked_ids.contains(&"bd-c".to_string()),
"{unblocked_ids:?}"
);
assert!(
!unblocked_ids.contains(&"bd-d".to_string()),
"{unblocked_ids:?}"
);
assert!(
!unblocked_ids.contains(&"bd-b".to_string()),
"{unblocked_ids:?}"
);
assert_eq!(unblocked_ids.len(), 2, "{unblocked_ids:?}");
assert_eq!(
udetails.get("ready").unwrap(),
&serde_json::json!(["bd-a", "bd-c"]),
"{unblocked:?}"
);
assert_eq!(
udetails.get("stale_blocked").unwrap(),
&serde_json::json!([]),
"{unblocked:?}"
);
}
#[test]
fn test_dep_graph_jsonl_satisfied_only_reports_ok_with_details() {
let temp = TempDir::new().unwrap();
let jsonl = temp.path().join("issues.jsonl");
let issues = vec![issue_with_blockers("bd-dep", Status::Open, &["bd-done"]), {
let mut closed = sample_issue("bd-done", "bd-done");
closed.status = Status::Closed;
closed.closed_at = Some(Utc::now());
closed
}];
write_issues_jsonl(&jsonl, &issues);
let mut checks = Vec::new();
check_dependency_graph_jsonl(&jsonl, &mut checks);
let dead = find_check(&checks, "dep.dead_closed_blocking_edges").unwrap();
assert_eq!(dead.status, CheckStatus::Ok, "{dead:?}");
let details = dead.details.as_ref().unwrap();
assert_eq!(
details
.get("dangling_count")
.and_then(serde_json::Value::as_u64),
Some(0)
);
let entry = &details.get("issues").unwrap().as_array().unwrap()[0];
assert_eq!(
entry.get("satisfied_blockers").unwrap(),
&serde_json::json!(["bd-done"])
);
assert!(details.get("remediation").is_none(), "{dead:?}");
assert!(
!dead
.message
.as_deref()
.unwrap_or_default()
.contains("dep remove"),
"{dead:?}"
);
let unblocked = find_check(&checks, "dep.fully_unblocked_open").unwrap();
assert_eq!(unblocked.status, CheckStatus::Ok, "{unblocked:?}");
assert_eq!(
unblocked.details.as_ref().unwrap().get("ready").unwrap(),
&serde_json::json!(["bd-dep"])
);
}
#[test]
fn test_dep_graph_jsonl_stale_blocked_status_warns() {
let temp = TempDir::new().unwrap();
let jsonl = temp.path().join("issues.jsonl");
let issues = vec![
issue_with_blockers("bd-stale", Status::Blocked, &["bd-done"]),
issue_with_blockers("bd-deferred", Status::Deferred, &["bd-done"]),
{
let mut closed = sample_issue("bd-done", "bd-done");
closed.status = Status::Closed;
closed.closed_at = Some(Utc::now());
closed
},
];
write_issues_jsonl(&jsonl, &issues);
let mut checks = Vec::new();
check_dependency_graph_jsonl(&jsonl, &mut checks);
let unblocked = find_check(&checks, "dep.fully_unblocked_open").unwrap();
assert_eq!(unblocked.status, CheckStatus::Warn, "{unblocked:?}");
let details = unblocked.details.as_ref().unwrap();
assert_eq!(
details.get("stale_blocked").unwrap(),
&serde_json::json!(["bd-stale"])
);
let excluded = details.get("excluded").unwrap().as_array().unwrap();
assert_eq!(excluded.len(), 1, "{unblocked:?}");
assert_eq!(
excluded[0].get("id").and_then(serde_json::Value::as_str),
Some("bd-deferred")
);
let message = unblocked.message.as_deref().unwrap();
assert!(message.contains("bd-stale"), "{message}");
assert!(!message.contains("bd-deferred"), "{message}");
let dead = find_check(&checks, "dep.dead_closed_blocking_edges").unwrap();
assert_eq!(dead.status, CheckStatus::Ok, "{dead:?}");
}
#[test]
fn test_dep_graph_jsonl_clean_graph_reports_ok() {
let temp = TempDir::new().unwrap();
let jsonl = temp.path().join("issues.jsonl");
let issues = vec![
issue_with_blockers("bd-x", Status::Open, &["bd-y"]),
sample_issue("bd-y", "bd-y"),
];
write_issues_jsonl(&jsonl, &issues);
let mut checks = Vec::new();
check_dependency_graph_jsonl(&jsonl, &mut checks);
assert_eq!(
find_check(&checks, "dep.dead_closed_blocking_edges")
.unwrap()
.status,
CheckStatus::Ok
);
assert_eq!(
find_check(&checks, "dep.fully_unblocked_open")
.unwrap()
.status,
CheckStatus::Ok
);
}
fn dependency_row_count(conn: &Connection, issue_id: &str, depends_on_id: &str) -> i64 {
let rows = conn
.query("SELECT issue_id, depends_on_id FROM dependencies")
.unwrap();
rows.iter()
.filter(|row| {
let values = row.values();
matches!(
(values.first(), values.get(1)),
(Some(SqliteValue::Text(owner)), Some(SqliteValue::Text(target)))
if owner.as_str() == issue_id && target.as_str() == depends_on_id
)
})
.count()
.try_into()
.expect("dependency row count should fit in i64")
}
fn seed_dependency_orphan_repair_fixture(db_path: &Path) {
let mut storage = SqliteStorage::open(db_path).unwrap();
for (id, title) in [
("bd-keep-d", "Keep external target"),
("bd-owner-d", "Prune missing local target"),
("bd-valid-d", "Keep valid owner"),
("bd-target-d", "Keep valid target"),
] {
create_sample_issue(&mut storage, id, title);
}
drop(storage);
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let _ = conn.execute("PRAGMA foreign_keys = OFF");
for (issue_id, depends_on_id) in [
("bd-orphan-fix-d", "bd-other"),
("bd-owner-d", "bd-missing-local-target"),
("bd-keep-d", "external:upstream-1"),
("bd-valid-d", "bd-target-d"),
] {
insert_dependency_row(&conn, issue_id, depends_on_id);
}
}
fn dependency_orphan_report(db_path: &Path) -> DoctorReport {
let mut report = DoctorReport {
ok: false,
workspace_health: Some("degraded".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
check_dependencies_orphans(&conn, &mut report.checks);
report
}
#[test]
fn test_repair_orphan_cleanup_preserves_external_dependency_endpoints() {
let mut storage = SqliteStorage::open_memory().unwrap();
let mut epic = sample_issue("bd-epic", "Epic");
epic.issue_type = IssueType::Epic;
storage.create_issue(&epic, "tester").unwrap();
storage
.execute_test_sql(
"PRAGMA foreign_keys = OFF;
INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
VALUES ('external:child:cap', 'bd-epic', 'parent-child', '2026-01-01T00:00:00Z', 'tester');
INSERT INTO comments (issue_id, author, text, created_at)
VALUES ('missing-issue', 'tester', 'dangling', '2026-01-01T00:00:00Z');
PRAGMA foreign_keys = ON;",
)
.unwrap();
let cleaned = cleanup_repair_missing_issue_references(&mut storage).unwrap();
assert_eq!(cleaned, 1, "only the real local orphan should be removed");
let external_rows = storage
.execute_raw_query(
"SELECT issue_id, depends_on_id
FROM dependencies
WHERE issue_id = 'external:child:cap'",
)
.unwrap();
assert_eq!(
external_rows.len(),
1,
"external dependency endpoints must survive doctor repair cleanup"
);
}
#[test]
fn test_repair_orphan_cleanup_rebuilds_blocked_cache_after_dependency_cleanup() {
let mut storage = SqliteStorage::open_memory().unwrap();
let issue = sample_issue("bd-local", "Local");
storage.create_issue(&issue, "tester").unwrap();
storage
.execute_test_sql(
"PRAGMA foreign_keys = OFF;
INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
VALUES ('bd-local', 'bd-missing', 'blocks', '2026-01-01T00:00:00Z', 'tester');
INSERT INTO blocked_issues_cache (issue_id, blocked_by, blocked_at)
VALUES ('bd-local', '[\"bd-missing\"]', '2026-01-01T00:00:00Z');
PRAGMA foreign_keys = ON;",
)
.unwrap();
let cleaned = cleanup_repair_missing_issue_references(&mut storage).unwrap();
assert_eq!(
cleaned, 1,
"only the missing dependency row should be removed"
);
let cache_rows = storage
.execute_raw_query(
"SELECT issue_id
FROM blocked_issues_cache
WHERE issue_id = 'bd-local'",
)
.unwrap();
assert!(
cache_rows.is_empty(),
"dependency cleanup must rebuild stale blocked cache rows"
);
}
#[test]
fn test_classify_doctor_checks_marks_write_probe_failure_recoverable() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let jsonl_path = temp.path().join("issues.jsonl");
let checks = vec![CheckResult {
name: "db.write_probe".to_string(),
status: CheckStatus::Error,
message: Some(
"Rollback-only issue write failed: database disk image is malformed".to_string(),
),
details: Some(serde_json::json!({ "issue_id": "bd-probe" })),
}];
let classification = classify_doctor_checks(&db_path, &jsonl_path, &checks);
assert_eq!(classification.health, WorkspaceHealth::Recoverable);
assert!(
classification
.anomalies
.iter()
.any(|anomaly| matches!(anomaly, AnomalyClass::WriteProbeFailed { .. })),
"expected write-probe failure anomaly: {:?}",
classification.anomalies
);
}
#[test]
fn test_classify_doctor_checks_marks_invalid_jsonl_unsafe() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let jsonl_path = temp.path().join("issues.jsonl");
let checks = vec![CheckResult {
name: "jsonl.parse".to_string(),
status: CheckStatus::Error,
message: Some("Malformed or invalid issue records: 1".to_string()),
details: Some(serde_json::json!({ "path": jsonl_path.display().to_string() })),
}];
let classification = classify_doctor_checks(&db_path, &jsonl_path, &checks);
assert_eq!(classification.health, WorkspaceHealth::Unsafe);
assert!(
classification
.anomalies
.iter()
.any(|anomaly| matches!(anomaly, AnomalyClass::JsonlParseError { .. })),
"expected JSONL parse anomaly: {:?}",
classification.anomalies
);
}
#[test]
fn test_classify_doctor_checks_marks_repairable_integrity_warnings_recoverable() {
for (check_name, message) in [
("sqlite.integrity_check", "Page 55: never used"),
(
"sqlite3.integrity_check",
"row 42 missing from index idx_foo",
),
] {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let jsonl_path = temp.path().join("issues.jsonl");
let checks = vec![CheckResult {
name: check_name.to_string(),
status: CheckStatus::Warn,
message: Some(message.to_string()),
details: None,
}];
let classification = classify_doctor_checks(&db_path, &jsonl_path, &checks);
assert_eq!(classification.health, WorkspaceHealth::Recoverable);
assert!(
classification.anomalies.iter().any(|anomaly| {
matches!(
anomaly,
AnomalyClass::DatabaseCorrupt { detail } if detail == message
)
}),
"expected repairable integrity warning anomaly for {check_name}: {:?}",
classification.anomalies
);
}
}
#[test]
fn test_classify_doctor_checks_ignores_benign_integrity_warning() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let jsonl_path = temp.path().join("issues.jsonl");
let checks = vec![CheckResult {
name: "sqlite.integrity_check".to_string(),
status: CheckStatus::Warn,
message: Some("out of order index idx_foo".to_string()),
details: None,
}];
let classification = classify_doctor_checks(&db_path, &jsonl_path, &checks);
assert_eq!(classification.health, WorkspaceHealth::Healthy);
assert!(
classification.anomalies.is_empty(),
"benign integrity warning should not create health anomalies: {:?}",
classification.anomalies
);
}
#[test]
fn test_classify_doctor_checks_marks_count_mismatch_degraded() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let jsonl_path = temp.path().join("issues.jsonl");
let checks = vec![CheckResult {
name: "counts.db_vs_jsonl".to_string(),
status: CheckStatus::Warn,
message: Some("DB and JSONL counts differ".to_string()),
details: Some(serde_json::json!({ "db": 2, "jsonl": 1 })),
}];
let classification = classify_doctor_checks(&db_path, &jsonl_path, &checks);
assert_eq!(classification.health, WorkspaceHealth::Degraded);
assert!(
classification.anomalies.iter().any(|anomaly| {
matches!(
anomaly,
AnomalyClass::DbJsonlCountMismatch {
db_count: 2,
jsonl_count: 1
}
)
}),
"expected count mismatch anomaly: {:?}",
classification.anomalies
);
}
#[test]
fn test_classify_doctor_checks_prefers_sync_metadata_booleans_over_message() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let jsonl_path = temp.path().join("issues.jsonl");
let checks = vec![CheckResult {
name: "sync.metadata".to_string(),
status: CheckStatus::Ok,
message: Some("External changes pending import".to_string()),
details: Some(serde_json::json!({
"pending_import": false,
"pending_export": false,
"jsonl_newer": false,
"db_newer": false,
})),
}];
let classification = classify_doctor_checks(&db_path, &jsonl_path, &checks);
assert_eq!(classification.health, WorkspaceHealth::Healthy);
assert!(
classification.anomalies.is_empty(),
"machine sync booleans must override stale prose: {:?}",
classification.anomalies
);
}
#[test]
fn test_classify_doctor_checks_records_ok_pending_import_as_degraded_advisory() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let jsonl_path = temp.path().join("issues.jsonl");
let checks = vec![CheckResult {
name: "sync.metadata".to_string(),
status: CheckStatus::Ok,
message: Some("External changes pending import".to_string()),
details: Some(serde_json::json!({
"pending_import": true,
"pending_export": false,
"jsonl_newer": true,
"db_newer": false,
})),
}];
let classification = classify_doctor_checks(&db_path, &jsonl_path, &checks);
assert_eq!(classification.health, WorkspaceHealth::Degraded);
assert!(
classification
.anomalies
.iter()
.any(|anomaly| matches!(anomaly, AnomalyClass::JsonlNewer)),
"pending import remains an advisory health anomaly: {:?}",
classification.anomalies
);
assert!(
!classification
.anomalies
.iter()
.any(|anomaly| matches!(anomaly, AnomalyClass::DbNewer)),
"one-way import must not invent DB-newer evidence: {:?}",
classification.anomalies
);
}
#[test]
fn test_classify_doctor_checks_records_both_sync_metadata_directions() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let jsonl_path = temp.path().join("issues.jsonl");
let checks = vec![CheckResult {
name: "sync.metadata".to_string(),
status: CheckStatus::Warn,
message: Some("Database and JSONL have diverged (merge required)".to_string()),
details: Some(serde_json::json!({
"pending_import": true,
"pending_export": true,
"jsonl_newer": true,
"db_newer": true,
})),
}];
let classification = classify_doctor_checks(&db_path, &jsonl_path, &checks);
assert_eq!(classification.health, WorkspaceHealth::Degraded);
assert!(
classification
.anomalies
.iter()
.any(|anomaly| matches!(anomaly, AnomalyClass::JsonlNewer)),
"expected JSONL-newer anomaly: {:?}",
classification.anomalies
);
assert!(
classification
.anomalies
.iter()
.any(|anomaly| matches!(anomaly, AnomalyClass::DbNewer)),
"expected DB-newer anomaly: {:?}",
classification.anomalies
);
}
#[test]
fn test_classify_doctor_checks_falls_back_to_sync_metadata_message() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let jsonl_path = temp.path().join("issues.jsonl");
let checks = vec![
CheckResult {
name: "sync.metadata".to_string(),
status: CheckStatus::Warn,
message: Some("Database and JSONL have diverged (merge required)".to_string()),
details: None,
},
CheckResult {
name: "sync.metadata".to_string(),
status: CheckStatus::Warn,
message: Some(
"Local changes exist but no export is recorded; consider running sync --flush-only"
.to_string(),
),
details: None,
},
];
let classification = classify_doctor_checks(&db_path, &jsonl_path, &checks);
assert_eq!(classification.health, WorkspaceHealth::Degraded);
assert!(
classification
.anomalies
.iter()
.any(|anomaly| matches!(anomaly, AnomalyClass::JsonlNewer)),
"expected JSONL-newer fallback anomaly: {:?}",
classification.anomalies
);
assert!(
classification
.anomalies
.iter()
.any(|anomaly| matches!(anomaly, AnomalyClass::DbNewer)),
"expected DB-newer fallback anomaly: {:?}",
classification.anomalies
);
let local_only_checks = vec![CheckResult {
name: "sync.metadata".to_string(),
status: CheckStatus::Warn,
message: Some(
"Local changes exist but no export is recorded; consider running sync --flush-only"
.to_string(),
),
details: None,
}];
let local_only = classify_doctor_checks(&db_path, &jsonl_path, &local_only_checks);
assert!(
local_only
.anomalies
.iter()
.any(|anomaly| matches!(anomaly, AnomalyClass::DbNewer)),
"expected no-export fallback to classify DB-newer: {:?}",
local_only.anomalies
);
assert!(
!local_only
.anomalies
.iter()
.any(|anomaly| matches!(anomaly, AnomalyClass::JsonlNewer)),
"no-export fallback must not invent JSONL-newer: {:?}",
local_only.anomalies
);
}
#[test]
fn test_classify_doctor_checks_preserves_id_delta_total_counts() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let jsonl_path = temp.path().join("issues.jsonl");
let checks = vec![CheckResult {
name: "counts.db_vs_jsonl".to_string(),
status: CheckStatus::Warn,
message: Some("DB and JSONL counts match but id sets diverge".to_string()),
details: Some(serde_json::json!({
"db": 100,
"jsonl": 100,
"id_delta": {
"only_db_count": 100,
"only_jsonl_count": 100,
"both_count": 0,
"only_db": ["db-1", "db-2"],
"only_jsonl": ["jsonl-1"],
"preview_limit": 2
}
})),
}];
let classification = classify_doctor_checks(&db_path, &jsonl_path, &checks);
assert_eq!(classification.health, WorkspaceHealth::Degraded);
let Some(AnomalyClass::DbJsonlIdSetMismatch {
only_db_count,
only_jsonl_count,
only_db,
only_jsonl,
both_count,
}) = classification
.anomalies
.iter()
.find(|anomaly| matches!(anomaly, AnomalyClass::DbJsonlIdSetMismatch { .. }))
else {
panic!(
"expected id-set mismatch anomaly: {:?}",
classification.anomalies
);
};
assert_eq!(*only_db_count, 100);
assert_eq!(*only_jsonl_count, 100);
assert_eq!(*both_count, 0);
assert_eq!(only_db.as_slice(), ["db-1".to_string(), "db-2".to_string()]);
assert_eq!(only_jsonl.as_slice(), ["jsonl-1".to_string()]);
}
#[test]
fn test_classify_doctor_checks_marks_warn_recoverable_anomalies_degraded() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let jsonl_path = temp.path().join("issues.jsonl");
let checks = vec![CheckResult {
name: "db.recoverable_anomalies".to_string(),
status: CheckStatus::Warn,
message: Some(BLOCKED_CACHE_STALE_FINDING.to_string()),
details: Some(serde_json::json!({
"findings": [
BLOCKED_CACHE_STALE_FINDING,
BLOCKED_CACHE_CONTENT_MISMATCH_FINDING,
READY_PROJECTION_CONTENT_MISMATCH_FINDING,
]
})),
}];
let classification = classify_doctor_checks(&db_path, &jsonl_path, &checks);
assert_eq!(classification.health, WorkspaceHealth::Degraded);
let anomalies = &classification.anomalies;
assert!(
anomalies
.iter()
.any(|anomaly| matches!(anomaly, AnomalyClass::BlockedCacheStale)),
"expected blocked-cache stale anomaly: {:?}",
anomalies
);
assert!(
anomalies
.iter()
.any(|anomaly| matches!(anomaly, AnomalyClass::BlockedCacheContentMismatch)),
"expected blocked-cache content mismatch anomaly: {:?}",
anomalies
);
assert!(
anomalies
.iter()
.any(|anomaly| matches!(anomaly, AnomalyClass::ReadyProjectionContentMismatch)),
"expected ready projection mismatch anomaly: {:?}",
anomalies
);
}
#[test]
fn test_classify_doctor_checks_preserves_duplicate_finding_details() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let jsonl_path = temp.path().join("issues.jsonl");
let checks = vec![CheckResult {
name: "db.recoverable_anomalies".to_string(),
status: CheckStatus::Error,
message: Some(
"sqlite_master contains duplicate table entries for 'blocked_issues_cache' (3 rows)"
.to_string(),
),
details: Some(serde_json::json!({
"findings": [
"sqlite_master contains duplicate table entries for 'blocked_issues_cache' (3 rows)",
"config contains duplicate rows for key 'issue_prefix' (4 rows)",
"metadata contains duplicate rows for key 'project' (5 rows)",
]
})),
}];
let classification = classify_doctor_checks(&db_path, &jsonl_path, &checks);
assert_eq!(classification.health, WorkspaceHealth::Recoverable);
assert!(
classification.anomalies.iter().any(|anomaly| {
matches!(
anomaly,
AnomalyClass::DuplicateSchemaRows { name, count }
if name == "blocked_issues_cache" && *count == 3
)
}),
"expected schema duplicate details: {:?}",
classification.anomalies
);
assert!(
classification.anomalies.iter().any(|anomaly| {
matches!(
anomaly,
AnomalyClass::DuplicateConfigKeys { key, count }
if key == "issue_prefix" && *count == 4
)
}),
"expected config duplicate details: {:?}",
classification.anomalies
);
assert!(
classification.anomalies.iter().any(|anomaly| {
matches!(
anomaly,
AnomalyClass::DuplicateMetadataKeys { key, count }
if key == "project" && *count == 5
)
}),
"expected metadata duplicate details: {:?}",
classification.anomalies
);
}
#[test]
fn test_classify_doctor_checks_preserves_shm_only_sidecar_presence() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let jsonl_path = temp.path().join("issues.jsonl");
let shm_path = PathBuf::from(format!("{}-shm", db_path.to_string_lossy()));
let checks = vec![CheckResult {
name: "db.sidecars".to_string(),
status: CheckStatus::Error,
message: Some(format!(
"SHM sidecar exists without a matching WAL sidecar at {}",
shm_path.display()
)),
details: Some(serde_json::json!({
"findings": [
format!(
"SHM sidecar exists without a matching WAL sidecar at {}",
shm_path.display()
)
],
"quarantine_candidates": [shm_path.display().to_string()],
})),
}];
let classification = classify_doctor_checks(&db_path, &jsonl_path, &checks);
assert_eq!(classification.health, WorkspaceHealth::Degraded);
assert!(
classification.anomalies.iter().any(|anomaly| {
matches!(
anomaly,
AnomalyClass::SidecarMismatch {
has_wal: false,
has_shm: true
}
)
}),
"expected SHM-only sidecar anomaly: {:?}",
classification.anomalies
);
}
#[test]
fn test_classify_doctor_checks_preserves_shm_only_sidecar_presence_without_details() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let jsonl_path = temp.path().join("issues.jsonl");
let checks = vec![CheckResult {
name: "db.sidecars".to_string(),
status: CheckStatus::Error,
message: Some("SHM sidecar exists without a matching WAL sidecar".to_string()),
details: None,
}];
let classification = classify_doctor_checks(&db_path, &jsonl_path, &checks);
assert!(
classification.anomalies.iter().any(|anomaly| {
matches!(
anomaly,
AnomalyClass::SidecarMismatch {
has_wal: false,
has_shm: true
}
)
}),
"expected SHM-only sidecar anomaly: {:?}",
classification.anomalies
);
}
#[test]
fn test_local_repair_audit_records_applied_actions_and_artifacts() {
let repair = LocalRepairResult {
blocked_cache_rebuilt: true,
indexes_reindexed: true,
vacuumed: false,
quarantined_artifacts: vec![".beads/.br_recovery/beads.db-shm.test".to_string()],
};
let audit = local_repair_audit_record(
"doctor.local_repair",
"verified",
&repair,
Some("post-repair checks passed".to_string()),
);
assert_eq!(audit.phase, "doctor.local_repair");
assert_eq!(audit.action, "local_repair");
assert_eq!(audit.outcome, "verified");
assert_eq!(
audit.applied_actions,
vec![
"blocked_cache_rebuilt".to_string(),
"indexes_reindexed".to_string(),
"quarantined_artifacts".to_string()
]
);
assert_eq!(audit.quarantined_artifacts.len(), 1);
assert_eq!(audit.reason.as_deref(), Some("post-repair checks passed"));
}
#[test]
fn test_jsonl_rebuild_audit_records_import_counts() {
let repair = DoctorRepairResult {
imported: 3,
skipped: 1,
fk_violations_cleaned: 2,
preserved_tombstones: 0,
preserved_dirty_issues: 0,
preserved_dirty_issue_ids: Vec::new(),
verified_backups: Vec::new(),
preserved_history: Vec::new(),
history_preservation_warnings: Vec::new(),
};
let audit =
jsonl_rebuild_audit_record("doctor.jsonl_rebuild", "verified", Some(&repair), None);
assert_eq!(audit.action, "jsonl_rebuild");
assert_eq!(audit.imported, Some(3));
assert_eq!(audit.skipped, Some(1));
assert_eq!(audit.fk_violations_cleaned, Some(2));
}
#[test]
fn test_repeated_jsonl_rebuild_refusal_reason_detects_failed_marker() -> Result<()> {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
let db_path = beads_dir.join("beads.db");
fs::create_dir_all(&beads_dir)?;
let recovery_dir = config::recovery_dir_for_db_path(&db_path, &beads_dir);
fs::create_dir_all(&recovery_dir)?;
let marker = recovery_dir.join(format!(
"beads.db.20260421_120000_000000{}",
JSONL_REBUILD_VERIFICATION_FAILED_SUFFIX
));
fs::write(&marker, b"{\"outcome\":\"verification_failed\"}")?;
let reason =
repeated_jsonl_rebuild_refusal_reason(&beads_dir, &db_path, false)?.expect("reason");
assert!(reason.contains(JSONL_REBUILD_REPEAT_ERROR_PREFIX));
assert!(reason.contains(&marker.display().to_string()));
assert!(reason.contains("--allow-repeated-repair"));
let allowed = repeated_jsonl_rebuild_refusal_reason(&beads_dir, &db_path, true)?;
assert!(allowed.is_none(), "explicit override should permit retry");
Ok(())
}
#[test]
fn test_write_jsonl_rebuild_verification_failed_marker_records_failed_checks() -> Result<()> {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
let db_path = beads_dir.join("beads.db");
fs::create_dir_all(&beads_dir)?;
let post_repair = DoctorRun {
report: DoctorReport {
ok: false,
workspace_health: Some("unsafe".to_string()),
reliability_audit: None,
checks: vec![
CheckResult {
name: "sqlite.integrity_check".to_string(),
status: CheckStatus::Error,
message: Some("database disk image is malformed".to_string()),
details: None,
},
CheckResult {
name: "jsonl.parse".to_string(),
status: CheckStatus::Ok,
message: Some("Parsed 1 records".to_string()),
details: None,
},
],
},
jsonl_path: None,
};
let repair = DoctorRepairResult {
imported: 1,
skipped: 0,
fk_violations_cleaned: 0,
preserved_tombstones: 0,
preserved_dirty_issues: 0,
preserved_dirty_issue_ids: Vec::new(),
verified_backups: Vec::new(),
preserved_history: Vec::new(),
history_preservation_warnings: Vec::new(),
};
let mut session =
DoctorRepairSession::new(temp.path(), false).expect("session builds");
let marker = write_jsonl_rebuild_verification_failed_marker(
&beads_dir,
&db_path,
&post_repair,
&repair,
&mut session,
)?;
assert!(
marker
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(JSONL_REBUILD_VERIFICATION_FAILED_SUFFIX)),
"unexpected marker path: {}",
marker.display()
);
let payload: serde_json::Value = serde_json::from_slice(&fs::read(&marker)?)?;
assert_eq!(payload["outcome"], "verification_failed");
assert_eq!(payload["workspace_health"], "unsafe");
assert_eq!(payload["imported"], 1);
let failed_checks = payload["failed_checks"]
.as_array()
.expect("failed check array");
assert_eq!(failed_checks.len(), 1);
assert_eq!(failed_checks[0]["name"], "sqlite.integrity_check");
let evidence = prior_jsonl_rebuild_failure_evidence(&beads_dir, &db_path)?
.expect("marker should become repeated-repair evidence");
assert_eq!(evidence.path, marker);
let actions = fs::read_to_string(&session.run.actions_file)?;
let action: serde_json::Value = serde_json::from_str(
actions
.lines()
.find(|line| !line.trim().is_empty())
.expect("actions.jsonl should contain the marker write"),
)?;
assert_eq!(action["op"], "write_file");
assert_eq!(
action["fixer_id"],
"doctor.jsonl_rebuild_verification_marker"
);
assert!(
action["path"]
.as_str()
.is_some_and(|path| path.starts_with(".beads/.br_recovery/beads.db."))
);
Ok(())
}
#[test]
fn test_check_root_gitignore_warns_for_directory_patterns() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(
temp.path().join(".gitignore"),
".beads/\n/.beads/*\n!.beads/.gitignore\nkeep-me\n",
)
.unwrap();
let mut checks = Vec::new();
check_root_gitignore(&beads_dir, &mut checks);
let check = find_check(&checks, "gitignore.beads_inner").expect("gitignore check");
assert!(matches!(check.status, CheckStatus::Warn));
let offending = check
.details
.as_ref()
.and_then(|details| details.get("offending_patterns"))
.and_then(serde_json::Value::as_array)
.expect("offending patterns");
assert_eq!(
offending,
&vec![
serde_json::Value::String(".beads/".to_string()),
serde_json::Value::String("/.beads/*".to_string()),
]
);
}
#[test]
fn test_fix_root_gitignore_if_warned_removes_all_offending_patterns() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let gitignore_path = temp.path().join(".gitignore");
fs::write(
&gitignore_path,
".beads/\nkeep-me\n/.beads/.gitignore\n!.beads/.gitignore\n",
)
.unwrap();
let db_path = beads_dir.join("beads.db");
let jsonl_path = beads_dir.join("issues.jsonl");
let _storage = SqliteStorage::open(&db_path).unwrap();
fs::write(
&jsonl_path,
format!(
"{}\n",
serde_json::to_string(&sample_issue("bd-test01", "Valid issue")).unwrap()
),
)
.unwrap();
let paths = config::ConfigPaths {
beads_dir: beads_dir.clone(),
db_path,
jsonl_path,
metadata: config::Metadata::default(),
};
let report_before = collect_doctor_report(&beads_dir, &paths).expect("doctor report");
let before_check =
find_check(&report_before.report.checks, "gitignore.beads_inner").expect("warning");
assert!(matches!(before_check.status, CheckStatus::Warn));
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Json, false, true);
assert!(fix_root_gitignore_if_warned(
&beads_dir,
&report_before.report,
&ctx,
None,
));
assert_eq!(
fs::read_to_string(&gitignore_path).unwrap(),
"keep-me\n!.beads/.gitignore\n"
);
let report_after = collect_doctor_report(&beads_dir, &paths).expect("doctor report");
let after_check =
find_check(&report_after.report.checks, "gitignore.beads_inner").expect("status");
assert!(matches!(after_check.status, CheckStatus::Ok));
}
#[test]
fn test_doctor_no_db_skips_db_backed_checks_and_reports_no_db_mode() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let jsonl_path = beads_dir.join("issues.jsonl");
let _storage = SqliteStorage::open(&db_path).unwrap();
fs::write(
&jsonl_path,
format!(
"{}\n",
serde_json::to_string(&sample_issue("bd-test01", "Valid issue")).unwrap()
),
)
.unwrap();
let paths = config::ConfigPaths {
beads_dir: beads_dir.clone(),
db_path,
jsonl_path,
metadata: config::Metadata::default(),
};
let run = collect_doctor_report_with_mode_and_db_override(
&beads_dir,
&paths,
None,
DoctorInspectionMode::Full,
true,
)
.expect("doctor report");
let checks = &run.report.checks;
let marker = find_check(checks, "db.no_db_mode").expect("db.no_db_mode present");
assert_eq!(marker.status, CheckStatus::Ok);
let skipped: Vec<String> = marker
.details
.as_ref()
.and_then(|d| d.get("skipped_checks"))
.and_then(serde_json::Value::as_array)
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert!(skipped.contains(&"db.inspect".to_string()), "{skipped:?}");
assert!(
skipped.contains(&"db.bloat_vs_jsonl".to_string()),
"{skipped:?}"
);
for db_check in [
"db.exists",
"db.sidecars",
"db.recovery_artifacts",
"schema.tables",
"counts.db_vs_jsonl",
"permissions.recovery_dir",
] {
assert!(
find_check(checks, db_check).is_none(),
"DB-backed check `{db_check}` must be skipped under --no-db: {:?}",
checks.iter().map(|c| &c.name).collect::<Vec<_>>()
);
}
assert!(
find_check(checks, "jsonl.parse").is_some(),
"JSONL audit must still run under --no-db"
);
let run_db = collect_doctor_report_with_mode_and_db_override(
&beads_dir,
&paths,
None,
DoctorInspectionMode::Full,
false,
)
.expect("doctor report with db");
assert!(find_check(&run_db.report.checks, "db.no_db_mode").is_none());
assert!(find_check(&run_db.report.checks, "db.sidecars").is_some());
}
#[test]
fn test_check_base_jsonl_missing_is_ok() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let mut checks = Vec::new();
check_base_jsonl(&beads_dir, &mut checks);
let check = find_check(&checks, "base_jsonl").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok), "{check:?}");
}
#[test]
fn test_check_base_jsonl_stale_anchor_warns() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let base = beads_dir.join("beads.base.jsonl");
let live = beads_dir.join("issues.jsonl");
fs::write(&base, b"{\"id\":\"bd-old\"}\n").unwrap();
fs::write(&live, b"{\"id\":\"bd-new\"}\n").unwrap();
let two_hours_ago = std::time::SystemTime::now() - std::time::Duration::from_secs(7200);
let times = std::fs::FileTimes::new()
.set_accessed(two_hours_ago)
.set_modified(two_hours_ago);
let base_file = std::fs::OpenOptions::new().write(true).open(&base).unwrap();
base_file.set_times(times).unwrap();
drop(base_file);
let mut checks = Vec::new();
check_base_jsonl(&beads_dir, &mut checks);
let check = find_check(&checks, "base_jsonl").expect("check present");
assert!(
matches!(check.status, CheckStatus::Warn),
"{check:?} should be Warn for stale anchor"
);
let kind = check
.details
.as_ref()
.and_then(|d| d.get("kind"))
.and_then(|v| v.as_str());
assert_eq!(kind, Some("stale"));
}
#[cfg(unix)]
#[test]
fn test_check_base_jsonl_symlink_warns() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let outside = temp.path().join("outside.jsonl");
fs::write(&outside, b"{\"id\":\"bd-outside\"}\n").unwrap();
symlink(&outside, beads_dir.join("beads.base.jsonl")).unwrap();
let mut checks = Vec::new();
check_base_jsonl(&beads_dir, &mut checks);
let check = find_check(&checks, "base_jsonl").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn));
let kind = check
.details
.as_ref()
.and_then(|d| d.get("kind"))
.and_then(|v| v.as_str());
assert_eq!(kind, Some("symlink"));
}
#[cfg(unix)]
#[test]
fn test_fix_base_jsonl_symlink_refuses_out_of_scope_target() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let outside = temp.path().join("outside.jsonl");
fs::write(&outside, b"{\"id\":\"bd-outside\"}\n").unwrap();
let symlink_path = beads_dir.join("beads.base.jsonl");
symlink(&outside, &symlink_path).unwrap();
fs::write(beads_dir.join("issues.jsonl"), b"{}\n").unwrap();
let mut report = DoctorReport {
ok: false,
workspace_health: Some("degraded".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
check_base_jsonl(&beads_dir, &mut report.checks);
assert!(
report
.checks
.iter()
.any(|c| c.name == "base_jsonl" && matches!(c.status, CheckStatus::Warn))
);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
let result =
fix_base_jsonl_symlink_if_warned(&beads_dir, &report, &ctx, Some(&mut session));
assert!(
!result,
"fixer must refuse when symlink target is outside write_scopes"
);
assert!(
fs::symlink_metadata(&symlink_path)
.unwrap()
.file_type()
.is_symlink(),
"symlink must remain in place when refused"
);
assert_eq!(
fs::read(&outside).unwrap(),
b"{\"id\":\"bd-outside\"}\n",
"symlink target's bytes must not be modified"
);
}
#[cfg(unix)]
#[test]
fn test_fix_base_jsonl_symlink_quarantines_in_scope_target() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let inside_target = beads_dir.join("sibling.jsonl");
fs::write(&inside_target, b"{\"id\":\"bd-sibling\"}\n").unwrap();
let symlink_path = beads_dir.join("beads.base.jsonl");
symlink(&inside_target, &symlink_path).unwrap();
fs::write(beads_dir.join("issues.jsonl"), b"{}\n").unwrap();
let mut report = DoctorReport {
ok: false,
workspace_health: Some("degraded".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
check_base_jsonl(&beads_dir, &mut report.checks);
assert!(
report
.checks
.iter()
.any(|c| c.name == "base_jsonl" && matches!(c.status, CheckStatus::Warn))
);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(fix_base_jsonl_symlink_if_warned(
&beads_dir,
&report,
&ctx,
Some(&mut session),
));
assert!(
!symlink_path.exists() && fs::symlink_metadata(&symlink_path).is_err(),
"source symlink should be moved out of .beads/"
);
let q = session.run.root.join("quarantine/.beads/beads.base.jsonl");
assert!(
q.exists(),
"quarantine should hold the moved symlink content at {q:?}"
);
assert_eq!(
fs::read(&inside_target).unwrap(),
b"{\"id\":\"bd-sibling\"}\n",
"symlink target's bytes must not be modified"
);
let actions = fs::read_to_string(&session.run.actions_file).unwrap();
let rename_count = actions
.lines()
.filter(|l| l.contains("\"op\":\"rename\""))
.count();
assert_eq!(rename_count, 1, "actions.jsonl: {actions}");
}
#[test]
fn test_fix_base_jsonl_stale_regen_writes_live_bytes() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let anchor = beads_dir.join("beads.base.jsonl");
let live = beads_dir.join("issues.jsonl");
fs::write(&anchor, b"{\"id\":\"bd-old\"}\n").unwrap();
fs::write(&live, b"{\"id\":\"bd-new-1\"}\n{\"id\":\"bd-new-2\"}\n").unwrap();
let two_hours_ago = std::time::SystemTime::now() - std::time::Duration::from_secs(7200);
let anchor_file = std::fs::OpenOptions::new()
.write(true)
.open(&anchor)
.unwrap();
anchor_file
.set_times(std::fs::FileTimes::new().set_modified(two_hours_ago))
.unwrap();
drop(anchor_file);
let mut report = DoctorReport {
ok: false,
workspace_health: Some("degraded".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
check_base_jsonl(&beads_dir, &mut report.checks);
let check = find_check(&report.checks, "base_jsonl").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn));
let kind = check
.details
.as_ref()
.and_then(|d| d.get("kind"))
.and_then(|v| v.as_str());
assert_eq!(kind, Some("stale"));
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(fix_base_jsonl_stale_if_warned(
&beads_dir,
&report,
&ctx,
Some(&mut session),
));
assert_eq!(
fs::read(&anchor).unwrap(),
fs::read(&live).unwrap(),
"regenerated anchor must equal live JSONL bytes"
);
let actions = fs::read_to_string(&session.run.actions_file).unwrap();
let write_count = actions
.lines()
.filter(|l| l.contains("\"op\":\"write_file\""))
.count();
assert_eq!(write_count, 1, "actions.jsonl: {actions}");
}
#[test]
fn test_fix_base_jsonl_stale_skips_byte_identical_anchor() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let anchor = beads_dir.join("beads.base.jsonl");
let live = beads_dir.join("issues.jsonl");
let content = b"{\"id\":\"bd-same\"}\n";
fs::write(&anchor, content).unwrap();
fs::write(&live, content).unwrap();
let two_hours_ago = std::time::SystemTime::now() - std::time::Duration::from_secs(7200);
let anchor_file = std::fs::OpenOptions::new()
.write(true)
.open(&anchor)
.unwrap();
anchor_file
.set_times(std::fs::FileTimes::new().set_modified(two_hours_ago))
.unwrap();
drop(anchor_file);
let mut report = DoctorReport {
ok: false,
workspace_health: Some("degraded".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
check_base_jsonl(&beads_dir, &mut report.checks);
let check = find_check(&report.checks, "base_jsonl").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn));
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(!fix_base_jsonl_stale_if_warned(
&beads_dir,
&report,
&ctx,
Some(&mut session),
));
assert_eq!(fs::read(&anchor).unwrap(), content);
let actions = fs::read_to_string(&session.run.actions_file).unwrap();
assert!(
actions.is_empty(),
"identical anchor must be a no-op: {actions}"
);
}
#[test]
fn test_fix_base_jsonl_stale_refuses_empty_live_jsonl() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let anchor = beads_dir.join("beads.base.jsonl");
fs::write(&anchor, b"{\"id\":\"bd-anchor\"}\n").unwrap();
fs::write(beads_dir.join("issues.jsonl"), b"").unwrap();
let mut report = DoctorReport {
ok: false,
workspace_health: Some("degraded".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
push_check(
&mut report.checks,
"base_jsonl",
CheckStatus::Warn,
Some("synthetic stale".to_string()),
Some(serde_json::json!({"kind": "stale"})),
);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(!fix_base_jsonl_stale_if_warned(
&beads_dir,
&report,
&ctx,
Some(&mut session),
));
assert_eq!(fs::read(&anchor).unwrap(), b"{\"id\":\"bd-anchor\"}\n");
}
#[test]
fn test_check_base_jsonl_missing_post_flush_warns_after_export() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.set_metadata(
crate::sync::METADATA_LAST_EXPORT_TIME,
"2026-05-01T00:00:00Z",
)
.unwrap();
drop(storage);
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_base_jsonl_missing_post_flush(&conn, &beads_dir, None, &mut checks);
let check = find_check(&checks, "base_jsonl.missing_post_flush").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn));
assert_eq!(
check
.details
.as_ref()
.and_then(|details| details.get("kind"))
.and_then(serde_json::Value::as_str),
Some("missing_post_flush")
);
}
#[test]
fn test_check_base_jsonl_missing_post_flush_allows_fresh_missing_anchor() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let _storage = SqliteStorage::open(&db_path).unwrap();
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_base_jsonl_missing_post_flush(&conn, &beads_dir, None, &mut checks);
let check = find_check(&checks, "base_jsonl.missing_post_flush").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_check_base_jsonl_missing_post_flush_ok_when_verifiably_in_sync() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let jsonl_path = beads_dir.join("issues.jsonl");
fs::write(&jsonl_path, b"{\"id\":\"bd-1\"}\n").unwrap();
let computed_hash = crate::sync::compute_jsonl_hash(&jsonl_path).unwrap();
let db_path = beads_dir.join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.set_metadata(
crate::sync::METADATA_LAST_EXPORT_TIME,
"2026-05-01T00:00:00Z",
)
.unwrap();
storage
.set_metadata(crate::sync::METADATA_JSONL_CONTENT_HASH, &computed_hash)
.unwrap();
drop(storage);
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_base_jsonl_missing_post_flush(&conn, &beads_dir, Some(&jsonl_path), &mut checks);
let check = find_check(&checks, "base_jsonl.missing_post_flush").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok), "{check:?}");
assert_eq!(
check
.details
.as_ref()
.and_then(|details| details.get("kind"))
.and_then(serde_json::Value::as_str),
Some("missing_but_in_sync")
);
}
#[test]
fn test_check_base_jsonl_missing_post_flush_warns_when_hash_diverged() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let jsonl_path = beads_dir.join("issues.jsonl");
fs::write(&jsonl_path, b"{\"id\":\"bd-1\"}\n").unwrap();
let db_path = beads_dir.join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.set_metadata(
crate::sync::METADATA_LAST_EXPORT_TIME,
"2026-05-01T00:00:00Z",
)
.unwrap();
storage
.set_metadata(crate::sync::METADATA_JSONL_CONTENT_HASH, "stale-hash")
.unwrap();
drop(storage);
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_base_jsonl_missing_post_flush(&conn, &beads_dir, Some(&jsonl_path), &mut checks);
let check = find_check(&checks, "base_jsonl.missing_post_flush").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
assert!(
check
.message
.as_deref()
.is_some_and(|m| m.contains("br sync --flush-only")),
"warning should name the recovery command: {check:?}"
);
}
#[test]
fn test_check_dirty_bitmap_clean_workspace_ok() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let _storage = SqliteStorage::open(&db_path).unwrap();
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_dirty_bitmap_divergence(&conn, &mut checks);
let check = find_check(&checks, "dirty_bitmap").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok), "{check:?}");
}
#[test]
fn test_check_dirty_bitmap_orphan_row_warns() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let _storage = SqliteStorage::open(&db_path).unwrap();
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let _ = conn.execute("PRAGMA foreign_keys = OFF");
conn.execute_with_params(
"INSERT INTO dirty_issues(issue_id, marked_at) VALUES (?1, ?2)",
&[
SqliteValue::Text("bd-orphan-1".into()),
SqliteValue::Text("2026-05-14T00:00:00Z".into()),
],
)
.unwrap();
let mut checks = Vec::new();
check_dirty_bitmap_divergence(&conn, &mut checks);
let check = find_check(&checks, "dirty_bitmap").expect("check present");
assert!(
matches!(check.status, CheckStatus::Warn),
"{check:?} should be Warn"
);
let orphan_count = check
.details
.as_ref()
.and_then(|d| d.get("orphan_count"))
.and_then(serde_json::Value::as_i64);
assert_eq!(orphan_count, Some(1));
let sample = check
.details
.as_ref()
.and_then(|d| d.get("sample_issue_ids"))
.and_then(|v| v.as_array())
.map(Vec::len)
.unwrap_or(0);
assert_eq!(sample, 1);
}
#[test]
fn test_check_dependencies_orphans_warns_on_orphan_row() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let _storage = SqliteStorage::open(&db_path).unwrap();
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let _ = conn.execute("PRAGMA foreign_keys = OFF");
conn.execute_with_params(
"INSERT INTO dependencies(issue_id, depends_on_id, type) VALUES (?1, ?2, ?3)",
&[
SqliteValue::Text("bd-orphan-d".into()),
SqliteValue::Text("bd-other".into()),
SqliteValue::Text("blocks".into()),
],
)
.unwrap();
let mut checks = Vec::new();
check_dependencies_orphans(&conn, &mut checks);
let check = find_check(&checks, "dependencies.orphans").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
let count = check
.details
.as_ref()
.and_then(|d| d.get("orphan_count"))
.and_then(serde_json::Value::as_i64);
assert_eq!(count, Some(1));
}
#[test]
fn test_check_dependencies_orphans_warns_on_missing_local_target() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.create_issue(
&sample_issue("bd-owner-d", "Dependency owner with missing target"),
"tester",
)
.unwrap();
drop(storage);
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
conn.execute_with_params(
"INSERT INTO dependencies(issue_id, depends_on_id, type) VALUES (?1, ?2, ?3)",
&[
SqliteValue::Text("bd-owner-d".into()),
SqliteValue::Text("bd-missing-local-target".into()),
SqliteValue::Text("blocks".into()),
],
)
.unwrap();
let mut checks = Vec::new();
check_dependencies_orphans(&conn, &mut checks);
let check = find_check(&checks, "dependencies.orphans").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
let count = check
.details
.as_ref()
.and_then(|d| d.get("orphan_count"))
.and_then(serde_json::Value::as_i64);
assert_eq!(count, Some(1));
let sample_edges = check
.details
.as_ref()
.and_then(|d| d.get("sample_edges"))
.and_then(serde_json::Value::as_array)
.expect("sample_edges array");
assert!(
sample_edges
.iter()
.any(|edge| edge.as_str() == Some("bd-owner-d -> bd-missing-local-target")),
"missing local target should be sampled: {sample_edges:?}"
);
}
#[test]
fn test_check_dependencies_orphans_clean_workspace_ok() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.create_issue(
&sample_issue("bd-local-d", "Local dependency owner"),
"tester",
)
.unwrap();
drop(storage);
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
conn.execute_with_params(
"INSERT INTO dependencies(issue_id, depends_on_id, type) VALUES (?1, ?2, ?3)",
&[
SqliteValue::Text("bd-local-d".into()),
SqliteValue::Text("external:upstream-1".into()),
SqliteValue::Text("blocks".into()),
],
)
.unwrap();
let mut checks = Vec::new();
check_dependencies_orphans(&conn, &mut checks);
let check = find_check(&checks, "dependencies.orphans").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_check_workflow_statuses_warns_on_out_of_set_status() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path();
let db_path = beads_dir.join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.create_issue(&sample_issue("bd-ok", "Open issue"), "tester")
.unwrap();
let mut bad = sample_issue("bd-bad", "Has bogus status");
bad.status = Status::Custom("completed".to_string());
storage.create_issue(&bad, "tester").unwrap();
drop(storage);
fs::write(
beads_dir.join(crate::close_policy::POLICY_FILE_NAME),
"workflow:\n strict: true\n statuses: [\"open\", \"in_progress\", \"closed\"]\n",
)
.unwrap();
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_workflow_statuses(&conn, beads_dir, &mut checks);
let check = find_check(&checks, "policy.workflow_statuses").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
let offenders = check
.details
.as_ref()
.and_then(|d| d.get("offenders"))
.and_then(serde_json::Value::as_array)
.expect("offenders array");
assert_eq!(offenders.len(), 1);
assert_eq!(
offenders[0].get("bead_id").and_then(|v| v.as_str()),
Some("bd-bad")
);
assert_eq!(
offenders[0].get("status").and_then(|v| v.as_str()),
Some("completed")
);
}
#[test]
fn test_check_workflow_statuses_ok_when_all_conform() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path();
let db_path = beads_dir.join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.create_issue(&sample_issue("bd-ok-1", "Open issue"), "tester")
.unwrap();
drop(storage);
fs::write(
beads_dir.join(crate::close_policy::POLICY_FILE_NAME),
"workflow:\n strict: true\n statuses: [\"open\", \"closed\"]\n",
)
.unwrap();
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_workflow_statuses(&conn, beads_dir, &mut checks);
let check = find_check(&checks, "policy.workflow_statuses").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok), "{check:?}");
}
#[test]
fn test_check_workflow_statuses_silent_without_policy() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path();
let db_path = beads_dir.join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let mut bad = sample_issue("bd-x", "Weird status");
bad.status = Status::Custom("completed".to_string());
storage.create_issue(&bad, "tester").unwrap();
drop(storage);
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_workflow_statuses(&conn, beads_dir, &mut checks);
assert!(
find_check(&checks, "policy.workflow_statuses").is_none(),
"no check should be emitted without a strict workflow policy"
);
}
#[test]
fn test_fix_dependencies_orphans_prunes_via_chokepoint() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
seed_dependency_orphan_repair_fixture(&db_path);
let report = dependency_orphan_report(&db_path);
assert!(
report
.checks
.iter()
.any(|c| c.name == "dependencies.orphans" && matches!(c.status, CheckStatus::Warn))
);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(fix_dependencies_orphans_if_warned(
&db_path,
&report,
&ctx,
Some(&mut session),
));
let mut after = Vec::new();
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
check_dependencies_orphans(&conn, &mut after);
let check = find_check(&after, "dependencies.orphans").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok), "{check:?}");
assert_eq!(
dependency_row_count(&conn, "bd-keep-d", "external:upstream-1"),
1,
"repair must preserve missing/external depends_on_id rows"
);
assert_eq!(
dependency_row_count(&conn, "bd-valid-d", "bd-target-d"),
1,
"repair must preserve valid local dependency rows"
);
assert_eq!(
dependency_row_count(&conn, "bd-owner-d", "bd-missing-local-target"),
0,
"repair must prune non-external missing depends_on_id rows"
);
}
#[test]
fn test_check_labels_orphans_warns_on_orphan_row() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let _storage = SqliteStorage::open(&db_path).unwrap();
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let _ = conn.execute("PRAGMA foreign_keys = OFF");
conn.execute_with_params(
"INSERT INTO labels(issue_id, label) VALUES (?1, ?2)",
&[
SqliteValue::Text("bd-orphan-l".into()),
SqliteValue::Text("doc".into()),
],
)
.unwrap();
let mut checks = Vec::new();
check_labels_orphans(&conn, &mut checks);
let check = find_check(&checks, "labels.orphans").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
let count = check
.details
.as_ref()
.and_then(|d| d.get("orphan_count"))
.and_then(serde_json::Value::as_i64);
assert_eq!(count, Some(1));
}
#[test]
fn test_check_labels_orphans_clean_workspace_ok() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let _storage = SqliteStorage::open(&db_path).unwrap();
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_labels_orphans(&conn, &mut checks);
let check = find_check(&checks, "labels.orphans").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_fix_labels_orphans_prunes_via_chokepoint() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let _storage = SqliteStorage::open(&db_path).unwrap();
{
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let _ = conn.execute("PRAGMA foreign_keys = OFF");
conn.execute_with_params(
"INSERT INTO labels(issue_id, label) VALUES (?1, ?2)",
&[
SqliteValue::Text("bd-orphan-fix-l".into()),
SqliteValue::Text("doc".into()),
],
)
.unwrap();
}
let mut report = DoctorReport {
ok: false,
workspace_health: Some("degraded".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
{
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
check_labels_orphans(&conn, &mut report.checks);
}
assert!(
report
.checks
.iter()
.any(|c| c.name == "labels.orphans" && matches!(c.status, CheckStatus::Warn))
);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(fix_labels_orphans_if_warned(
&db_path,
&report,
&ctx,
Some(&mut session),
));
let mut after = Vec::new();
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
check_labels_orphans(&conn, &mut after);
let check = find_check(&after, "labels.orphans").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok), "{check:?}");
}
#[test]
fn test_check_comments_orphans_warns_on_orphan_row() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let _storage = SqliteStorage::open(&db_path).unwrap();
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let _ = conn.execute("PRAGMA foreign_keys = OFF");
conn.execute_with_params(
"INSERT INTO comments(issue_id, text, created_at, author) VALUES (?1, ?2, ?3, ?4)",
&[
SqliteValue::Text("bd-orphan-c".into()),
SqliteValue::Text("orphan body".into()),
SqliteValue::Text("2026-05-15T00:00:00Z".into()),
SqliteValue::Text("ghost".into()),
],
)
.unwrap();
let mut checks = Vec::new();
check_comments_orphans(&conn, &mut checks);
let check = find_check(&checks, "comments.orphans").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
let count = check
.details
.as_ref()
.and_then(|d| d.get("orphan_count"))
.and_then(serde_json::Value::as_i64);
assert_eq!(count, Some(1));
}
#[test]
fn test_check_comments_orphans_clean_workspace_ok() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let _storage = SqliteStorage::open(&db_path).unwrap();
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_comments_orphans(&conn, &mut checks);
let check = find_check(&checks, "comments.orphans").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_fix_comments_orphans_prunes_via_chokepoint() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let _storage = SqliteStorage::open(&db_path).unwrap();
{
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let _ = conn.execute("PRAGMA foreign_keys = OFF");
conn.execute_with_params(
"INSERT INTO comments(issue_id, text, created_at, author) VALUES (?1, ?2, ?3, ?4)",
&[
SqliteValue::Text("bd-orphan-fix".into()),
SqliteValue::Text("orphan body".into()),
SqliteValue::Text("2026-05-15T00:00:00Z".into()),
SqliteValue::Text("ghost".into()),
],
)
.unwrap();
}
let mut report = DoctorReport {
ok: false,
workspace_health: Some("degraded".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
{
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
check_comments_orphans(&conn, &mut report.checks);
}
assert!(
report
.checks
.iter()
.any(|c| c.name == "comments.orphans" && matches!(c.status, CheckStatus::Warn))
);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(fix_comments_orphans_if_warned(
&db_path,
&report,
&ctx,
Some(&mut session),
));
let mut after = Vec::new();
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
check_comments_orphans(&conn, &mut after);
let check = find_check(&after, "comments.orphans").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok), "{check:?}");
}
#[test]
fn test_fix_null_defaults_backfills_via_chokepoint() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let storage = SqliteStorage::open(&db_path).unwrap();
drop(storage);
{
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
conn.execute("DROP TABLE events").unwrap();
conn.execute(
"CREATE TABLE events (
id INTEGER PRIMARY KEY,
issue_id TEXT NOT NULL,
event_type TEXT NOT NULL DEFAULT '',
actor TEXT,
created_at TEXT NOT NULL DEFAULT '',
metadata TEXT
)",
)
.unwrap();
conn.execute_with_params(
"INSERT INTO events(issue_id, event_type, actor, created_at) VALUES (?1, ?2, ?3, ?4)",
&[
SqliteValue::Text("bd-null-fix".into()),
SqliteValue::Text("created".into()),
SqliteValue::Null,
SqliteValue::Text("2026-05-15T00:00:00Z".into()),
],
)
.unwrap();
}
let mut report = DoctorReport {
ok: false,
workspace_health: Some("degraded".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
{
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
check_null_defaults(&conn, &mut report.checks);
}
assert!(
report
.checks
.iter()
.any(|c| c.name == "db.null_defaults" && matches!(c.status, CheckStatus::Warn)),
"precondition: detector must warn on the injected NULL"
);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(fix_null_defaults_if_warned(
&db_path,
&report,
&ctx,
Some(&mut session),
));
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut after = Vec::new();
check_null_defaults(&conn, &mut after);
let check = find_check(&after, "db.null_defaults").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok), "{check:?}");
let rows = conn
.query("SELECT actor FROM events WHERE issue_id = 'bd-null-fix'")
.unwrap();
let actor = rows.first().and_then(|row| row.values().first().cloned());
assert!(
matches!(actor, Some(SqliteValue::Text(ref s)) if s.is_empty()),
"actor should be backfilled to '': {actor:?}"
);
}
#[test]
fn test_fix_dirty_bitmap_orphans_prunes_via_chokepoint() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let _storage = SqliteStorage::open(&db_path).unwrap();
{
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let _ = conn.execute("PRAGMA foreign_keys = OFF");
conn.execute_with_params(
"INSERT INTO dirty_issues(issue_id, marked_at) VALUES (?1, ?2)",
&[
SqliteValue::Text("bd-orphan-fix".into()),
SqliteValue::Text("2026-05-15T00:00:00Z".into()),
],
)
.unwrap();
}
let mut report = DoctorReport {
ok: false,
workspace_health: Some("degraded".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
{
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
check_dirty_bitmap_divergence(&conn, &mut report.checks);
}
let warned = report
.checks
.iter()
.any(|c| c.name == "dirty_bitmap" && matches!(c.status, CheckStatus::Warn));
assert!(warned, "precondition: detector must warn");
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(fix_dirty_bitmap_orphans_if_warned(
&db_path,
&report,
&ctx,
Some(&mut session),
));
let mut after = Vec::new();
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
check_dirty_bitmap_divergence(&conn, &mut after);
let check = find_check(&after, "dirty_bitmap").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok), "{check:?}");
}
#[test]
fn test_fix_dirty_bitmap_orphans_prunes_null_issue_id() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let _storage = SqliteStorage::open(&db_path).unwrap();
{
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let _ = conn.execute("PRAGMA foreign_keys = OFF");
conn.execute_with_params(
"INSERT INTO dirty_issues(issue_id, marked_at) VALUES (?1, ?2)",
&[
SqliteValue::Null,
SqliteValue::Text("2026-05-15T00:00:00Z".into()),
],
)
.unwrap();
}
let mut report = DoctorReport {
ok: false,
workspace_health: Some("degraded".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
{
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
check_dirty_bitmap_divergence(&conn, &mut report.checks);
}
assert!(
report
.checks
.iter()
.any(|c| c.name == "dirty_bitmap" && matches!(c.status, CheckStatus::Warn)),
"precondition: NULL dirty_issues issue_id must warn"
);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
assert!(fix_dirty_bitmap_orphans_if_warned(
&db_path,
&report,
&quiet_ctx(),
Some(&mut session),
));
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let count = conn
.query_row("SELECT COUNT(*) FROM dirty_issues")
.unwrap()
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or(-1);
assert_eq!(count, 0, "NULL issue_id orphan should be pruned");
}
#[test]
fn test_fix_dirty_bitmap_orphans_noop_when_clean() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let _storage = SqliteStorage::open(&db_path).unwrap();
let mut report = DoctorReport {
ok: true,
workspace_health: Some("healthy".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
check_dirty_bitmap_divergence(&conn, &mut report.checks);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(!fix_dirty_bitmap_orphans_if_warned(
&db_path,
&report,
&ctx,
Some(&mut session),
));
}
#[test]
fn test_check_doctor_runs_dir_below_threshold_ok() {
let temp = TempDir::new().unwrap();
let runs = temp.path().join(".doctor").join("runs");
fs::create_dir_all(&runs).unwrap();
for i in 0..5 {
fs::create_dir_all(runs.join(format!("run-{i}"))).unwrap();
}
let mut checks = Vec::new();
check_doctor_runs_dir_size(temp.path(), &mut checks);
let check = find_check(&checks, "doctor.runs_dir").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_check_doctor_runs_dir_above_threshold_warns() {
let temp = TempDir::new().unwrap();
let runs = temp.path().join(".doctor").join("runs");
fs::create_dir_all(&runs).unwrap();
for i in 0..(DOCTOR_RUNS_THRESHOLD + 5) {
fs::create_dir_all(runs.join(format!("run-{i}"))).unwrap();
}
let mut checks = Vec::new();
check_doctor_runs_dir_size(temp.path(), &mut checks);
let check = find_check(&checks, "doctor.runs_dir").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn));
let run_count = check
.details
.as_ref()
.and_then(|d| d.get("run_count"))
.and_then(serde_json::Value::as_u64);
assert_eq!(run_count, Some((DOCTOR_RUNS_THRESHOLD + 5) as u64));
}
#[test]
fn test_check_doctor_runs_dir_missing_is_ok() {
let temp = TempDir::new().unwrap();
let mut checks = Vec::new();
check_doctor_runs_dir_size(temp.path(), &mut checks);
let check = find_check(&checks, "doctor.runs_dir").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_check_doctor_runs_creatable_ok_when_missing() {
let temp = TempDir::new().unwrap();
let mut checks = Vec::new();
check_doctor_runs_creatable(temp.path(), &mut checks);
let check = find_check(&checks, "doctor.runs_creatable").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_check_doctor_runs_creatable_ok_when_writable() {
let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join(".doctor")).unwrap();
let mut checks = Vec::new();
check_doctor_runs_creatable(temp.path(), &mut checks);
let check = find_check(&checks, "doctor.runs_creatable").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_check_root_gitignore_writable_ok_when_missing() {
let temp = TempDir::new().unwrap();
let mut checks = Vec::new();
check_root_gitignore_writable(temp.path(), &mut checks);
let check = find_check(&checks, "permissions.root_gitignore").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_check_root_gitignore_writable_ok_when_writable() {
let temp = TempDir::new().unwrap();
fs::write(temp.path().join(".gitignore"), b"target/\n").unwrap();
let mut checks = Vec::new();
check_root_gitignore_writable(temp.path(), &mut checks);
let check = find_check(&checks, "permissions.root_gitignore").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[cfg(unix)]
#[test]
fn test_check_root_gitignore_writable_warns_when_not_writable() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let gi = temp.path().join(".gitignore");
fs::write(&gi, b"target/\n").unwrap();
fs::set_permissions(&gi, fs::Permissions::from_mode(0o444)).unwrap();
let mut checks = Vec::new();
check_root_gitignore_writable(temp.path(), &mut checks);
let check = find_check(&checks, "permissions.root_gitignore").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
let details = check.details.as_ref().expect("details");
assert_eq!(
details.get("mode_octal").and_then(|v| v.as_str()),
Some("444")
);
fs::set_permissions(&gi, fs::Permissions::from_mode(0o644)).unwrap();
}
#[test]
fn test_check_write_lock_writable_ok_when_missing() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let mut checks = Vec::new();
check_write_lock_writable(&beads_dir, &mut checks);
let check = find_check(&checks, "permissions.write_lock").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_check_write_lock_writable_ok_when_writable() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(beads_dir.join(".write.lock"), b"").unwrap();
let mut checks = Vec::new();
check_write_lock_writable(&beads_dir, &mut checks);
let check = find_check(&checks, "permissions.write_lock").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[cfg(unix)]
#[test]
fn test_check_write_lock_writable_warns_when_not_writable() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let lock = beads_dir.join(".write.lock");
fs::write(&lock, b"").unwrap();
fs::set_permissions(&lock, fs::Permissions::from_mode(0o444)).unwrap();
let mut checks = Vec::new();
check_write_lock_writable(&beads_dir, &mut checks);
let check = find_check(&checks, "permissions.write_lock").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
let details = check.details.as_ref().expect("details");
assert_eq!(
details.get("mode_octal").and_then(|v| v.as_str()),
Some("444")
);
fs::set_permissions(&lock, fs::Permissions::from_mode(0o644)).unwrap();
}
#[test]
fn test_check_recovery_dir_writable_ok_when_missing() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let mut checks = Vec::new();
check_recovery_dir_writable(&db_path, &beads_dir, &mut checks);
let check = find_check(&checks, "permissions.recovery_dir").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_check_recovery_dir_writable_ok_when_writable() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(beads_dir.join(".br_recovery")).unwrap();
let db_path = beads_dir.join("beads.db");
let mut checks = Vec::new();
check_recovery_dir_writable(&db_path, &beads_dir, &mut checks);
let check = find_check(&checks, "permissions.recovery_dir").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[cfg(unix)]
#[test]
fn test_check_recovery_dir_writable_warns_when_not_writable() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
let recovery = beads_dir.join(".br_recovery");
fs::create_dir_all(&recovery).unwrap();
fs::set_permissions(&recovery, fs::Permissions::from_mode(0o555)).unwrap();
let db_path = beads_dir.join("beads.db");
let mut checks = Vec::new();
check_recovery_dir_writable(&db_path, &beads_dir, &mut checks);
let check = find_check(&checks, "permissions.recovery_dir").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
let details = check.details.as_ref().expect("details");
assert_eq!(
details.get("mode_octal").and_then(|v| v.as_str()),
Some("555")
);
fs::set_permissions(&recovery, fs::Permissions::from_mode(0o755)).unwrap();
}
#[cfg(unix)]
#[test]
fn test_check_recovery_dir_writable_uses_configured_db_parent() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(beads_dir.join(".br_recovery")).unwrap();
let db_dir = temp.path().join("configured-db");
let recovery = db_dir.join(".br_recovery");
fs::create_dir_all(&recovery).unwrap();
fs::set_permissions(&recovery, fs::Permissions::from_mode(0o555)).unwrap();
let db_path = db_dir.join("beads.db");
let mut checks = Vec::new();
check_recovery_dir_writable(&db_path, &beads_dir, &mut checks);
let check = find_check(&checks, "permissions.recovery_dir").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
let expected_path = recovery.to_string_lossy().into_owned();
assert_eq!(
check
.details
.as_ref()
.and_then(|details| details.get("path"))
.and_then(|path| path.as_str()),
Some(expected_path.as_str())
);
fs::set_permissions(&recovery, fs::Permissions::from_mode(0o755)).unwrap();
}
#[cfg(unix)]
#[test]
fn test_check_doctor_runs_creatable_warns_when_not_writable() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let doctor = temp.path().join(".doctor");
fs::create_dir_all(&doctor).unwrap();
fs::set_permissions(&doctor, fs::Permissions::from_mode(0o555)).unwrap();
let mut checks = Vec::new();
check_doctor_runs_creatable(temp.path(), &mut checks);
let check = find_check(&checks, "doctor.runs_creatable").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
let details = check.details.as_ref().expect("details");
assert_eq!(
details.get("mode_octal").and_then(|v| v.as_str()),
Some("555")
);
fs::set_permissions(&doctor, fs::Permissions::from_mode(0o755)).unwrap();
}
#[cfg(unix)]
#[test]
fn test_check_config_yaml_secret_mode_warns_on_world_readable_with_secrets() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let config = beads_dir.join("config.yaml");
fs::write(&config, b"github_token: ghp_abc123\n").unwrap();
let mut perms = fs::metadata(&config).unwrap().permissions();
perms.set_mode(0o644); fs::set_permissions(&config, perms).unwrap();
let mut checks = Vec::new();
check_config_yaml_secret_mode(&beads_dir, &mut checks);
let check = find_check(&checks, "permissions.config_yaml_secrets").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
let matched = check
.details
.as_ref()
.and_then(|d| d.get("matched_keywords"))
.and_then(|v| v.as_array())
.map(Vec::len)
.unwrap_or(0);
assert!(matched >= 1);
}
#[cfg(unix)]
#[test]
fn test_check_config_yaml_secret_mode_ok_when_mode_0600() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let config = beads_dir.join("config.yaml");
fs::write(&config, b"github_token: ghp_xyz\npassword: hunter2\n").unwrap();
let mut perms = fs::metadata(&config).unwrap().permissions();
perms.set_mode(0o600);
fs::set_permissions(&config, perms).unwrap();
let mut checks = Vec::new();
check_config_yaml_secret_mode(&beads_dir, &mut checks);
let check = find_check(&checks, "permissions.config_yaml_secrets").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok), "{check:?}");
}
#[cfg(unix)]
#[test]
fn test_check_config_yaml_secret_mode_ok_when_no_secrets() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let config = beads_dir.join("config.yaml");
fs::write(&config, b"theme: dark\neditor: vim\n").unwrap();
let mut perms = fs::metadata(&config).unwrap().permissions();
perms.set_mode(0o644);
fs::set_permissions(&config, perms).unwrap();
let mut checks = Vec::new();
check_config_yaml_secret_mode(&beads_dir, &mut checks);
let check = find_check(&checks, "permissions.config_yaml_secrets").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok), "{check:?}");
}
#[cfg(unix)]
#[test]
fn test_br_binaries_in_path_str_finds_multiple() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let dir_a = temp.path().join("a");
let dir_b = temp.path().join("b");
fs::create_dir_all(&dir_a).unwrap();
fs::create_dir_all(&dir_b).unwrap();
let br_a = dir_a.join("br");
let br_b = dir_b.join("br");
fs::write(&br_a, b"#!/bin/sh\n").unwrap();
fs::write(&br_b, b"#!/bin/sh\n").unwrap();
let mut perms = fs::metadata(&br_a).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&br_a, perms.clone()).unwrap();
fs::set_permissions(&br_b, perms).unwrap();
let path_var = format!("{}:{}", dir_a.display(), dir_b.display());
let found = br_binaries_in_path_str(&path_var);
assert_eq!(found.len(), 2);
}
#[cfg(unix)]
#[test]
fn test_br_binaries_in_path_str_skips_non_executable() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let dir = temp.path().join("d");
fs::create_dir_all(&dir).unwrap();
let br = dir.join("br");
fs::write(&br, b"not a binary").unwrap();
let mut perms = fs::metadata(&br).unwrap().permissions();
perms.set_mode(0o644);
fs::set_permissions(&br, perms).unwrap();
let path_var = dir.display().to_string();
let found = br_binaries_in_path_str(&path_var);
assert!(found.is_empty(), "non-executable should be skipped");
}
#[cfg(unix)]
#[test]
fn test_br_binaries_in_path_str_dedupes_canonical_path() {
use std::os::unix::fs::PermissionsExt;
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let real_dir = temp.path().join("real");
fs::create_dir_all(&real_dir).unwrap();
let br = real_dir.join("br");
fs::write(&br, b"#!/bin/sh\n").unwrap();
let mut perms = fs::metadata(&br).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&br, perms).unwrap();
let link_dir = temp.path().join("link");
symlink(&real_dir, &link_dir).unwrap();
let path_var = format!("{}:{}", real_dir.display(), link_dir.display());
let found = br_binaries_in_path_str(&path_var);
assert_eq!(found.len(), 1, "canonical dedup expected: {found:?}");
}
#[test]
fn test_check_inner_gitignore_present_missing_warns() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let mut checks = Vec::new();
check_inner_gitignore_present(&beads_dir, &mut checks);
let check = find_check(&checks, "gitignore.beads_inner_present").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
assert_eq!(
check
.details
.as_ref()
.and_then(|d| d.get("kind"))
.and_then(|v| v.as_str()),
Some("missing")
);
}
#[test]
fn test_check_inner_gitignore_present_complete_ok() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let contents = inner_gitignore_all_append_patterns().join("\n") + "\n";
fs::write(beads_dir.join(".gitignore"), contents).unwrap();
let mut checks = Vec::new();
check_inner_gitignore_present(&beads_dir, &mut checks);
let check = find_check(&checks, "gitignore.beads_inner_present").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok), "{check:?}");
}
#[test]
fn test_check_inner_gitignore_operator_megamix_equivalents_are_ok() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(
beads_dir.join(".gitignore"),
b"*.db\n*.db?*\n*-fsqlite-ns-gate\n*-fsqlite-ns-use\n*.lock\n*.tmp\n",
)
.unwrap();
let mut checks = Vec::new();
check_inner_gitignore_present(&beads_dir, &mut checks);
let check = find_check(&checks, "gitignore.beads_inner_present").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok), "{check:?}");
}
#[test]
fn test_check_inner_gitignore_flags_missing_db_artifact_family() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(beads_dir.join(".gitignore"), b"*.db\n.write.lock\n*.tmp\n").unwrap();
let mut checks = Vec::new();
check_inner_gitignore_present(&beads_dir, &mut checks);
let check = find_check(&checks, "gitignore.beads_inner_present").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
let missing: Vec<String> = check
.details
.as_ref()
.and_then(|d| d.get("missing_patterns"))
.and_then(|v| v.as_array())
.map(|patterns| {
patterns
.iter()
.filter_map(|p| p.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
for expected in [
"*.db-wal*",
"*-fsqlite-ns-gate",
"*-fsqlite-ns-use",
"*.vacuum-wal-cert*",
] {
assert!(
missing.iter().any(|p| p == expected),
"expected {expected} in missing patterns, got {missing:?}"
);
}
}
#[test]
fn test_inner_gitignore_broad_lock_rule_and_later_negation() {
assert!(inner_gitignore_ignores_probe(
"*.lock\n*.tmp\n",
".write.lock"
));
assert!(!inner_gitignore_ignores_probe(
"*.lock\n!.write.lock\n*.tmp\n",
".write.lock"
));
}
#[test]
fn test_gitignore_glob_matches_semantics() {
assert!(gitignore_glob_matches("*.db?*", "beads.db-wal"));
assert!(gitignore_glob_matches(
"*.db?*",
".beads.db.schema-migration-20260101T000000.000000Z-0-0.vacuum-fsqlite-ns-gate"
));
assert!(!gitignore_glob_matches("*.db?*", "beads.db"));
assert!(gitignore_glob_matches("*.db", "beads.db"));
assert!(gitignore_glob_matches(
"*-fsqlite-ns-gate",
"beads.db-fsqlite-ns-gate"
));
assert!(!gitignore_glob_matches("*.tmp", "probe.tmpx"));
assert!(gitignore_glob_matches("probe.?mp", "probe.tmp"));
assert!(!gitignore_glob_matches("probe.?mp", "probe.mp"));
}
#[test]
fn test_check_inner_gitignore_present_incomplete_warns() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let contents = inner_gitignore_all_append_patterns()
.into_iter()
.filter(|pattern| *pattern != "*.tmp")
.collect::<Vec<_>>()
.join("\n")
+ "\n";
fs::write(beads_dir.join(".gitignore"), contents).unwrap();
let mut checks = Vec::new();
check_inner_gitignore_present(&beads_dir, &mut checks);
let check = find_check(&checks, "gitignore.beads_inner_present").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn));
assert_eq!(
check
.details
.as_ref()
.and_then(|d| d.get("kind"))
.and_then(|v| v.as_str()),
Some("incomplete")
);
let missing = check
.details
.as_ref()
.and_then(|d| d.get("missing_patterns"))
.and_then(|v| v.as_array())
.map(Vec::len)
.unwrap_or(0);
assert_eq!(missing, 1);
}
#[cfg(unix)]
#[test]
fn test_check_inner_gitignore_present_symlink_warns_even_with_valid_target() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let target = temp.path().join("shared-ignore");
fs::write(&target, b".write.lock\n*.tmp\n").unwrap();
symlink(&target, beads_dir.join(".gitignore")).unwrap();
let mut checks = Vec::new();
check_inner_gitignore_present(&beads_dir, &mut checks);
let check = find_check(&checks, "gitignore.beads_inner_present").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
assert_eq!(
check
.details
.as_ref()
.and_then(|d| d.get("kind"))
.and_then(|v| v.as_str()),
Some("symlink")
);
}
#[cfg(unix)]
#[test]
fn test_check_jsonl_world_writable_warns_on_world_writable() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let jsonl = beads_dir.join("issues.jsonl");
fs::write(&jsonl, b"{}\n").unwrap();
let mut perms = fs::metadata(&jsonl).unwrap().permissions();
perms.set_mode(0o666); fs::set_permissions(&jsonl, perms).unwrap();
let mut checks = Vec::new();
check_jsonl_world_writable(Some(&jsonl), &mut checks);
let check = find_check(&checks, "permissions.jsonl_world_writable").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
}
#[cfg(unix)]
#[test]
fn test_check_jsonl_world_writable_ok_when_mode_0644() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let jsonl = beads_dir.join("issues.jsonl");
fs::write(&jsonl, b"{}\n").unwrap();
let mut perms = fs::metadata(&jsonl).unwrap().permissions();
perms.set_mode(0o644);
fs::set_permissions(&jsonl, perms).unwrap();
let mut checks = Vec::new();
check_jsonl_world_writable(Some(&jsonl), &mut checks);
let check = find_check(&checks, "permissions.jsonl_world_writable").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok), "{check:?}");
}
#[test]
fn test_check_jsonl_world_writable_missing_is_ok() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let mut checks = Vec::new();
check_jsonl_world_writable(None, &mut checks);
let check = find_check(&checks, "permissions.jsonl_world_writable").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_check_orphan_tmp_files_old_tmp_warns() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let tmp_path = beads_dir.join("issues.jsonl.99999.tmp");
fs::write(&tmp_path, b"partial write").unwrap();
let two_hours_ago =
std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 60 * 60);
let f = std::fs::OpenOptions::new()
.write(true)
.open(&tmp_path)
.unwrap();
f.set_times(std::fs::FileTimes::new().set_modified(two_hours_ago))
.unwrap();
drop(f);
let mut checks = Vec::new();
check_orphan_tmp_files(&beads_dir, &mut checks);
let check = find_check(&checks, "tmp_files_orphan").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
let files = check
.details
.as_ref()
.and_then(|d| d.get("files"))
.and_then(|v| v.as_array())
.map(Vec::len)
.unwrap_or(0);
assert_eq!(files, 1);
}
#[test]
fn test_check_orphan_tmp_files_fresh_tmp_ok() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(beads_dir.join("issues.jsonl.123.tmp"), b"fresh").unwrap();
let mut checks = Vec::new();
check_orphan_tmp_files(&beads_dir, &mut checks);
let check = find_check(&checks, "tmp_files_orphan").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok), "{check:?}");
}
#[test]
fn test_check_orphan_tmp_files_ignores_non_tmp_files() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let jsonl = beads_dir.join("issues.jsonl");
fs::write(&jsonl, b"{}\n").unwrap();
let two_hours_ago =
std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 60 * 60);
let f = std::fs::OpenOptions::new()
.write(true)
.open(&jsonl)
.unwrap();
f.set_times(std::fs::FileTimes::new().set_modified(two_hours_ago))
.unwrap();
drop(f);
let mut checks = Vec::new();
check_orphan_tmp_files(&beads_dir, &mut checks);
let check = find_check(&checks, "tmp_files_orphan").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_check_jsonl_duplicate_ids_warns_on_dup() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let jsonl = beads_dir.join("issues.jsonl");
fs::write(
&jsonl,
"{\"id\":\"bd-aaa\",\"title\":\"first\"}\n\
{\"id\":\"bd-bbb\",\"title\":\"unique\"}\n\
{\"id\": \"bd-aaa\", \"title\":\"merge-conflict-side\"}\n",
)
.unwrap();
let mut checks = Vec::new();
check_jsonl_duplicate_ids(Some(&jsonl), &mut checks);
let check = find_check(&checks, "jsonl.duplicate_ids").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
let details = check.details.as_ref().expect("details");
assert_eq!(
details
.get("distinct_duplicate_ids")
.and_then(serde_json::Value::as_u64),
Some(1)
);
assert_eq!(
details
.get("total_duplicate_records")
.and_then(serde_json::Value::as_u64),
Some(2)
);
let sample = details
.get("sample_duplicate_ids")
.and_then(|v| v.as_array())
.expect("sample array");
assert_eq!(sample.len(), 1);
assert_eq!(sample[0].as_str(), Some("bd-aaa"));
}
#[test]
fn test_check_jsonl_duplicate_ids_ok_when_unique() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let jsonl = beads_dir.join("issues.jsonl");
fs::write(
&jsonl,
"{\"id\":\"bd-aaa\"}\n{\"id\":\"bd-bbb\"}\n{\"id\":\"bd-ccc\"}\n",
)
.unwrap();
let mut checks = Vec::new();
check_jsonl_duplicate_ids(Some(&jsonl), &mut checks);
let check = find_check(&checks, "jsonl.duplicate_ids").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_check_jsonl_duplicate_ids_missing_is_ok() {
let mut checks = Vec::new();
check_jsonl_duplicate_ids(None, &mut checks);
let check = find_check(&checks, "jsonl.duplicate_ids").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_check_jsonl_oversized_warns_above_threshold() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let jsonl = beads_dir.join("issues.jsonl");
let f = fs::File::create(&jsonl).unwrap();
f.set_len(JSONL_OVERSIZED_THRESHOLD_BYTES + 1).unwrap();
drop(f);
let mut checks = Vec::new();
check_jsonl_oversized(Some(&jsonl), &mut checks);
let check = find_check(&checks, "jsonl_size").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
let size_bytes = check
.details
.as_ref()
.and_then(|d| d.get("size_bytes"))
.and_then(serde_json::Value::as_u64);
assert_eq!(size_bytes, Some(JSONL_OVERSIZED_THRESHOLD_BYTES + 1));
}
#[test]
fn test_check_jsonl_oversized_ok_below_threshold() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let jsonl = beads_dir.join("issues.jsonl");
fs::write(&jsonl, b"{\"id\":\"bd-tiny\"}\n").unwrap();
let mut checks = Vec::new();
check_jsonl_oversized(Some(&jsonl), &mut checks);
let check = find_check(&checks, "jsonl_size").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_check_br_history_above_threshold_warns() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
let history = beads_dir.join(".br_history");
fs::create_dir_all(&history).unwrap();
for i in 0..(BR_HISTORY_SNAPSHOT_THRESHOLD + 5) {
fs::write(
history.join(format!("issues.20250101_000000.{i}.jsonl")),
b"{}\n",
)
.unwrap();
}
let mut checks = Vec::new();
check_br_history_size(&beads_dir, &mut checks);
let check = find_check(&checks, "br_history.size").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
let count = check
.details
.as_ref()
.and_then(|d| d.get("snapshot_count"))
.and_then(serde_json::Value::as_u64);
assert_eq!(count, Some((BR_HISTORY_SNAPSHOT_THRESHOLD + 5) as u64));
}
#[test]
fn test_check_br_history_below_threshold_ok() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
let history = beads_dir.join(".br_history");
fs::create_dir_all(&history).unwrap();
for i in 0..5 {
fs::write(
history.join(format!("issues.20250101_000000.{i}.jsonl")),
b"{}\n",
)
.unwrap();
}
let mut checks = Vec::new();
check_br_history_size(&beads_dir, &mut checks);
let check = find_check(&checks, "br_history.size").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_check_br_history_metadata_sidecars_do_not_count_as_snapshots() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
let history = beads_dir.join(".br_history");
fs::create_dir_all(&history).unwrap();
for i in 0..BR_HISTORY_SNAPSHOT_THRESHOLD {
let backup = history.join(format!("issues.20250101_000000.{i}.jsonl"));
fs::write(&backup, b"{}\n").unwrap();
fs::write(backup.with_extension("jsonl.meta.json"), b"{not-json").unwrap();
}
let mut checks = Vec::new();
check_br_history_size(&beads_dir, &mut checks);
let check = find_check(&checks, "br_history.size").expect("check present");
assert!(
matches!(check.status, CheckStatus::Ok),
"metadata sidecars must not push threshold backups over the limit: {check:?}"
);
}
#[test]
fn test_check_br_history_missing_is_ok() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let mut checks = Vec::new();
check_br_history_size(&beads_dir, &mut checks);
let check = find_check(&checks, "br_history.size").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_check_jsonl_crlf_endings_warns_on_crlf() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(
beads_dir.join("issues.jsonl"),
b"{\"id\":\"bd-windows\"}\r\n",
)
.unwrap();
let mut checks = Vec::new();
check_jsonl_crlf_endings(Some(&beads_dir.join("issues.jsonl")), &mut checks);
let check = find_check(&checks, "jsonl_crlf").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
}
#[test]
fn test_check_jsonl_crlf_endings_lf_only_is_ok() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(beads_dir.join("issues.jsonl"), b"{\"id\":\"bd-unix\"}\n").unwrap();
let mut checks = Vec::new();
check_jsonl_crlf_endings(Some(&beads_dir.join("issues.jsonl")), &mut checks);
let check = find_check(&checks, "jsonl_crlf").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_check_jsonl_crlf_endings_missing_is_ok() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let mut checks = Vec::new();
check_jsonl_crlf_endings(None, &mut checks);
let check = find_check(&checks, "jsonl_crlf").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_check_jsonl_utf8_bom_warns_when_present() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let mut bom_bytes = UTF8_BOM.to_vec();
bom_bytes.extend_from_slice(b"{\"id\":\"bd-bom\"}\n");
fs::write(beads_dir.join("issues.jsonl"), &bom_bytes).unwrap();
let mut checks = Vec::new();
check_jsonl_utf8_bom(Some(&beads_dir.join("issues.jsonl")), &mut checks);
let check = find_check(&checks, "jsonl_bom").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
}
#[test]
fn test_check_jsonl_utf8_bom_ok_when_clean() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(beads_dir.join("issues.jsonl"), b"{\"id\":\"bd-clean\"}\n").unwrap();
let mut checks = Vec::new();
check_jsonl_utf8_bom(Some(&beads_dir.join("issues.jsonl")), &mut checks);
let check = find_check(&checks, "jsonl_bom").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_fix_jsonl_utf8_bom_strips_via_chokepoint() {
let temp = TempDir::new().unwrap();
let jsonl_dir = temp.path().join("external");
fs::create_dir_all(&jsonl_dir).unwrap();
let jsonl_path = jsonl_dir.join("issues.jsonl");
let payload = b"{\"id\":\"bd-bom\"}\n{\"id\":\"bd-other\"}\n";
let mut bom_bytes = UTF8_BOM.to_vec();
bom_bytes.extend_from_slice(payload);
fs::write(&jsonl_path, &bom_bytes).unwrap();
let mut report = DoctorReport {
ok: false,
workspace_health: Some("degraded".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
check_jsonl_utf8_bom(Some(&jsonl_path), &mut report.checks);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(fix_jsonl_utf8_bom_if_warned(
Some(&jsonl_path),
&report,
&ctx,
Some(&mut session),
));
let after = fs::read(&jsonl_path).unwrap();
assert_eq!(after, payload, "BOM stripped, payload preserved");
assert!(
session
.run
.root
.join("backups/external/issues.jsonl")
.is_file(),
"selected in-workspace JSONL should be backed up relative to repo root"
);
}
#[test]
fn test_fix_jsonl_utf8_bom_skips_traversal_outside_workspace() {
let parent = TempDir::new().unwrap();
let repo = parent.path().join("repo");
let outside = parent.path().join("outside");
fs::create_dir_all(&repo).unwrap();
fs::create_dir_all(&outside).unwrap();
let outside_jsonl = outside.join("issues.jsonl");
let mut original = UTF8_BOM.to_vec();
original.extend_from_slice(b"{\"id\":\"bd-outside\"}\n");
fs::write(&outside_jsonl, &original).unwrap();
let traversal_path = repo.join("../outside/issues.jsonl");
let mut report = DoctorReport {
ok: false,
workspace_health: Some("degraded".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
check_jsonl_utf8_bom(Some(&traversal_path), &mut report.checks);
let mut session = DoctorRepairSession::new(&repo, false).expect("session");
assert!(!fix_jsonl_utf8_bom_if_warned(
Some(&traversal_path),
&report,
&quiet_ctx(),
Some(&mut session),
));
assert_eq!(
fs::read(&outside_jsonl).unwrap(),
original,
"traversal-selected outside JSONL must not be rewritten"
);
assert_eq!(
fs::read_to_string(&session.run.actions_file).unwrap(),
"",
"skipped traversal repairs must not write an undo action"
);
}
#[test]
fn test_check_db_bloat_warns_on_high_ratio() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let jsonl_path = beads_dir.join("issues.jsonl");
let jsonl_f = fs::File::create(&jsonl_path).unwrap();
jsonl_f.set_len(DB_BLOAT_MIN_JSONL_BYTES + 1024).unwrap();
drop(jsonl_f);
let db_path = beads_dir.join("beads.db");
let db_f = fs::File::create(&db_path).unwrap();
db_f.set_len((DB_BLOAT_MIN_JSONL_BYTES + 1024) * 20)
.unwrap();
drop(db_f);
let mut checks = Vec::new();
check_db_bloat_vs_jsonl(&db_path, Some(&jsonl_path), &mut checks);
let check = find_check(&checks, "db_bloat").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
let ratio = check
.details
.as_ref()
.and_then(|d| d.get("ratio"))
.and_then(serde_json::Value::as_u64);
assert_eq!(ratio, Some(20));
}
#[test]
fn test_foreign_recovery_debris_is_reported_without_failing_the_workspace() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
fs::write(&db_path, b"not a real db, only the name matters here").unwrap();
fs::create_dir_all(beads_dir.join(".br_recovery")).unwrap();
fs::create_dir_all(beads_dir.join(".br_history")).unwrap();
for dir in [
"recovery",
"recovery_20260319T032504Z",
"recovery_snapshot_20260322T032047Z",
"snapshot_20260322T032111Z",
".beads_snapshot",
] {
fs::create_dir_all(beads_dir.join(dir)).unwrap();
}
fs::write(
beads_dir.join("recovery_20260319T032504Z/payload.bin"),
vec![7_u8; 2048],
)
.unwrap();
fs::write(
beads_dir.join("beads.db.rebuild_20260321T073015Z"),
vec![1_u8; 1024],
)
.unwrap();
let mut checks = Vec::new();
check_foreign_recovery_debris(&beads_dir, &db_path, &mut checks).unwrap();
let check = find_check(&checks, "db.foreign_recovery_debris").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok), "{check:?}");
let details = check.details.as_ref().expect("details");
let dirs: Vec<&str> = details["directories"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(
dirs.len(),
5,
"all five spellings should be caught: {dirs:?}"
);
assert!(
!dirs
.iter()
.any(|d| d.contains(".br_recovery") || d.contains(".br_history")),
"br's own directories must not be reported as foreign: {dirs:?}"
);
assert_eq!(details["files"].as_array().unwrap().len(), 1);
assert_eq!(details["bytes"].as_u64(), Some(3072));
assert_eq!(details["bytes_are_lower_bound"].as_bool(), Some(false));
}
#[test]
fn test_clean_workspace_reports_no_foreign_recovery_debris() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(beads_dir.join(".br_recovery")).unwrap();
fs::create_dir_all(beads_dir.join(".br_history")).unwrap();
let db_path = beads_dir.join("beads.db");
fs::write(&db_path, b"db").unwrap();
fs::write(beads_dir.join("issues.jsonl"), b"").unwrap();
fs::write(beads_dir.join("beads.db.bad_20260312T000000Z"), b"x").unwrap();
let mut checks = Vec::new();
check_foreign_recovery_debris(&beads_dir, &db_path, &mut checks).unwrap();
let check = find_check(&checks, "db.foreign_recovery_debris").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
assert!(check.message.is_none(), "{check:?}");
if let Some(details) = check.details.as_ref() {
assert!(details.get("directories").is_none(), "{check:?}");
assert!(details.get("files").is_none(), "{check:?}");
assert!(details.get("bytes").is_none(), "{check:?}");
}
}
#[test]
fn test_foreign_debris_name_predicates() {
assert!(is_foreign_recovery_debris_dir_name("recovery"));
assert!(is_foreign_recovery_debris_dir_name(
"recovery_20260319T032504Z"
));
assert!(is_foreign_recovery_debris_dir_name(
"recovery_20260322T032013Z_codex_beads_repair"
));
assert!(is_foreign_recovery_debris_dir_name(
"snapshot_20260322T032111Z"
));
assert!(is_foreign_recovery_debris_dir_name(".beads_snapshot"));
assert!(!is_foreign_recovery_debris_dir_name(".br_recovery"));
assert!(!is_foreign_recovery_debris_dir_name(".br_history"));
assert!(!is_foreign_recovery_debris_dir_name("issues.jsonl"));
assert!(!is_foreign_recovery_debris_dir_name(".doctor"));
assert!(is_foreign_recovery_debris_file_name(
"beads.db.rebuild_20260321T073015Z",
"beads.db"
));
assert!(!is_foreign_recovery_debris_file_name(
"beads.db.bad_20260312T000000Z",
"beads.db"
));
assert!(!is_foreign_recovery_debris_file_name(
"beads.db", "beads.db"
));
}
#[test]
fn test_vacuum_database_compacts_a_database_with_trailing_pages() {
const APPENDED_PAGES: u32 = 64;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
{
let mut storage = SqliteStorage::open(&db_path).unwrap();
for i in 0..50 {
let mut issue = sample_issue(&format!("bd-bloat{i:03}"), "bloat fixture issue");
issue.description = Some("x".repeat(4096));
storage.create_issue(&issue, "test").unwrap();
}
storage.checkpoint_full().ok();
}
let header_pages_before = database_header_page_count(&db_path);
let logical_size = fs::metadata(&db_path).unwrap().len();
{
let mut f = fs::OpenOptions::new().append(true).open(&db_path).unwrap();
f.write_all(&vec![0_u8; 4096 * APPENDED_PAGES as usize])
.unwrap();
f.flush().unwrap();
}
let bloated_size = fs::metadata(&db_path).unwrap().len();
assert!(
bloated_size > logical_size,
"fixture should have grown: {bloated_size} vs {logical_size}"
);
let write_authority = std::sync::Arc::new(
crate::sync::blocking_database_family_write_lock_with_timeout(
&beads_dir, &db_path, None,
)
.expect("acquire database-family write authority for vacuum test"),
);
vacuum_database(&db_path, &write_authority)
.expect("vacuum must compact a trailing-pages database");
let after = fs::metadata(&db_path).unwrap().len();
assert!(
after < bloated_size,
"VACUUM should reclaim the appended slack: {after} still >= {bloated_size}"
);
let header_pages_after = database_header_page_count(&db_path);
assert!(
header_pages_after < header_pages_before + APPENDED_PAGES,
"compaction absorbed the appended pages: {header_pages_after} \
(was {header_pages_before}, appended {APPENDED_PAGES})"
);
assert_eq!(
u64::from(header_pages_after) * 4096,
after,
"compacted file should contain exactly its logical pages"
);
let storage = SqliteStorage::open(&db_path).unwrap();
assert!(storage.get_issue("bd-bloat000").unwrap().is_some());
assert!(storage.get_issue("bd-bloat049").unwrap().is_some());
}
fn database_header_page_count(db_path: &Path) -> u32 {
let bytes = fs::read(db_path).unwrap();
u32::from_be_bytes([bytes[28], bytes[29], bytes[30], bytes[31]])
}
#[test]
fn test_check_db_bloat_ok_under_threshold() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let jsonl_path = beads_dir.join("issues.jsonl");
let jsonl_f = fs::File::create(&jsonl_path).unwrap();
jsonl_f.set_len(DB_BLOAT_MIN_JSONL_BYTES + 1024).unwrap();
drop(jsonl_f);
let db_path = beads_dir.join("beads.db");
let db_f = fs::File::create(&db_path).unwrap();
db_f.set_len((DB_BLOAT_MIN_JSONL_BYTES + 1024) * 2).unwrap();
drop(db_f);
let mut checks = Vec::new();
check_db_bloat_vs_jsonl(&db_path, Some(&jsonl_path), &mut checks);
let check = find_check(&checks, "db_bloat").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_check_db_bloat_skip_small_workspaces() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let jsonl_path = beads_dir.join("issues.jsonl");
fs::write(&jsonl_path, b"tiny").unwrap();
let db_path = beads_dir.join("beads.db");
let db_f = fs::File::create(&db_path).unwrap();
db_f.set_len(100 * 1024 * 1024).unwrap();
drop(db_f);
let mut checks = Vec::new();
check_db_bloat_vs_jsonl(&db_path, Some(&jsonl_path), &mut checks);
let check = find_check(&checks, "db_bloat").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_check_db_bloat_uses_selected_db_and_jsonl_paths() {
let temp = TempDir::new().unwrap();
let db_dir = temp.path().join("db");
let jsonl_dir = temp.path().join("external");
fs::create_dir_all(&db_dir).unwrap();
fs::create_dir_all(&jsonl_dir).unwrap();
let db_path = db_dir.join("custom.sqlite");
let jsonl_path = jsonl_dir.join("issues.jsonl");
let jsonl_f = fs::File::create(&jsonl_path).unwrap();
jsonl_f.set_len(DB_BLOAT_MIN_JSONL_BYTES + 1024).unwrap();
drop(jsonl_f);
let db_f = fs::File::create(&db_path).unwrap();
db_f.set_len((DB_BLOAT_MIN_JSONL_BYTES + 1024) * 20)
.unwrap();
drop(db_f);
let mut checks = Vec::new();
check_db_bloat_vs_jsonl(&db_path, Some(&jsonl_path), &mut checks);
let check = find_check(&checks, "db_bloat").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
assert_eq!(
check
.details
.as_ref()
.and_then(|details| details.get("db_path"))
.and_then(serde_json::Value::as_str),
Some(db_path.to_string_lossy().as_ref())
);
assert_eq!(
check
.details
.as_ref()
.and_then(|details| details.get("jsonl_path"))
.and_then(serde_json::Value::as_str),
Some(jsonl_path.to_string_lossy().as_ref())
);
}
#[test]
fn test_fix_inner_gitignore_creates_when_missing() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let mut report = DoctorReport {
ok: false,
workspace_health: Some("degraded".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
check_inner_gitignore_present(&beads_dir, &mut report.checks);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(fix_inner_gitignore_if_warned(
&beads_dir,
&report,
&ctx,
Some(&mut session),
));
let after = fs::read_to_string(beads_dir.join(".gitignore")).unwrap();
assert!(
after.lines().any(|l| l.trim() == ".write.lock"),
"{after:?}"
);
assert!(after.lines().any(|l| l.trim() == "*.tmp"), "{after:?}");
}
#[test]
fn test_fix_inner_gitignore_appends_when_incomplete() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(
beads_dir.join(".gitignore"),
"# operator custom\n.write.lock\nlocal-cache/\n",
)
.unwrap();
let mut report = DoctorReport {
ok: false,
workspace_health: Some("degraded".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
check_inner_gitignore_present(&beads_dir, &mut report.checks);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(fix_inner_gitignore_if_warned(
&beads_dir,
&report,
&ctx,
Some(&mut session),
));
let after = fs::read_to_string(beads_dir.join(".gitignore")).unwrap();
assert!(after.contains("# operator custom"), "{after:?}");
assert!(after.contains("local-cache/"), "{after:?}");
assert!(
after.lines().any(|l| l.trim() == ".write.lock"),
"{after:?}"
);
assert!(after.lines().any(|l| l.trim() == "*.tmp"), "{after:?}");
}
#[test]
fn test_fix_inner_gitignore_refuses_symlink() {
#[cfg(unix)]
{
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let external = temp.path().join("external.gitignore");
fs::write(&external, ".write.lock\n").unwrap();
std::os::unix::fs::symlink(&external, beads_dir.join(".gitignore")).unwrap();
let mut report = DoctorReport {
ok: false,
workspace_health: Some("degraded".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
check_inner_gitignore_present(&beads_dir, &mut report.checks);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx =
OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(!fix_inner_gitignore_if_warned(
&beads_dir,
&report,
&ctx,
Some(&mut session),
));
let after_meta = fs::symlink_metadata(beads_dir.join(".gitignore")).unwrap();
assert!(after_meta.file_type().is_symlink());
}
}
#[cfg(unix)]
#[test]
fn test_db_sidecar_mode_check_flags_and_repairs_over_permissive_sidecar() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
fs::write(&db_path, b"SQLite format 3\0").unwrap();
fs::set_permissions(&db_path, fs::Permissions::from_mode(0o600)).unwrap();
let gate = beads_dir.join("beads.db-fsqlite-ns-gate");
let use_file = beads_dir.join("beads.db-fsqlite-ns-use");
fs::write(&gate, b"FSQLNS01").unwrap();
fs::write(&use_file, b"FSQLNS01").unwrap();
fs::set_permissions(&gate, fs::Permissions::from_mode(0o664)).unwrap();
fs::set_permissions(&use_file, fs::Permissions::from_mode(0o600)).unwrap();
let mut report = DoctorReport {
ok: false,
workspace_health: Some("degraded".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
check_db_sidecar_modes(&db_path, &mut report.checks);
let check = find_check(&report.checks, "permissions.db_sidecars").expect("check present");
assert_eq!(check.status, CheckStatus::Warn);
let message = check.message.clone().unwrap_or_default();
assert!(
message.contains("beads.db-fsqlite-ns-gate") && message.contains("0664"),
"message must name the sidecar and its mode: {message}"
);
assert!(
!message.contains("beads.db-fsqlite-ns-use"),
"the owner-only sidecar must not be flagged: {message}"
);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(fix_db_sidecar_modes_if_warned(
&db_path,
&report,
&ctx,
Some(&mut session),
));
let repaired = fs::metadata(&gate).unwrap().permissions().mode() & 0o777;
assert_eq!(repaired & 0o077, 0, "group/other bits must be cleared");
assert_eq!(repaired, 0o600, "owner bits preserved, group/other removed");
let mut after = Vec::new();
check_db_sidecar_modes(&db_path, &mut after);
assert_eq!(
find_check(&after, "permissions.db_sidecars")
.expect("check present")
.status,
CheckStatus::Ok
);
assert!(!fix_db_sidecar_modes_if_warned(
&db_path,
&report,
&ctx,
Some(&mut session),
));
}
#[cfg(unix)]
#[test]
fn test_fix_config_yaml_secret_mode_chmods_via_chokepoint() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let config = beads_dir.join("config.yaml");
fs::write(&config, b"github_token: abc123\nfoo: bar\n").unwrap();
fs::set_permissions(&config, fs::Permissions::from_mode(0o666)).unwrap();
let before = fs::metadata(&config).unwrap().permissions().mode() & 0o777;
let mut report = DoctorReport {
ok: false,
workspace_health: Some("degraded".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
check_config_yaml_secret_mode(&beads_dir, &mut report.checks);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(fix_config_yaml_secret_mode_if_warned(
&beads_dir,
&report,
&ctx,
Some(&mut session),
));
let after = fs::metadata(&config).unwrap().permissions().mode() & 0o777;
assert_eq!(
after & 0o006,
0,
"world-read/write bits must be cleared (mode {after:o})"
);
assert_eq!(after, before & !0o006, "only world bits removed");
}
#[cfg(unix)]
#[test]
fn test_fix_config_yaml_secret_mode_noop_when_no_warning() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let config = beads_dir.join("config.yaml");
fs::write(&config, b"foo: bar\n").unwrap();
fs::set_permissions(&config, fs::Permissions::from_mode(0o644)).unwrap();
let mut report = DoctorReport {
ok: true,
workspace_health: Some("healthy".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
check_config_yaml_secret_mode(&beads_dir, &mut report.checks);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(!fix_config_yaml_secret_mode_if_warned(
&beads_dir,
&report,
&ctx,
Some(&mut session),
));
let after = fs::metadata(&config).unwrap().permissions().mode() & 0o777;
assert_eq!(after, 0o644, "mode untouched when no warning");
}
#[cfg(unix)]
#[test]
fn test_fix_jsonl_world_writable_chmods_via_chokepoint() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let jsonl = beads_dir.join("issues.jsonl");
fs::write(&jsonl, b"{\"id\":\"bd-1\"}\n").unwrap();
fs::set_permissions(&jsonl, fs::Permissions::from_mode(0o666)).unwrap();
let before = fs::metadata(&jsonl).unwrap().permissions().mode() & 0o777;
assert_ne!(before & 0o002, 0, "precondition: world-write bit set");
let mut report = DoctorReport {
ok: false,
workspace_health: Some("degraded".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
check_jsonl_world_writable(Some(&jsonl), &mut report.checks);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(fix_jsonl_world_writable_if_warned(
Some(&jsonl),
&report,
&ctx,
Some(&mut session),
));
let after = fs::metadata(&jsonl).unwrap().permissions().mode() & 0o777;
assert_eq!(
after & 0o002,
0,
"world-write bit must be cleared (mode {after:o})"
);
assert_eq!(after, before & !0o002, "only world-write bit removed");
}
#[cfg(unix)]
#[test]
fn test_fix_jsonl_world_writable_allows_selected_in_workspace_jsonl() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let jsonl_dir = temp.path().join("external");
fs::create_dir_all(&jsonl_dir).unwrap();
let jsonl = jsonl_dir.join("issues.jsonl");
fs::write(&jsonl, b"{\"id\":\"bd-external\"}\n").unwrap();
fs::set_permissions(&jsonl, fs::Permissions::from_mode(0o666)).unwrap();
let mut report = DoctorReport {
ok: false,
workspace_health: Some("degraded".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
check_jsonl_world_writable(Some(&jsonl), &mut report.checks);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
assert!(fix_jsonl_world_writable_if_warned(
Some(&jsonl),
&report,
&quiet_ctx(),
Some(&mut session),
));
let after = fs::metadata(&jsonl).unwrap().permissions().mode() & 0o777;
assert_eq!(after, 0o664, "only the world-write bit is stripped");
}
#[cfg(unix)]
#[test]
fn test_fix_jsonl_world_writable_noop_when_clean() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let jsonl = beads_dir.join("issues.jsonl");
fs::write(&jsonl, b"{\"id\":\"bd-clean\"}\n").unwrap();
fs::set_permissions(&jsonl, fs::Permissions::from_mode(0o644)).unwrap();
let mut report = DoctorReport {
ok: true,
workspace_health: Some("healthy".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
check_jsonl_world_writable(Some(&jsonl), &mut report.checks);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(!fix_jsonl_world_writable_if_warned(
Some(&jsonl),
&report,
&ctx,
Some(&mut session),
));
let after = fs::metadata(&jsonl).unwrap().permissions().mode() & 0o777;
assert_eq!(after, 0o644, "mode untouched when no warning");
}
#[test]
fn test_fix_jsonl_crlf_converts_via_chokepoint() {
let temp = TempDir::new().unwrap();
let jsonl_dir = temp.path().join("external");
fs::create_dir_all(&jsonl_dir).unwrap();
let jsonl_path = jsonl_dir.join("issues.jsonl");
let mixed = b"{\"id\":\"bd-1\"}\r\n{\"id\":\"bd-2\"}\r\nfinal-no-eol";
fs::write(&jsonl_path, mixed).unwrap();
let mut report = DoctorReport {
ok: false,
workspace_health: Some("degraded".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
check_jsonl_crlf_endings(Some(&jsonl_path), &mut report.checks);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(fix_jsonl_crlf_endings_if_warned(
Some(&jsonl_path),
&report,
&ctx,
Some(&mut session),
));
let after = fs::read(&jsonl_path).unwrap();
assert_eq!(
after, b"{\"id\":\"bd-1\"}\n{\"id\":\"bd-2\"}\nfinal-no-eol",
"CRLF converted to LF; payload otherwise preserved"
);
assert!(
session
.run
.root
.join("backups/external/issues.jsonl")
.is_file(),
"selected in-workspace JSONL should be backed up relative to repo root"
);
}
#[test]
fn test_fix_jsonl_crlf_noop_when_clean() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let clean = b"{\"id\":\"bd-clean\"}\n";
fs::write(beads_dir.join("issues.jsonl"), clean).unwrap();
let mut report = DoctorReport {
ok: true,
workspace_health: Some("healthy".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
let jsonl_path = beads_dir.join("issues.jsonl");
check_jsonl_crlf_endings(Some(&jsonl_path), &mut report.checks);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(!fix_jsonl_crlf_endings_if_warned(
Some(&jsonl_path),
&report,
&ctx,
Some(&mut session),
));
let after = fs::read(beads_dir.join("issues.jsonl")).unwrap();
assert_eq!(after, clean, "file untouched when no warning");
}
#[test]
fn test_fix_jsonl_crlf_skips_traversal_outside_workspace() {
let parent = TempDir::new().unwrap();
let repo = parent.path().join("repo");
let outside = parent.path().join("outside");
fs::create_dir_all(&repo).unwrap();
fs::create_dir_all(&outside).unwrap();
let outside_jsonl = outside.join("issues.jsonl");
let original = b"{\"id\":\"bd-outside\"}\r\n";
fs::write(&outside_jsonl, original).unwrap();
let traversal_path = repo.join("../outside/issues.jsonl");
let report = DoctorReport {
ok: false,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "jsonl_crlf".to_string(),
status: CheckStatus::Warn,
message: None,
details: None,
}],
};
let mut session = DoctorRepairSession::new(&repo, false).expect("session");
assert!(!fix_jsonl_crlf_endings_if_warned(
Some(&traversal_path),
&report,
&quiet_ctx(),
Some(&mut session),
));
assert_eq!(
fs::read(&outside_jsonl).unwrap(),
original,
"traversal-selected outside JSONL must not be rewritten"
);
assert_eq!(
fs::read_to_string(&session.run.actions_file).unwrap(),
"",
"skipped traversal repairs must not write an undo action"
);
}
#[test]
fn test_fix_wal_oversized_truncates_valid_wal_via_chokepoint() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let wal_writer = create_valid_oversized_wal(&db_path);
wal_writer.close().unwrap();
let mut report = DoctorReport {
ok: false,
workspace_health: Some("degraded".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
check_wal_oversized(&db_path, &mut report.checks);
assert!(
report
.checks
.iter()
.any(|c| c.name == "wal_size" && matches!(c.status, CheckStatus::Warn))
);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(fix_wal_oversized_if_warned(
&db_path,
&report,
&ctx,
Some(&mut session),
));
let mut after = Vec::new();
check_wal_oversized(&db_path, &mut after);
let check = find_check(&after, "wal_size").expect("check present");
assert!(
matches!(check.status, CheckStatus::Ok),
"WAL must be at-or-below threshold after PRAGMA wal_checkpoint(TRUNCATE): {check:?}"
);
}
#[test]
fn test_truncate_oversized_regular_wal_if_needed_shrinks_padded_sidecar() {
let temp = TempDir::new().unwrap();
let wal_path = temp.path().join("beads.db-wal");
fs::File::create(&wal_path)
.unwrap()
.set_len(WAL_OVERSIZED_BYTES + 1)
.unwrap();
truncate_oversized_regular_wal_if_needed(&wal_path).unwrap();
assert!(
fs::metadata(&wal_path).map_or(0, |meta| meta.len()) <= WAL_OVERSIZED_BYTES,
"complete checkpoint fallback must shrink padded WAL sidecars"
);
}
fn create_valid_oversized_wal(db_path: &Path) -> Connection {
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
conn.execute("PRAGMA journal_mode = WAL").unwrap();
conn.execute("PRAGMA wal_autocheckpoint = 0").unwrap();
conn.execute("CREATE TABLE wal_growth (id INTEGER PRIMARY KEY, payload TEXT)")
.unwrap();
let payload = "x".repeat(1024 * 1024);
let wal_path = sqlite_wal_sidecar_path(db_path);
for _ in 0..(WAL_OVERSIZED_BYTES / (1024 * 1024) + 4) {
conn.execute_with_params(
"INSERT INTO wal_growth(payload) VALUES (?1)",
&[SqliteValue::Text(payload.as_str().into())],
)
.unwrap();
if fs::metadata(&wal_path).map_or(0, |meta| meta.len()) > WAL_OVERSIZED_BYTES {
break;
}
}
let wal_size = fs::metadata(&wal_path).map_or(0, |meta| meta.len());
assert!(
wal_size > WAL_OVERSIZED_BYTES,
"test setup must create a valid oversized WAL, got {wal_size} bytes"
);
conn
}
#[test]
fn test_fix_wal_oversized_noop_when_no_warning() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let _storage = SqliteStorage::open(&db_path).unwrap();
let mut report = DoctorReport {
ok: true,
workspace_health: Some("healthy".to_string()),
reliability_audit: None,
checks: Vec::new(),
};
check_wal_oversized(&db_path, &mut report.checks);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Text, false, true);
assert!(!fix_wal_oversized_if_warned(
&db_path,
&report,
&ctx,
Some(&mut session),
));
}
#[test]
fn test_check_wal_oversized_warns_on_oversized() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let wal = fs::File::create(beads_dir.join("beads.db-wal")).unwrap();
wal.set_len(WAL_OVERSIZED_BYTES + 1).unwrap();
drop(wal);
let mut checks = Vec::new();
check_wal_oversized(&beads_dir.join("beads.db"), &mut checks);
let check = find_check(&checks, "wal_size").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
let details = check.details.as_ref().expect("details");
assert_eq!(
details
.get("threshold_bytes")
.and_then(serde_json::Value::as_u64),
Some(WAL_OVERSIZED_BYTES)
);
}
#[test]
fn test_check_wal_oversized_ok_on_normal_size() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let wal = fs::File::create(beads_dir.join("beads.db-wal")).unwrap();
wal.set_len(4 * 1024 * 1024).unwrap();
drop(wal);
let mut checks = Vec::new();
check_wal_oversized(&beads_dir.join("beads.db"), &mut checks);
let check = find_check(&checks, "wal_size").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_check_wal_oversized_missing_is_ok() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let mut checks = Vec::new();
check_wal_oversized(&beads_dir.join("beads.db"), &mut checks);
let check = find_check(&checks, "wal_size").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_sqlite_wal_sidecar_path_preserves_non_utf8_bytes() {
use std::ffi::OsString;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let db_path = PathBuf::from(OsString::from_vec(b"/tmp/br-\xFF.sqlite".to_vec()));
let wal_path = sqlite_wal_sidecar_path(&db_path);
assert_eq!(wal_path.as_os_str().as_bytes(), b"/tmp/br-\xFF.sqlite-wal");
}
#[test]
fn test_check_wal_oversized_uses_selected_db_sidecar_path() {
let temp = TempDir::new().unwrap();
let db_dir = temp.path().join("db");
fs::create_dir_all(&db_dir).unwrap();
let db_path = db_dir.join("custom.sqlite");
let wal_path = sqlite_wal_sidecar_path(&db_path);
let wal = fs::File::create(&wal_path).unwrap();
wal.set_len(WAL_OVERSIZED_BYTES + 1).unwrap();
drop(wal);
let mut checks = Vec::new();
check_wal_oversized(&db_path, &mut checks);
let check = find_check(&checks, "wal_size").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
assert_eq!(
check
.details
.as_ref()
.and_then(|details| details.get("path"))
.and_then(serde_json::Value::as_str),
Some(wal_path.to_string_lossy().as_ref())
);
}
#[test]
fn test_check_jsonl_oversized_missing_is_ok() {
let mut checks = Vec::new();
check_jsonl_oversized(None, &mut checks);
let check = find_check(&checks, "jsonl_size").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn test_check_jsonl_oversized_uses_selected_external_path() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
let external_dir = temp.path().join("external");
fs::create_dir_all(&beads_dir).unwrap();
fs::create_dir_all(&external_dir).unwrap();
fs::write(beads_dir.join("issues.jsonl"), b"{\"id\":\"bd-small\"}\n").unwrap();
let external_jsonl = external_dir.join("issues.jsonl");
let f = fs::File::create(&external_jsonl).unwrap();
f.set_len(JSONL_OVERSIZED_THRESHOLD_BYTES + 1).unwrap();
drop(f);
let mut checks = Vec::new();
check_jsonl_oversized(Some(&external_jsonl), &mut checks);
let check = find_check(&checks, "jsonl_size").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
assert_eq!(
check
.details
.as_ref()
.and_then(|d| d.get("path"))
.and_then(serde_json::Value::as_str),
Some(external_jsonl.to_string_lossy().as_ref())
);
}
#[cfg(unix)]
#[test]
fn test_check_orphan_tmp_files_ignores_symlinked_tmp_names() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let target = temp.path().join("outside-target");
fs::write(&target, b"old outside target").unwrap();
let two_hours_ago =
std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 60 * 60);
let f = std::fs::OpenOptions::new()
.write(true)
.open(&target)
.unwrap();
f.set_times(std::fs::FileTimes::new().set_modified(two_hours_ago))
.unwrap();
drop(f);
symlink(&target, beads_dir.join("issues.jsonl.99999.tmp")).unwrap();
let mut checks = Vec::new();
check_orphan_tmp_files(&beads_dir, &mut checks);
let check = find_check(&checks, "tmp_files_orphan").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok), "{check:?}");
}
#[test]
fn test_check_orphan_tmp_files_reports_files_sorted() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let two_hours_ago =
std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 60 * 60);
for name in ["z.tmp", "a.tmp"] {
let tmp_path = beads_dir.join(name);
fs::write(&tmp_path, b"partial write").unwrap();
let f = std::fs::OpenOptions::new()
.write(true)
.open(&tmp_path)
.unwrap();
f.set_times(std::fs::FileTimes::new().set_modified(two_hours_ago))
.unwrap();
}
let mut checks = Vec::new();
check_orphan_tmp_files(&beads_dir, &mut checks);
let check = find_check(&checks, "tmp_files_orphan").expect("check present");
let files: Vec<&str> = check
.details
.as_ref()
.and_then(|d| d.get("files"))
.and_then(|v| v.as_array())
.expect("files array")
.iter()
.map(|v| v.as_str().expect("file name string"))
.collect();
assert_eq!(files, ["a.tmp", "z.tmp"]);
}
#[test]
fn test_fix_orphan_tmp_files_quarantines_old_regular_files_only() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let old_tmp = beads_dir.join("issues.jsonl.99999.tmp");
let fresh_tmp = beads_dir.join("issues.jsonl.12345.tmp");
fs::write(&old_tmp, b"old partial write").unwrap();
fs::write(&fresh_tmp, b"fresh partial write").unwrap();
backdate_file_two_hours(&old_tmp);
let report = DoctorReport {
ok: false,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "tmp_files_orphan".to_string(),
status: CheckStatus::Warn,
message: None,
details: None,
}],
};
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
assert!(fix_orphan_tmp_files_if_warned(
&beads_dir,
&report,
&quiet_ctx(),
Some(&mut session),
));
assert!(!old_tmp.exists(), "old tmp must be moved into quarantine");
assert!(fresh_tmp.is_file(), "fresh tmp must be preserved in place");
assert!(
session
.run
.root
.join("quarantine/.beads/issues.jsonl.99999.tmp")
.is_file()
);
let actions_before = fs::read_to_string(&session.run.actions_file).unwrap();
assert_eq!(actions_before.matches("\"op\":\"rename\"").count(), 1);
assert!(!fix_orphan_tmp_files_if_warned(
&beads_dir,
&report,
&quiet_ctx(),
Some(&mut session),
));
let actions_after = fs::read_to_string(&session.run.actions_file).unwrap();
assert_eq!(actions_after, actions_before, "second pass must be a no-op");
}
#[test]
fn test_fix_orphan_tmp_files_ignores_symlinked_tmp_names() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let real_orphan = beads_dir.join("real.tmp");
fs::write(&real_orphan, b"old regular tmp").unwrap();
backdate_file_two_hours(&real_orphan);
let symlink_target = beads_dir.join("target-data");
let symlink_path = beads_dir.join("linked.tmp");
fs::write(&symlink_target, b"old target behind symlink").unwrap();
backdate_file_two_hours(&symlink_target);
symlink(&symlink_target, &symlink_path).unwrap();
let report = DoctorReport {
ok: false,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "tmp_files_orphan".to_string(),
status: CheckStatus::Warn,
message: None,
details: None,
}],
};
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
assert!(fix_orphan_tmp_files_if_warned(
&beads_dir,
&report,
&quiet_ctx(),
Some(&mut session),
));
assert!(
!real_orphan.exists(),
"regular orphan should be quarantined"
);
assert!(
fs::symlink_metadata(&symlink_path)
.unwrap()
.file_type()
.is_symlink(),
"symlink-shaped tmp names are not detector-owned and must stay in place"
);
assert!(
symlink_target.is_file(),
"the symlink target must not be modified"
);
assert!(
!session
.run
.root
.join("quarantine/.beads/linked.tmp")
.exists(),
"repair must not quarantine symlink-shaped tmp names"
);
let actions = fs::read_to_string(&session.run.actions_file).unwrap();
assert_eq!(actions.matches("\"op\":\"rename\"").count(), 1);
}
#[test]
fn test_fixer_filter_empty_allows_everything() {
let filter = FixerFilter::default();
assert!(filter.allows("fm-anything"));
assert!(filter.allows("fm-state_files-merge-artifact-stuck"));
assert!(!filter.has_only());
assert!(!filter.has_skip());
}
#[test]
fn test_fixer_filter_only_allowlist() {
let filter = FixerFilter::from_args(&["fm-a".to_string(), "fm-b".to_string()], &[]);
assert!(filter.allows("fm-a"));
assert!(filter.allows("fm-b"));
assert!(!filter.allows("fm-c"));
assert!(filter.has_only());
}
#[test]
fn test_fixer_filter_skip_blocklist() {
let filter = FixerFilter::from_args(&[], &["fm-blocked".to_string()]);
assert!(filter.allows("fm-other"));
assert!(!filter.allows("fm-blocked"));
assert!(filter.has_skip());
}
#[test]
fn test_fixer_filter_skip_overrides_only() {
let filter = FixerFilter::from_args(&["fm-a".to_string()], &["fm-a".to_string()]);
assert!(!filter.allows("fm-a"));
}
#[test]
fn test_fixer_filter_trims_whitespace_and_drops_empties() {
let filter = FixerFilter::from_args(
&[" fm-a ".to_string(), String::new(), " fm-b".to_string()],
&[],
);
assert!(filter.allows("fm-a"));
assert!(filter.allows("fm-b"));
assert!(!filter.allows("fm-c"));
}
mod filter_props {
use super::*;
use proptest::prelude::*;
fn id_strategy() -> impl Strategy<Value = String> {
proptest::sample::select(vec!["fm-a", "fm-b", "fm-c", "fm-d", "fm-e"])
.prop_map(ToString::to_string)
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(128))]
#[test]
fn prop_filter_matches_set_semantics(
only in proptest::collection::vec(id_strategy(), 0..5),
skip in proptest::collection::vec(id_strategy(), 0..5),
query in id_strategy(),
) {
let filter = FixerFilter::from_args(&only, &skip);
let only_set: std::collections::HashSet<&str> =
only.iter().map(String::as_str).collect();
let skip_set: std::collections::HashSet<&str> =
skip.iter().map(String::as_str).collect();
let expected = (only_set.is_empty() || only_set.contains(query.as_str()))
&& !skip_set.contains(query.as_str());
prop_assert_eq!(filter.allows(&query), expected);
}
#[test]
fn prop_filter_skip_always_overrides_only(query in id_strategy()) {
let filter = FixerFilter::from_args(
std::slice::from_ref(&query),
std::slice::from_ref(&query),
);
prop_assert!(!filter.allows(&query));
}
#[test]
fn prop_filter_normalizes_whitespace(base in id_strategy()) {
let clean = FixerFilter::from_args(std::slice::from_ref(&base), &[]);
let messy = FixerFilter::from_args(
&[String::new(), format!(" {base} "), " ".to_string()],
&[],
);
prop_assert!(messy.allows(&base));
prop_assert_eq!(clean.allows(&base), messy.allows(&base));
let other = if base.as_str() == "fm-a" { "fm-b" } else { "fm-a" };
prop_assert_eq!(clean.allows(other), messy.allows(other));
}
}
}
#[test]
fn every_fixer_filter_gate_id_is_advertised_in_capabilities() {
use std::collections::{HashMap, HashSet};
let caps =
crate::cli::commands::doctor_subsystems::capabilities_doctor::DoctorCapabilities::build(
);
let advertised: HashSet<&str> = caps
.fixers
.iter()
.flat_map(|fixer| fixer.filter_ids.iter().map(String::as_str))
.collect();
let source = include_str!("doctor.rs");
let runtime = source
.split("#[cfg(all(test, unix))]")
.next()
.expect("split never yields zero items");
let mut const_values: HashMap<String, &str> = HashMap::new();
for line in runtime.lines() {
let trimmed = line.trim_start();
let Some(rest) = trimmed.strip_prefix("const FM_") else {
continue;
};
let Some((name, value_rest)) = rest.split_once(':') else {
continue;
};
let mut quoted = value_rest.split('"');
let _ = quoted.next();
if let Some(value) = quoted.next() {
const_values.insert(format!("FM_{}", name.trim()), value);
}
}
let mut gate_ids: HashSet<String> = HashSet::new();
for line in runtime.lines() {
let mut rest = line;
while let Some(pos) = rest.find("allows(") {
let after = &rest[pos + "allows(".len()..];
let Some(end) = after.find(')') else { break };
let arg = after[..end].trim();
if let Some(literal) = arg.strip_prefix('"').and_then(|a| a.strip_suffix('"')) {
gate_ids.insert(literal.to_string());
} else if let Some(value) = const_values.get(arg) {
gate_ids.insert((*value).to_string());
}
rest = &after[end..];
}
}
let rebuild_body = runtime
.split("fn filter_allows_jsonl_rebuild")
.nth(1)
.expect("filter_allows_jsonl_rebuild present in runtime source");
let rebuild_array = rebuild_body
.split(".iter()")
.next()
.expect("split never yields zero items");
for token in rebuild_array.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) {
if let Some(value) = const_values.get(token) {
gate_ids.insert((*value).to_string());
}
}
assert!(
gate_ids.len() >= 20,
"suspiciously few gate ids found by the source scan ({}); did the scan break?",
gate_ids.len()
);
let mut unadvertised: Vec<&String> = gate_ids
.iter()
.filter(|id| !advertised.contains(id.as_str()))
.collect();
unadvertised.sort();
assert!(
unadvertised.is_empty(),
"fixer gate id(s) consulted by FixerFilter::allows but missing from \
capabilities fixers[].filter_ids: {unadvertised:?}"
);
let mut unconsulted: Vec<&&str> = advertised
.iter()
.filter(|id| !gate_ids.contains(**id))
.collect();
unconsulted.sort();
assert!(
unconsulted.is_empty(),
"capabilities fixers[].filter_ids advertise id(s) no runtime gate \
consults: {unconsulted:?}"
);
}
#[test]
fn test_recoverable_db_state_filter_preserves_sidecar_fm() {
let sidecar_only = FixerFilter::from_args(&[FM_WAL_SHM_SIDECAR_ORPHAN.to_string()], &[]);
assert!(filter_allows_recoverable_db_state_repair(
&sidecar_only,
false,
true
));
assert!(!filter_allows_recoverable_db_state_repair(
&sidecar_only,
true,
false
));
let blocked_cache_only = FixerFilter::from_args(&[FM_BLOCKED_CACHE_STALE.to_string()], &[]);
assert!(filter_allows_recoverable_db_state_repair(
&blocked_cache_only,
true,
false
));
assert!(!filter_allows_recoverable_db_state_repair(
&blocked_cache_only,
false,
true
));
let skip_sidecar = FixerFilter::from_args(&[], &[FM_WAL_SHM_SIDECAR_ORPHAN.to_string()]);
assert!(!filter_allows_recoverable_db_state_repair(
&skip_sidecar,
false,
true
));
}
#[test]
fn test_jsonl_rebuild_filter_matches_addressed_fms() {
for fm in [
FM_JSONL_ROW_COUNT_MISMATCH,
FM_EMPTY_OR_TRUNCATED_DATABASE,
FM_SQLITE_PAGE_MALFORMED,
FM_MISSING_REQUIRED_TABLE,
FM_MISSING_REQUIRED_COLUMN,
FM_BLOCKED_CACHE_STALE,
] {
let filter = FixerFilter::from_args(&[fm.to_string()], &[]);
assert!(
filter_allows_jsonl_rebuild(&filter),
"JSONL rebuild should run for addressed FM {fm}"
);
}
let sidecar_only = FixerFilter::from_args(&[FM_WAL_SHM_SIDECAR_ORPHAN.to_string()], &[]);
assert!(!filter_allows_jsonl_rebuild(&sidecar_only));
let skip_page = FixerFilter::from_args(&[], &[FM_SQLITE_PAGE_MALFORMED.to_string()]);
assert!(filter_allows_jsonl_rebuild(&skip_page));
let only_page_skip_page = FixerFilter::from_args(
&[FM_SQLITE_PAGE_MALFORMED.to_string()],
&[FM_SQLITE_PAGE_MALFORMED.to_string()],
);
assert!(!filter_allows_jsonl_rebuild(&only_page_skip_page));
}
#[test]
fn test_fix_merge_artifacts_quarantines_via_chokepoint() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let jsonl_path = beads_dir.join("issues.jsonl");
let _storage = SqliteStorage::open(&db_path).unwrap();
fs::write(
&jsonl_path,
format!(
"{}\n",
serde_json::to_string(&sample_issue("bd-test01", "Valid issue")).unwrap()
),
)
.unwrap();
fs::write(beads_dir.join("issues.base.jsonl"), b"").unwrap();
fs::write(beads_dir.join("issues.left.jsonl"), b"").unwrap();
fs::write(beads_dir.join("issues.right.jsonl"), b"").unwrap();
fs::write(beads_dir.join("beads.base.jsonl"), b"canonical-anchor").unwrap();
let paths = config::ConfigPaths {
beads_dir: beads_dir.clone(),
db_path,
jsonl_path,
metadata: config::Metadata::default(),
};
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
let report_before = collect_doctor_report(&beads_dir, &paths).expect("doctor report");
let before_check = find_check(&report_before.report.checks, "jsonl.merge_artifacts")
.expect("merge_artifacts check");
assert!(matches!(before_check.status, CheckStatus::Warn));
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Json, false, true);
assert!(fix_merge_artifacts_if_warned(
&beads_dir,
&report_before.report,
&ctx,
Some(&mut session),
));
assert!(!beads_dir.join("issues.base.jsonl").exists());
assert!(!beads_dir.join("issues.left.jsonl").exists());
assert!(!beads_dir.join("issues.right.jsonl").exists());
assert_eq!(
fs::read(beads_dir.join("beads.base.jsonl")).unwrap(),
b"canonical-anchor"
);
let q = session.run.root.join("quarantine/.beads");
assert!(q.join("issues.base.jsonl").is_file());
assert!(q.join("issues.left.jsonl").is_file());
assert!(q.join("issues.right.jsonl").is_file());
let actions = fs::read_to_string(&session.run.actions_file).unwrap();
let rename_count = actions
.lines()
.filter(|l| l.contains("\"op\":\"rename\""))
.count();
assert_eq!(rename_count, 3, "actions.jsonl: {actions}");
let report_after = collect_doctor_report(&beads_dir, &paths).expect("doctor report");
let after_check = find_check(&report_after.report.checks, "jsonl.merge_artifacts")
.expect("merge_artifacts check");
assert!(
matches!(after_check.status, CheckStatus::Ok),
"post-repair status must be ok, got {:?}",
after_check.status
);
}
#[test]
fn test_merge_artifacts_warn_carries_classification_and_recovery() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let jsonl_path = beads_dir.join("issues.jsonl");
let _storage = SqliteStorage::open(&db_path).unwrap();
fs::write(&jsonl_path, b"").unwrap();
fs::write(beads_dir.join("issues.base.jsonl"), b"").unwrap();
fs::write(
beads_dir.join("issues.left.jsonl"),
b"<<<<<<< HEAD\n{\"id\":\"bd-a\"}\n=======\n{\"id\":\"bd-b\"}\n>>>>>>> theirs\n",
)
.unwrap();
fs::write(beads_dir.join("issues.right.jsonl"), b"{\"id\":\"bd-c\"}\n").unwrap();
fs::write(beads_dir.join("beads.base.jsonl"), b"canonical-anchor").unwrap();
let mut checks = Vec::new();
check_merge_artifacts(&beads_dir, &jsonl_path, &mut checks).expect("merge artifact scan");
let check = find_check(&checks, "jsonl.merge_artifacts").expect("merge_artifacts check");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
let details = check.details.as_ref().expect("warn details");
assert_eq!(
details["canonical_jsonl"],
serde_json::json!(jsonl_path.display().to_string())
);
let recovery = details["recovery"].as_array().expect("recovery actions");
assert_eq!(recovery.len(), 2, "{details}");
assert_eq!(recovery[0]["kind"], "quarantine");
assert_eq!(recovery[0]["command"], "br doctor --repair");
assert_eq!(recovery[1]["kind"], "undo");
assert_eq!(recovery[1]["command"], "br doctor undo <run-id>");
let artifacts = details["artifacts"].as_array().expect("artifact entries");
assert_eq!(artifacts.len(), 3, "{details}");
let by_kind = |kind: &str| {
artifacts
.iter()
.find(|a| a["artifact_kind"] == kind)
.unwrap_or_else(|| panic!("missing {kind} entry: {details}"))
};
let left = by_kind("merge-left");
assert_eq!(left["conflict_markers_found"], true, "{details}");
assert!(
left["path"]
.as_str()
.is_some_and(|p| p.ends_with("issues.left.jsonl")),
"{details}"
);
assert_eq!(by_kind("merge-right")["conflict_markers_found"], false);
assert_eq!(
by_kind("stale-base-variant")["conflict_markers_found"],
false
);
assert!(
!artifacts.iter().any(|a| a["path"]
.as_str()
.is_some_and(|p| p.ends_with("beads.base.jsonl"))),
"protected anchor must not be classified as an artifact: {details}"
);
let listed = details["files"].as_array().expect("files list");
assert_eq!(listed.len(), 3, "{details}");
assert_eq!(details["finding_id"], "fm-state_files-merge-artifact-stuck");
}
#[test]
fn test_fix_merge_artifacts_is_idempotent_no_op_on_second_call() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let jsonl_path = beads_dir.join("issues.jsonl");
let _storage = SqliteStorage::open(&db_path).unwrap();
fs::write(&jsonl_path, b"").unwrap();
let paths = config::ConfigPaths {
beads_dir: beads_dir.clone(),
db_path,
jsonl_path,
metadata: config::Metadata::default(),
};
let report = collect_doctor_report(&beads_dir, &paths).expect("doctor report");
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Json, false, true);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
assert!(!fix_merge_artifacts_if_warned(
&beads_dir,
&report.report,
&ctx,
Some(&mut session),
));
let actions = fs::read_to_string(&session.run.actions_file).unwrap_or_default();
let rename_count = actions.matches("\"op\":\"rename\"").count();
assert_eq!(rename_count, 0, "no actions expected: {actions:?}");
}
#[test]
fn test_fix_startup_cache_quarantines_poisoned_files_via_chokepoint() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
let cache_dir = temp.path().join("startup-cache");
fs::create_dir_all(&beads_dir).unwrap();
fs::create_dir_all(&cache_dir).unwrap();
let current_cache = config::doctor_startup_cache_path_at(&cache_dir, &beads_dir, None);
fs::write(¤t_cache, "not-json-at-all\n").unwrap();
let unrelated_cache = cache_dir.join("startup-deadbeef.json");
fs::write(&unrelated_cache, "also-not-json\n").unwrap();
let poisoned = config::doctor_inspect_startup_cache_at(&cache_dir, &beads_dir, None);
assert_eq!(poisoned.len(), 1);
assert_eq!(poisoned[0].path, current_cache);
let report = DoctorReport {
ok: false,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "startup_cache.health".to_string(),
status: CheckStatus::Warn,
message: None,
details: None,
}],
};
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Json, false, true);
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
assert!(fix_startup_cache_entries_if_warned(
&poisoned,
&cache_dir,
&report,
&ctx,
Some(&mut session),
));
assert!(!current_cache.exists());
assert!(
unrelated_cache.is_file(),
"unrelated cache key must not be quarantined"
);
assert!(
session
.run
.root
.join("quarantine/startup-cache")
.join(current_cache.file_name().unwrap())
.is_file()
);
let after = config::doctor_inspect_startup_cache_at(&cache_dir, &beads_dir, None);
assert!(after.is_empty());
let actions_before = fs::read_to_string(&session.run.actions_file).unwrap();
assert!(!fix_startup_cache_entries_if_warned(
&after,
&cache_dir,
&report,
&ctx,
Some(&mut session),
));
let actions_after = fs::read_to_string(&session.run.actions_file).unwrap();
assert_eq!(actions_after, actions_before, "second pass must be a no-op");
}
#[cfg(unix)]
#[test]
fn test_check_root_gitignore_warns_for_symlink() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let outside = TempDir::new().unwrap();
let outside_gitignore = outside.path().join("gitignore-target");
fs::write(&outside_gitignore, ".beads/\n").unwrap();
symlink(&outside_gitignore, temp.path().join(".gitignore")).unwrap();
let mut checks = Vec::new();
check_root_gitignore(&beads_dir, &mut checks);
let check = find_check(&checks, "gitignore.beads_inner").expect("gitignore check");
assert!(matches!(check.status, CheckStatus::Warn));
assert!(
check
.message
.as_deref()
.is_some_and(|message| message.contains("symlinked root .gitignore")),
"warning should explain that symlinked .gitignore is unsupported: {check:?}"
);
}
#[cfg(unix)]
#[test]
fn test_fix_root_gitignore_if_warned_refuses_symlink_target() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let outside = TempDir::new().unwrap();
let outside_gitignore = outside.path().join("gitignore-target");
let original = ".beads/\nkeep-me\n";
fs::write(&outside_gitignore, original).unwrap();
let root_gitignore = temp.path().join(".gitignore");
symlink(&outside_gitignore, &root_gitignore).unwrap();
let mut checks = Vec::new();
check_root_gitignore(&beads_dir, &mut checks);
let report = DoctorReport {
ok: true,
workspace_health: None,
reliability_audit: None,
checks,
};
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Json, false, true);
assert!(!fix_root_gitignore_if_warned(
&beads_dir, &report, &ctx, None
));
assert_eq!(fs::read_to_string(&outside_gitignore).unwrap(), original);
assert!(
fs::symlink_metadata(&root_gitignore)
.unwrap()
.file_type()
.is_symlink(),
"doctor repair must leave the symlink itself untouched"
);
}
#[test]
fn test_repair_outcome_message_combines_gitignore_and_incomplete_reindex() {
let message = repair_outcome_message_from_parts(
vec![ROOT_GITIGNORE_REPAIR_MESSAGE.to_string()],
Some(&LocalRepairResult::default()),
Some(REINDEX_INCOMPLETE_MESSAGE),
);
assert!(message.contains(ROOT_GITIGNORE_REPAIR_MESSAGE));
assert!(message.contains(REINDEX_INCOMPLETE_MESSAGE));
}
#[test]
fn test_early_repair_summary_reports_export_hash_repairs() {
let summary = EarlyRepairSummary {
gitignore: false,
merge_artifacts: false,
startup_cache: false,
recovery_aged: false,
export_hash: true,
base_jsonl_symlink: false,
base_jsonl_stale: false,
orphan_tmp: false,
jsonl_eof_newline: false,
jsonl_bom: false,
jsonl_crlf: false,
jsonl_world_writable: false,
config_yaml_secret_mode: false,
inner_gitignore: false,
dirty_bitmap_orphans: false,
comments_orphans: false,
labels_orphans: false,
dependencies_orphans: false,
wal_checkpoint: false,
null_defaults: false,
db_bloat_vacuum: false,
};
assert!(summary.applied());
assert_eq!(
summary.action_labels(),
vec!["export_hash_cache_recomputed".to_string()]
);
assert_eq!(
repair_outcome_message_from_parts(summary.messages(), None, None),
"Recomputed metadata.jsonl_content_hash."
);
let audit = summary.audit_record();
assert_eq!(audit.phase, "doctor.early_repair");
assert_eq!(audit.outcome, "export_hash_cache_recomputed");
assert_eq!(
audit.applied_actions,
vec!["export_hash_cache_recomputed".to_string()]
);
let local_repair = LocalRepairResult {
blocked_cache_rebuilt: true,
..LocalRepairResult::default()
};
let combined_audit = summary.prepend_actions_to_audit(local_repair_audit_record(
"doctor.local_repair",
"verified",
&local_repair,
None,
));
assert_eq!(
combined_audit.applied_actions,
vec![
"export_hash_cache_recomputed".to_string(),
"blocked_cache_rebuilt".to_string()
]
);
}
#[test]
fn test_early_repair_summary_reports_db_bloat_vacuum() {
let summary = EarlyRepairSummary {
gitignore: false,
merge_artifacts: false,
startup_cache: false,
recovery_aged: false,
export_hash: false,
base_jsonl_symlink: false,
base_jsonl_stale: false,
orphan_tmp: false,
jsonl_eof_newline: false,
jsonl_bom: false,
jsonl_crlf: false,
jsonl_world_writable: false,
config_yaml_secret_mode: false,
inner_gitignore: false,
dirty_bitmap_orphans: false,
comments_orphans: false,
labels_orphans: false,
dependencies_orphans: false,
wal_checkpoint: false,
null_defaults: false,
db_bloat_vacuum: true,
};
assert!(summary.applied());
assert_eq!(
summary.action_labels(),
vec!["db_bloat_vacuumed".to_string()]
);
assert_eq!(
repair_outcome_message_from_parts(summary.messages(), None, None),
"Compacted database via VACUUM to reclaim freelist space (--unsafe-auto-fix opt-in)."
);
let audit = summary.audit_record();
assert_eq!(audit.phase, "doctor.early_repair");
assert_eq!(audit.outcome, "db_bloat_vacuumed");
assert_eq!(audit.applied_actions, vec!["db_bloat_vacuumed".to_string()]);
}
#[test]
fn test_early_repair_summary_reports_null_defaults_backfill() {
let summary = EarlyRepairSummary {
gitignore: false,
merge_artifacts: false,
startup_cache: false,
recovery_aged: false,
export_hash: false,
base_jsonl_symlink: false,
base_jsonl_stale: false,
orphan_tmp: false,
jsonl_eof_newline: false,
jsonl_bom: false,
jsonl_crlf: false,
jsonl_world_writable: false,
config_yaml_secret_mode: false,
inner_gitignore: false,
dirty_bitmap_orphans: false,
comments_orphans: false,
labels_orphans: false,
dependencies_orphans: false,
wal_checkpoint: false,
null_defaults: true,
db_bloat_vacuum: false,
};
assert!(summary.applied());
assert_eq!(
summary.action_labels(),
vec!["null_defaults_backfilled".to_string()]
);
assert_eq!(
repair_outcome_message_from_parts(summary.messages(), None, None),
"Backfilled schema-declared defaults into NULL NOT-NULL columns."
);
let audit = summary.audit_record();
assert_eq!(audit.phase, "doctor.early_repair");
assert_eq!(audit.outcome, "null_defaults_backfilled");
assert_eq!(
audit.applied_actions,
vec!["null_defaults_backfilled".to_string()]
);
}
#[test]
fn test_early_repair_summary_reports_base_jsonl_symlink_quarantine() {
let summary = EarlyRepairSummary {
gitignore: false,
merge_artifacts: false,
startup_cache: false,
recovery_aged: false,
export_hash: false,
base_jsonl_symlink: true,
base_jsonl_stale: false,
orphan_tmp: false,
jsonl_eof_newline: false,
jsonl_bom: false,
jsonl_crlf: false,
jsonl_world_writable: false,
config_yaml_secret_mode: false,
inner_gitignore: false,
dirty_bitmap_orphans: false,
comments_orphans: false,
labels_orphans: false,
dependencies_orphans: false,
wal_checkpoint: false,
null_defaults: false,
db_bloat_vacuum: false,
};
assert!(summary.applied());
assert_eq!(
summary.action_labels(),
vec!["base_jsonl_symlink_quarantined".to_string()]
);
assert_eq!(
repair_outcome_message_from_parts(summary.messages(), None, None),
"Quarantined symlinked merge anchor."
);
let audit = summary.audit_record();
assert_eq!(audit.phase, "doctor.early_repair");
assert_eq!(audit.outcome, "base_jsonl_symlink_quarantined");
assert_eq!(
audit.applied_actions,
vec!["base_jsonl_symlink_quarantined".to_string()]
);
}
#[test]
fn test_early_repair_summary_reports_base_jsonl_stale_regen() {
let summary = EarlyRepairSummary {
gitignore: false,
merge_artifacts: false,
startup_cache: false,
recovery_aged: false,
export_hash: false,
base_jsonl_symlink: false,
base_jsonl_stale: true,
orphan_tmp: false,
jsonl_eof_newline: false,
jsonl_bom: false,
jsonl_crlf: false,
jsonl_world_writable: false,
config_yaml_secret_mode: false,
inner_gitignore: false,
dirty_bitmap_orphans: false,
comments_orphans: false,
labels_orphans: false,
dependencies_orphans: false,
wal_checkpoint: false,
null_defaults: false,
db_bloat_vacuum: false,
};
assert!(summary.applied());
assert_eq!(
summary.action_labels(),
vec!["base_jsonl_anchor_regenerated".to_string()]
);
assert_eq!(
repair_outcome_message_from_parts(summary.messages(), None, None),
"Regenerated stale merge anchor from current JSONL."
);
let audit = summary.audit_record();
assert_eq!(audit.phase, "doctor.early_repair");
assert_eq!(audit.outcome, "base_jsonl_anchor_regenerated");
assert_eq!(
audit.applied_actions,
vec!["base_jsonl_anchor_regenerated".to_string()]
);
}
#[test]
fn test_early_repair_summary_reports_orphan_tmp_quarantine() {
let summary = EarlyRepairSummary {
gitignore: false,
merge_artifacts: false,
startup_cache: false,
recovery_aged: false,
export_hash: false,
base_jsonl_symlink: false,
base_jsonl_stale: false,
orphan_tmp: true,
jsonl_eof_newline: false,
jsonl_bom: false,
jsonl_crlf: false,
jsonl_world_writable: false,
config_yaml_secret_mode: false,
inner_gitignore: false,
dirty_bitmap_orphans: false,
comments_orphans: false,
labels_orphans: false,
dependencies_orphans: false,
wal_checkpoint: false,
null_defaults: false,
db_bloat_vacuum: false,
};
assert!(summary.applied());
assert_eq!(
summary.action_labels(),
vec!["orphan_tmp_quarantined".to_string()]
);
assert_eq!(
repair_outcome_message_from_parts(summary.messages(), None, None),
"Quarantined orphan tmp files."
);
let audit = summary.audit_record();
assert_eq!(audit.phase, "doctor.early_repair");
assert_eq!(audit.outcome, "orphan_tmp_quarantined");
assert_eq!(
audit.applied_actions,
vec!["orphan_tmp_quarantined".to_string()]
);
}
#[test]
fn test_check_jsonl_detects_malformed() -> Result<()> {
let mut file = NamedTempFile::new().unwrap();
std::io::Write::write_all(file.as_file_mut(), b"{\"id\":\"ok\"}\n")?;
std::io::Write::write_all(file.as_file_mut(), b"{bad json}\n")?;
let mut checks = Vec::new();
let state = check_jsonl(file.path(), &mut checks).unwrap();
assert_eq!(state, JsonlCountState::Invalid);
let check = find_check(&checks, "jsonl.parse").expect("check present");
assert!(matches!(check.status, CheckStatus::Error));
Ok(())
}
#[test]
fn test_check_jsonl_detects_invalid_issue_records() -> Result<()> {
let mut file = NamedTempFile::new().unwrap();
let mut invalid_issue = sample_issue("bd-bad01", "");
invalid_issue.id = "bd-bad01".to_string();
let encoded = serde_json::to_string(&invalid_issue)?;
std::io::Write::write_all(file.as_file_mut(), encoded.as_bytes())?;
std::io::Write::write_all(file.as_file_mut(), b"\n")?;
let mut checks = Vec::new();
let state = check_jsonl(file.path(), &mut checks).unwrap();
assert_eq!(state, JsonlCountState::Invalid);
let check = find_check(&checks, "jsonl.parse").expect("check present");
assert!(matches!(check.status, CheckStatus::Error));
assert!(
check
.message
.as_deref()
.is_some_and(|message| message.contains("title")),
"unexpected check message: {:?}",
check.message
);
Ok(())
}
#[test]
fn test_check_jsonl_returns_count_only_for_valid_records() -> Result<()> {
let mut file = NamedTempFile::new().unwrap();
let issue = sample_issue("bd-good01", "Good issue");
let encoded = serde_json::to_string(&issue)?;
std::io::Write::write_all(file.as_file_mut(), encoded.as_bytes())?;
std::io::Write::write_all(file.as_file_mut(), b"\n")?;
let mut checks = Vec::new();
let state = check_jsonl(file.path(), &mut checks).unwrap();
assert_eq!(state, JsonlCountState::Available(1));
let check = find_check(&checks, "jsonl.parse").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
Ok(())
}
#[test]
fn test_collect_doctor_report_skips_count_comparison_for_invalid_jsonl() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let jsonl_path = beads_dir.join("issues.jsonl");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.create_issue(
&sample_issue("bd-test01", "Doctor count source"),
"doctor-test",
)
.unwrap();
let valid_json =
serde_json::to_string(&sample_issue("bd-test01", "Doctor count source")).unwrap();
fs::write(&jsonl_path, format!("{valid_json}\n{{bad json}}\n")).unwrap();
let paths = config::ConfigPaths {
beads_dir: beads_dir.clone(),
db_path,
jsonl_path,
metadata: config::Metadata::default(),
};
let report = collect_doctor_report(&beads_dir, &paths).expect("doctor report");
let parse_check = find_check(&report.report.checks, "jsonl.parse").expect("jsonl parse");
let counts_check =
find_check(&report.report.checks, "counts.db_vs_jsonl").expect("count check");
assert!(matches!(parse_check.status, CheckStatus::Error));
assert!(matches!(counts_check.status, CheckStatus::Warn));
assert!(
counts_check
.message
.as_deref()
.is_some_and(|message| message.contains("JSONL is invalid")),
"unexpected count-check message: {:?}",
counts_check.message
);
}
#[test]
fn test_collect_doctor_report_warns_on_equal_count_id_set_mismatch() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let jsonl_path = beads_dir.join("issues.jsonl");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.create_issue(&sample_issue("bd-db01", "Only in DB"), "doctor-test")
.unwrap();
let json = serde_json::to_string(&sample_issue("bd-jsonl01", "Only in JSONL")).unwrap();
fs::write(&jsonl_path, format!("{json}\n")).unwrap();
let paths = config::ConfigPaths {
beads_dir: beads_dir.clone(),
db_path,
jsonl_path,
metadata: config::Metadata::default(),
};
let report = collect_doctor_report(&beads_dir, &paths).expect("doctor report");
let counts_check =
find_check(&report.report.checks, "counts.db_vs_jsonl").expect("count check");
assert!(matches!(counts_check.status, CheckStatus::Warn));
assert!(
counts_check
.message
.as_deref()
.is_some_and(|message| message.contains("id sets diverge")),
"unexpected count-check message: {:?}",
counts_check.message
);
let id_delta = counts_check
.details
.as_ref()
.and_then(|details| details.get("id_delta"))
.expect("id delta details");
assert_eq!(id_delta["only_db_count"], 1);
assert_eq!(id_delta["only_jsonl_count"], 1);
assert_eq!(id_delta["only_db"][0], "bd-db01");
assert_eq!(id_delta["only_jsonl"][0], "bd-jsonl01");
}
#[test]
fn test_required_schema_checks_missing_tables() {
let conn = Connection::open(":memory:").unwrap();
let mut checks = Vec::new();
required_schema_checks(&conn, &mut checks).unwrap();
let tables = find_check(&checks, "schema.tables").expect("tables check");
assert!(matches!(tables.status, CheckStatus::Error));
}
#[test]
fn test_collect_doctor_report_reports_missing_metadata_tables_without_aborting() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let jsonl_path = beads_dir.join("issues.jsonl");
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
conn.execute(
r"
CREATE TABLE issues (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL,
priority INTEGER NOT NULL,
issue_type TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
",
)
.unwrap();
conn.close().unwrap();
fs::write(&jsonl_path, "{\"id\":\"bd-test\"}\n").unwrap();
let paths = config::ConfigPaths {
beads_dir: beads_dir.clone(),
db_path,
jsonl_path,
metadata: config::Metadata::default(),
};
let report = collect_doctor_report(&beads_dir, &paths).expect("doctor report");
let anomaly_check = find_check(&report.report.checks, "db.recoverable_anomalies")
.expect("recoverable anomalies check");
assert!(matches!(anomaly_check.status, CheckStatus::Error));
assert!(
anomaly_check
.message
.as_deref()
.is_some_and(|message| message.contains("Failed to inspect recoverable anomalies")),
"unexpected check message: {:?}",
anomaly_check.message
);
}
#[test]
fn test_collect_doctor_report_quick_skips_slow_detectors_before_execution() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let jsonl_path = beads_dir.join("issues.jsonl");
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
conn.execute(
r"
CREATE TABLE issues (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL,
priority INTEGER NOT NULL,
issue_type TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
",
)
.unwrap();
fs::write(&jsonl_path, "{\"id\":\"bd-test\"}\n").unwrap();
let paths = config::ConfigPaths {
beads_dir: beads_dir.clone(),
db_path,
jsonl_path,
metadata: config::Metadata::default(),
};
let quick =
collect_doctor_report_with_mode(&beads_dir, &paths, DoctorInspectionMode::Quick)
.expect("quick doctor report");
assert!(
find_check(&quick.report.checks, "schema.tables").is_some(),
"quick mode should still run cheap schema checks"
);
for skipped in [
"db.recoverable_anomalies",
"counts.db_vs_jsonl",
"sync.metadata",
"sqlite3.integrity_check",
"db.write_probe",
] {
assert!(
find_check(&quick.report.checks, skipped).is_none(),
"quick mode should not execute slow detector {skipped}"
);
}
}
#[test]
fn test_collect_doctor_report_quick_treats_warnings_as_findings() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
{
let _storage = SqliteStorage::open(&db_path).unwrap();
}
let paths = config::ConfigPaths {
beads_dir: beads_dir.clone(),
db_path,
jsonl_path: beads_dir.join("issues.jsonl"),
metadata: config::Metadata::default(),
};
let quick =
collect_doctor_report_with_mode(&beads_dir, &paths, DoctorInspectionMode::Quick)
.expect("quick doctor report");
let jsonl_check = find_check(&quick.report.checks, "jsonl.parse").expect("jsonl warning");
assert!(matches!(jsonl_check.status, CheckStatus::Warn));
assert!(
!quick.report.ok,
"quick mode should fail its gate when a warning-level finding remains"
);
}
#[test]
fn test_select_doctor_jsonl_path_keeps_missing_explicit_override() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let configured_jsonl = beads_dir.join("custom.jsonl");
let legacy_jsonl = beads_dir.join("issues.jsonl");
fs::write(&legacy_jsonl, "{\"id\":\"bd-legacy\"}\n").unwrap();
let paths = config::ConfigPaths {
beads_dir: beads_dir.clone(),
db_path: beads_dir.join("beads.db"),
jsonl_path: configured_jsonl.clone(),
metadata: config::Metadata {
database: "beads.db".to_string(),
jsonl_export: "custom.jsonl".to_string(),
backend: None,
deletions_retention_days: None,
},
};
assert_eq!(
select_doctor_jsonl_path(&beads_dir, &paths),
Some(configured_jsonl)
);
}
#[test]
fn test_collect_doctor_report_surfaces_missing_explicit_metadata_jsonl() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let configured_jsonl = beads_dir.join("custom.jsonl");
fs::write(beads_dir.join("issues.jsonl"), "{\"id\":\"bd-legacy\"}\n").unwrap();
let _storage = SqliteStorage::open(&db_path).unwrap();
let paths = config::ConfigPaths {
beads_dir: beads_dir.clone(),
db_path,
jsonl_path: configured_jsonl.clone(),
metadata: config::Metadata {
database: "beads.db".to_string(),
jsonl_export: "custom.jsonl".to_string(),
backend: None,
deletions_retention_days: None,
},
};
let report = collect_doctor_report(&beads_dir, &paths).expect("doctor report");
let parse_check = find_check(&report.report.checks, "jsonl.parse").expect("jsonl parse");
assert!(matches!(parse_check.status, CheckStatus::Error));
assert_eq!(report.jsonl_path, Some(configured_jsonl.clone()));
assert_eq!(
parse_check
.details
.as_ref()
.and_then(|details| details.get("path"))
.and_then(serde_json::Value::as_str),
Some(configured_jsonl.to_string_lossy().as_ref())
);
}
#[test]
fn test_collect_doctor_report_accepts_configured_external_jsonl() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
let external_dir = temp.path().join("external");
fs::create_dir_all(&beads_dir).unwrap();
fs::create_dir_all(&external_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let external_jsonl = external_dir.join("issues.jsonl");
fs::write(&external_jsonl, "{\"id\":\"bd-external\"}\n").unwrap();
let _storage = SqliteStorage::open(&db_path).unwrap();
let paths = config::ConfigPaths {
beads_dir: beads_dir.clone(),
db_path,
jsonl_path: external_jsonl.clone(),
metadata: config::Metadata {
database: "beads.db".to_string(),
jsonl_export: external_jsonl.to_string_lossy().into_owned(),
backend: None,
deletions_retention_days: None,
},
};
let report = collect_doctor_report(&beads_dir, &paths).expect("doctor report");
let sync_path_check =
find_check(&report.report.checks, "sync_jsonl_path").expect("sync path check");
assert!(matches!(sync_path_check.status, CheckStatus::Ok));
assert_eq!(
sync_path_check
.details
.as_ref()
.and_then(|details| details.get("path"))
.and_then(serde_json::Value::as_str),
Some(external_jsonl.to_string_lossy().as_ref())
);
assert_eq!(
sync_path_check
.details
.as_ref()
.and_then(|details| details.get("external"))
.and_then(serde_json::Value::as_bool),
Some(true)
);
}
#[cfg(unix)]
#[test]
fn test_collect_doctor_report_checks_selected_external_jsonl_permissions() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
let external_dir = temp.path().join("external");
fs::create_dir_all(&beads_dir).unwrap();
fs::create_dir_all(&external_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let external_jsonl = external_dir.join("issues.jsonl");
fs::write(beads_dir.join("issues.jsonl"), "{\"id\":\"bd-local\"}\n").unwrap();
fs::write(&external_jsonl, "{\"id\":\"bd-external\"}\n").unwrap();
let mut perms = fs::metadata(&external_jsonl).unwrap().permissions();
perms.set_mode(0o666);
fs::set_permissions(&external_jsonl, perms).unwrap();
let _storage = SqliteStorage::open(&db_path).unwrap();
let paths = config::ConfigPaths {
beads_dir: beads_dir.clone(),
db_path,
jsonl_path: external_jsonl.clone(),
metadata: config::Metadata {
database: "beads.db".to_string(),
jsonl_export: external_jsonl.to_string_lossy().into_owned(),
backend: None,
deletions_retention_days: None,
},
};
let report = collect_doctor_report(&beads_dir, &paths).expect("doctor report");
let permission_check =
find_check(&report.report.checks, "permissions.jsonl_world_writable")
.expect("permission check");
assert!(
matches!(permission_check.status, CheckStatus::Warn),
"{permission_check:?}"
);
assert_eq!(
permission_check
.details
.as_ref()
.and_then(|details| details.get("path"))
.and_then(serde_json::Value::as_str),
Some(external_jsonl.to_string_lossy().as_ref())
);
}
#[test]
fn test_collect_doctor_report_checks_selected_external_jsonl_trailing_newline() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
let external_dir = temp.path().join("external");
fs::create_dir_all(&beads_dir).unwrap();
fs::create_dir_all(&external_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let external_jsonl = external_dir.join("issues.jsonl");
fs::write(beads_dir.join("issues.jsonl"), "{\"id\":\"bd-local\"}\n").unwrap();
fs::write(&external_jsonl, "{\"id\":\"bd-external\"}").unwrap();
let _storage = SqliteStorage::open(&db_path).unwrap();
let paths = config::ConfigPaths {
beads_dir: beads_dir.clone(),
db_path,
jsonl_path: external_jsonl.clone(),
metadata: config::Metadata {
database: "beads.db".to_string(),
jsonl_export: external_jsonl.to_string_lossy().into_owned(),
backend: None,
deletions_retention_days: None,
},
};
let report = collect_doctor_report(&beads_dir, &paths).expect("doctor report");
let newline_check =
find_check(&report.report.checks, "jsonl_eof_newline").expect("newline check");
assert!(
matches!(newline_check.status, CheckStatus::Warn),
"{newline_check:?}"
);
assert_eq!(
newline_check
.details
.as_ref()
.and_then(|details| details.get("path"))
.and_then(serde_json::Value::as_str),
Some(external_jsonl.to_string_lossy().as_ref())
);
}
#[test]
fn test_collect_doctor_report_checks_selected_external_jsonl_bom_and_crlf() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
let external_dir = temp.path().join("external");
fs::create_dir_all(&beads_dir).unwrap();
fs::create_dir_all(&external_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let external_jsonl = external_dir.join("issues.jsonl");
fs::write(beads_dir.join("issues.jsonl"), "{\"id\":\"bd-local\"}\n").unwrap();
let mut external_bytes = UTF8_BOM.to_vec();
external_bytes.extend_from_slice(b"{\"id\":\"bd-external\"}\r\n");
fs::write(&external_jsonl, external_bytes).unwrap();
let _storage = SqliteStorage::open(&db_path).unwrap();
let paths = config::ConfigPaths {
beads_dir: beads_dir.clone(),
db_path,
jsonl_path: external_jsonl.clone(),
metadata: config::Metadata {
database: "beads.db".to_string(),
jsonl_export: external_jsonl.to_string_lossy().into_owned(),
backend: None,
deletions_retention_days: None,
},
};
let report = collect_doctor_report(&beads_dir, &paths).expect("doctor report");
for check_name in ["jsonl_bom", "jsonl_crlf"] {
let check = find_check(&report.report.checks, check_name).expect("selected path check");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
assert_eq!(
check
.details
.as_ref()
.and_then(|details| details.get("path"))
.and_then(serde_json::Value::as_str),
Some(external_jsonl.to_string_lossy().as_ref()),
"{check_name} must report the selected external JSONL"
);
}
}
#[test]
fn test_fix_jsonl_trailing_newline_appends_selected_in_workspace_path() {
let temp = TempDir::new().unwrap();
let external_dir = temp.path().join("external");
fs::create_dir_all(&external_dir).unwrap();
let external_jsonl = external_dir.join("issues.jsonl");
fs::write(&external_jsonl, "{\"id\":\"bd-external\"}").unwrap();
let report = DoctorReport {
ok: false,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "jsonl_eof_newline".to_string(),
status: CheckStatus::Warn,
message: None,
details: None,
}],
};
let mut session = DoctorRepairSession::new(temp.path(), false)
.expect("session must build");
assert!(fix_jsonl_trailing_newline_if_warned(
Some(&external_jsonl),
&report,
&quiet_ctx(),
Some(&mut session),
));
assert_eq!(
fs::read_to_string(&external_jsonl).unwrap(),
"{\"id\":\"bd-external\"}\n"
);
assert_eq!(
fs::read_to_string(session.run.root.join("backups/external/issues.jsonl")).unwrap(),
"{\"id\":\"bd-external\"}"
);
}
#[test]
fn test_fix_jsonl_trailing_newline_skips_path_outside_workspace() {
let repo = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let external_jsonl = outside.path().join("issues.jsonl");
fs::write(&external_jsonl, "{\"id\":\"bd-outside\"}").unwrap();
let report = DoctorReport {
ok: false,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "jsonl_eof_newline".to_string(),
status: CheckStatus::Warn,
message: None,
details: None,
}],
};
let mut session =
DoctorRepairSession::new(repo.path(), false).expect("session");
assert!(!fix_jsonl_trailing_newline_if_warned(
Some(&external_jsonl),
&report,
&quiet_ctx(),
Some(&mut session),
));
assert_eq!(
fs::read_to_string(&external_jsonl).unwrap(),
"{\"id\":\"bd-outside\"}"
);
assert_eq!(
fs::read_to_string(&session.run.actions_file).unwrap(),
"",
"skipped external repairs must not write an undo action"
);
}
#[test]
fn test_fix_jsonl_trailing_newline_skips_traversal_outside_workspace() {
let parent = TempDir::new().unwrap();
let repo = parent.path().join("repo");
let outside = parent.path().join("outside");
fs::create_dir_all(&repo).unwrap();
fs::create_dir_all(&outside).unwrap();
let outside_jsonl = outside.join("issues.jsonl");
fs::write(&outside_jsonl, "{\"id\":\"bd-outside\"}").unwrap();
let traversal_path = repo.join("../outside/issues.jsonl");
let report = DoctorReport {
ok: false,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "jsonl_eof_newline".to_string(),
status: CheckStatus::Warn,
message: None,
details: None,
}],
};
let mut session = DoctorRepairSession::new(&repo, false).expect("session");
assert!(!fix_jsonl_trailing_newline_if_warned(
Some(&traversal_path),
&report,
&quiet_ctx(),
Some(&mut session),
));
assert_eq!(
fs::read_to_string(&outside_jsonl).unwrap(),
"{\"id\":\"bd-outside\"}",
"traversal-selected outside JSONL must not be rewritten"
);
assert_eq!(
fs::read_to_string(&session.run.actions_file).unwrap(),
"",
"skipped traversal repairs must not write an undo action"
);
}
#[test]
fn test_integrity_check_messages_collects_all_rows() {
let messages = integrity_check_messages(&[
vec![SqliteValue::Text("row 1 missing from index idx_a".into())],
vec![SqliteValue::Text("row 2 missing from index idx_a".into())],
]);
assert_eq!(
messages,
vec![
"row 1 missing from index idx_a".to_string(),
"row 2 missing from index idx_a".to_string(),
]
);
}
#[test]
fn test_check_sync_metadata_clean_export_after_import_is_not_pending_import() -> Result<()> {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let jsonl_path = temp.path().join("issues.jsonl");
fs::write(&jsonl_path, "{\"id\":\"bd-clean\"}\n").unwrap();
let jsonl_hash = crate::sync::compute_jsonl_hash(&jsonl_path)?;
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage.set_metadata("last_import_time", "2026-01-01T00:00:00Z")?;
storage.set_metadata("last_export_time", "2999-01-01T00:00:00Z")?;
storage.set_metadata("jsonl_content_hash", &jsonl_hash)?;
drop(storage);
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_sync_metadata(&conn, &db_path, Some(&jsonl_path), &mut checks);
let check = find_check(&checks, "sync.metadata").expect("sync metadata check");
assert!(matches!(check.status, CheckStatus::Ok), "{check:?}");
assert_eq!(
check.message.as_deref(),
Some("Database and JSONL are in sync"),
"last_export > last_import is history, not a pending import"
);
let details = check.details.as_ref().expect("sync details");
assert_eq!(details["dirty_issues"], 0);
assert_eq!(details["jsonl_newer"], false);
assert_eq!(details["db_newer"], false);
assert_eq!(details["pending_import"], false);
assert_eq!(details["pending_export"], false);
Ok(())
}
#[test]
fn test_check_sync_metadata_pending_import_without_local_dirty_is_warn() -> Result<()> {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let jsonl_path = temp.path().join("issues.jsonl");
fs::write(&jsonl_path, "{\"id\":\"bd-remote\"}\n").unwrap();
let storage = SqliteStorage::open(&db_path).unwrap();
assert_eq!(storage.get_dirty_issue_count()?, 0);
drop(storage);
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_sync_metadata(&conn, &db_path, Some(&jsonl_path), &mut checks);
let check = find_check(&checks, "sync.metadata").expect("sync metadata check");
assert!(
matches!(check.status, CheckStatus::Warn),
"pending-import is an advisory Warn, matching the pending-export \
direction (beads_rust#330): {check:?}"
);
assert_eq!(
check.message.as_deref(),
Some("External changes pending import"),
"pure JSONL-newer state is explicit import work, not a doctor failure"
);
let details = check.details.as_ref().expect("sync details");
assert_eq!(details["jsonl_newer"], true);
assert_eq!(details["db_newer"], false);
assert_eq!(details["pending_import"], true);
assert_eq!(details["pending_export"], false);
Ok(())
}
#[test]
fn test_check_sync_metadata_empty_jsonl_pending_import_stays_ok() -> Result<()> {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let jsonl_path = temp.path().join("issues.jsonl");
fs::write(&jsonl_path, b"").unwrap();
let storage = SqliteStorage::open(&db_path).unwrap();
assert_eq!(storage.get_dirty_issue_count()?, 0);
drop(storage);
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_sync_metadata(&conn, &db_path, Some(&jsonl_path), &mut checks);
let check = find_check(&checks, "sync.metadata").expect("sync metadata check");
assert!(
matches!(check.status, CheckStatus::Ok),
"empty-JSONL pending-import is benign (nothing to import): {check:?}"
);
assert_eq!(
check.message.as_deref(),
Some("External changes pending import (empty JSONL, nothing to import)")
);
let details = check.details.as_ref().expect("sync details");
assert_eq!(details["jsonl_newer"], true);
assert_eq!(details["pending_import"], true);
Ok(())
}
#[test]
fn test_check_sync_metadata_no_export_does_not_hide_divergence() -> Result<()> {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let jsonl_path = temp.path().join("issues.jsonl");
fs::write(&jsonl_path, "{\"id\":\"bd-remote\"}\n").unwrap();
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage.create_issue(&sample_issue("bd-local", "Local dirty issue"), "tester")?;
drop(storage);
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_sync_metadata(&conn, &db_path, Some(&jsonl_path), &mut checks);
let check = find_check(&checks, "sync.metadata").expect("sync metadata check");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
assert_eq!(
check.message.as_deref(),
Some("Database and JSONL have diverged (merge required)"),
"doctor must not suggest flush-only when JSONL is also newer"
);
let details = check.details.as_ref().expect("sync details");
assert_eq!(details["jsonl_newer"], true);
assert_eq!(details["db_newer"], true);
assert_eq!(details["pending_import"], true);
assert_eq!(details["pending_export"], true);
Ok(())
}
#[test]
fn test_check_recoverable_anomalies_detects_duplicate_config_and_metadata() -> Result<()> {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let _storage = SqliteStorage::open(&db_path).unwrap();
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
conn.execute("INSERT INTO config (key, value) VALUES ('issue_prefix', 'dup-a')")
.unwrap();
conn.execute("INSERT INTO config (key, value) VALUES ('issue_prefix', 'dup-b')")
.unwrap();
conn.execute("INSERT INTO metadata (key, value) VALUES ('project', 'dup-a')")
.unwrap();
conn.execute("INSERT INTO metadata (key, value) VALUES ('project', 'dup-b')")
.unwrap();
let mut checks = Vec::new();
check_recoverable_anomalies(&conn, &mut checks)?;
let check = find_check(&checks, "db.recoverable_anomalies").expect("check present");
assert!(matches!(check.status, CheckStatus::Error));
let findings = check
.details
.as_ref()
.and_then(|details| details.get("findings"))
.and_then(serde_json::Value::as_array)
.expect("findings array");
assert!(
findings.iter().any(|finding| {
finding
.as_str()
.is_some_and(|message| message.contains("config contains duplicate rows"))
}),
"expected duplicate config finding: {findings:?}"
);
assert!(
findings.iter().any(|finding| {
finding
.as_str()
.is_some_and(|message| message.contains("metadata contains duplicate rows"))
}),
"expected duplicate metadata finding: {findings:?}"
);
Ok(())
}
#[test]
fn test_check_recoverable_anomalies_treats_stale_blocked_cache_as_warning() -> Result<()> {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage.mark_blocked_cache_stale()?;
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_recoverable_anomalies(&conn, &mut checks)?;
let check = find_check(&checks, "db.recoverable_anomalies").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn));
assert_eq!(check.message.as_deref(), Some(BLOCKED_CACHE_STALE_FINDING));
Ok(())
}
#[test]
fn test_check_recoverable_anomalies_warns_on_blocked_cache_content_mismatch() -> Result<()> {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let blocker = sample_issue("bd-blocker", "Blocker");
let target = sample_issue("bd-target", "Target");
storage.create_issue(&blocker, "tester")?;
storage.create_issue(&target, "tester")?;
storage.add_dependency(&target.id, &blocker.id, "blocks", "tester")?;
assert!(storage.ensure_blocked_cache_fresh()?);
storage.execute_test_sql(
"UPDATE blocked_issues_cache
SET blocked_by = '[\"bd-other:open\"]'
WHERE issue_id = 'bd-target'",
)?;
drop(storage);
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_recoverable_anomalies(&conn, &mut checks)?;
let check = find_check(&checks, "db.recoverable_anomalies").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn));
assert_eq!(
check.message.as_deref(),
Some(BLOCKED_CACHE_CONTENT_MISMATCH_FINDING)
);
let findings = check
.details
.as_ref()
.and_then(|details| details.get("findings"))
.and_then(serde_json::Value::as_array)
.expect("findings array");
assert!(findings.iter().any(|finding| {
finding
.as_str()
.is_some_and(|message| message == BLOCKED_CACHE_CONTENT_MISMATCH_FINDING)
}));
Ok(())
}
#[test]
fn test_check_recoverable_anomalies_warns_on_ready_projection_mismatch() -> Result<()> {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let blocker = sample_issue("bd-blocker", "Blocker");
let target = sample_issue("bd-target", "Target");
storage.create_issue(&blocker, "tester")?;
storage.create_issue(&target, "tester")?;
storage.add_dependency(&target.id, &blocker.id, "blocks", "tester")?;
assert!(storage.ensure_blocked_cache_fresh()?);
storage.execute_test_sql(
"DELETE FROM blocked_issues_cache
WHERE issue_id = 'bd-target'",
)?;
drop(storage);
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_recoverable_anomalies(&conn, &mut checks)?;
let check = find_check(&checks, "db.recoverable_anomalies").expect("check present");
assert!(matches!(check.status, CheckStatus::Warn));
let findings = check
.details
.as_ref()
.and_then(|details| details.get("findings"))
.and_then(serde_json::Value::as_array)
.expect("findings array");
assert!(findings.iter().any(|finding| {
finding
.as_str()
.is_some_and(|message| message == READY_PROJECTION_CONTENT_MISMATCH_FINDING)
}));
Ok(())
}
#[test]
fn test_repair_recoverable_db_state_rebuilds_blocked_cache_content_mismatch() -> Result<()> {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let blocker = sample_issue("bd-blocker", "Blocker");
let target = sample_issue("bd-target", "Target");
storage.create_issue(&blocker, "tester")?;
storage.create_issue(&target, "tester")?;
storage.add_dependency(&target.id, &blocker.id, "blocks", "tester")?;
assert!(storage.ensure_blocked_cache_fresh()?);
storage.execute_test_sql(
"UPDATE blocked_issues_cache
SET blocked_by = '[\"bd-other:open\"]'
WHERE issue_id = 'bd-target'",
)?;
drop(storage);
let report = DoctorReport {
ok: true,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "db.recoverable_anomalies".to_string(),
status: CheckStatus::Warn,
message: Some(BLOCKED_CACHE_CONTENT_MISMATCH_FINDING.to_string()),
details: Some(serde_json::json!({
"findings": [BLOCKED_CACHE_CONTENT_MISMATCH_FINDING],
})),
}],
};
let repair = repair_recoverable_db_state(
temp.path(),
&db_path,
&report,
None,
&FixerFilter::default(),
);
assert!(repair.blocked_cache_rebuilt);
let storage = SqliteStorage::open(&db_path).unwrap();
let rows = storage.execute_raw_query(
"SELECT blocked_by
FROM blocked_issues_cache
WHERE issue_id = 'bd-target'",
)?;
let blocked_by = rows
.first()
.and_then(|row| row.first())
.and_then(SqliteValue::as_text)
.unwrap_or("");
assert_eq!(blocked_by, "[\"bd-blocker:open\"]");
Ok(())
}
#[test]
fn test_repair_recoverable_db_state_rebuilds_ready_projection_mismatch() -> Result<()> {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let blocker = sample_issue("bd-blocker", "Blocker");
let target = sample_issue("bd-target", "Target");
storage.create_issue(&blocker, "tester")?;
storage.create_issue(&target, "tester")?;
storage.add_dependency(&target.id, &blocker.id, "blocks", "tester")?;
assert!(storage.ensure_blocked_cache_fresh()?);
storage.execute_test_sql(
"DELETE FROM blocked_issues_cache
WHERE issue_id = 'bd-target'",
)?;
drop(storage);
let report = DoctorReport {
ok: true,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "db.recoverable_anomalies".to_string(),
status: CheckStatus::Warn,
message: Some(READY_PROJECTION_CONTENT_MISMATCH_FINDING.to_string()),
details: Some(serde_json::json!({
"findings": [READY_PROJECTION_CONTENT_MISMATCH_FINDING],
})),
}],
};
let repair = repair_recoverable_db_state(
temp.path(),
&db_path,
&report,
None,
&FixerFilter::default(),
);
assert!(repair.blocked_cache_rebuilt);
let storage = SqliteStorage::open(&db_path).unwrap();
let rows = storage.execute_raw_query(
"SELECT blocked_by
FROM blocked_issues_cache
WHERE issue_id = 'bd-target'",
)?;
assert_eq!(rows.len(), 1);
Ok(())
}
#[test]
fn test_check_database_sidecars_accepts_wal_without_shm() -> Result<()> {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
fs::write(&db_path, b"sqlite-header-placeholder")?;
fs::write(
PathBuf::from(format!("{}-wal", db_path.to_string_lossy())),
b"synthetic wal",
)?;
let mut checks = Vec::new();
check_database_sidecars(&db_path, &mut checks)?;
let check = find_check(&checks, "db.sidecars").expect("sidecar check");
assert!(
matches!(check.status, CheckStatus::Ok),
"expected Ok for FrankenSQLite WAL-without-SHM, got {:?}",
check.status
);
assert!(
check.message.as_deref().is_some_and(|message| {
message.contains("WAL sidecar exists without a matching SHM sidecar")
}),
"unexpected sidecar message: {:?}",
check.message
);
Ok(())
}
#[test]
fn test_check_database_sidecars_rejects_invalid_layouts() -> Result<()> {
let assert_error = |db_path: &Path, expected: &str| -> Result<()> {
let mut checks = Vec::new();
check_database_sidecars(db_path, &mut checks)?;
let check = find_check(&checks, "db.sidecars").expect("sidecar check");
assert_eq!(check.status, CheckStatus::Error, "{check:?}");
assert!(
check
.message
.as_deref()
.is_some_and(|message| message.contains(expected)),
"expected `{expected}` in sidecar error: {check:?}"
);
Ok(())
};
let shm_only = TempDir::new()?;
let shm_only_db = shm_only.path().join("beads.db");
fs::write(&shm_only_db, b"sqlite-header-placeholder")?;
fs::write(
PathBuf::from(format!("{}-shm", shm_only_db.to_string_lossy())),
b"orphan shm",
)?;
assert_error(&shm_only_db, "SHM sidecar exists without a matching WAL")?;
let non_file = TempDir::new()?;
let non_file_db = non_file.path().join("beads.db");
fs::write(&non_file_db, b"sqlite-header-placeholder")?;
fs::create_dir(PathBuf::from(format!(
"{}-wal",
non_file_db.to_string_lossy()
)))?;
assert_error(&non_file_db, "directory instead of a regular file")?;
let dangling = TempDir::new()?;
let missing_db = dangling.path().join("beads.db");
fs::write(
PathBuf::from(format!("{}-wal", missing_db.to_string_lossy())),
b"dangling wal",
)?;
assert_error(&missing_db, "Database sidecars exist even though")?;
Ok(())
}
#[test]
fn sqlite_readonly_immutable_uri_escapes_uri_control_bytes() {
let uri = sqlite_readonly_immutable_uri(Path::new("/tmp/br doctor/a b?c#d%.db"));
assert_eq!(
uri,
"file:/tmp/br%20doctor/a%20b%3Fc%23d%25.db?mode=ro&immutable=1"
);
}
#[test]
fn sqlite_cli_integrity_does_not_create_shm_sidecar() -> Result<()> {
if Command::new("sqlite3").arg("-version").output().is_err() {
return Ok(());
}
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let wal_path = PathBuf::from(format!("{}-wal", db_path.to_string_lossy()));
let shm_path = PathBuf::from(format!("{}-shm", db_path.to_string_lossy()));
fs::write(&db_path, b"this is not a SQLite")?;
fs::write(&wal_path, b"synthetic wal")?;
assert!(!shm_path.exists(), "test precondition violated");
let result = sqlite_cli_integrity_messages(&db_path);
assert!(
result.is_err(),
"malformed database should still surface an integrity failure"
);
assert!(
!shm_path.exists(),
"sqlite3 integrity fallback must not create a live SHM sidecar"
);
Ok(())
}
#[test]
fn test_check_recovery_artifacts_warns_on_preserved_database_family() -> Result<()> {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
let db_path = beads_dir.join("beads.db");
fs::create_dir_all(&beads_dir)?;
fs::write(beads_dir.join("beads.db.bad_20260312T000000Z"), b"backup")?;
let recovery_dir = config::recovery_dir_for_db_path(&db_path, &beads_dir);
fs::create_dir_all(&recovery_dir)?;
fs::write(
recovery_dir.join("beads.db.20260312T000000Z.rebuild-failed"),
b"preserved",
)?;
let mut checks = Vec::new();
check_recovery_artifacts(&beads_dir, &db_path, &mut checks)?;
let check = find_check(&checks, "db.recovery_artifacts").expect("recovery artifact check");
assert!(matches!(check.status, CheckStatus::Warn));
let artifacts = check
.details
.as_ref()
.and_then(|details| details.get("artifacts"))
.and_then(serde_json::Value::as_array)
.expect("artifact list");
assert_eq!(artifacts.len(), 2);
Ok(())
}
#[test]
fn test_repair_recoverable_db_state_quarantines_orphan_shm_sidecar() -> Result<()> {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir)?;
let db_path = beads_dir.join("beads.db");
{
let _storage = SqliteStorage::open(&db_path)?;
}
let wal_path = PathBuf::from(format!("{}-wal", db_path.to_string_lossy()));
let shm_path = PathBuf::from(format!("{}-shm", db_path.to_string_lossy()));
let _ = fs::remove_file(&wal_path);
let _ = fs::remove_file(&shm_path);
fs::write(&shm_path, b"orphan shm")?;
let report = DoctorReport {
ok: false,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "db.sidecars".to_string(),
status: CheckStatus::Error,
message: Some("SHM sidecar exists without a matching WAL sidecar".to_string()),
details: None,
}],
};
let mut session =
DoctorRepairSession::new(temp.path(), false).expect("session builds");
let repair = repair_recoverable_db_state(
&beads_dir,
&db_path,
&report,
Some(&mut session),
&FixerFilter::default(),
);
assert!(
!repair.quarantined_artifacts.is_empty(),
"expected local repair to quarantine the orphan SHM sidecar"
);
let quarantine_path = session
.run
.root
.join("quarantine")
.join(".beads")
.join("beads.db-shm");
assert_ne!(
fs::read(&shm_path).ok().as_deref(),
Some(b"orphan shm".as_slice()),
"orphan SHM content should no longer be at the live sidecar path"
);
assert_eq!(
fs::read(&quarantine_path)?,
b"orphan shm",
"the quarantined copy should hold the orphan bytes"
);
assert!(
quarantine_path.is_file(),
"orphan SHM should be quarantined at {}",
quarantine_path.display()
);
assert!(
repair
.quarantined_artifacts
.iter()
.any(|path| path == &quarantine_path.display().to_string()),
"repair result should report the chokepoint quarantine path: {:?}",
repair.quarantined_artifacts
);
let actions = fs::read_to_string(&session.run.actions_file)?;
let action: serde_json::Value = serde_json::from_str(
actions
.lines()
.find(|line| !line.trim().is_empty())
.expect("actions.jsonl should contain the sidecar rename"),
)?;
assert_eq!(action["op"], "rename");
assert_eq!(action["fixer_id"], "doctor.database_sidecar_quarantine");
assert_eq!(action["path"], ".beads/beads.db-shm");
assert_eq!(
action["rename_to"].as_str(),
Some(quarantine_path.to_string_lossy().as_ref())
);
Ok(())
}
#[test]
fn test_repair_recoverable_db_state_skips_repair_for_valid_wal_without_shm() -> Result<()> {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir)?;
let db_path = beads_dir.join("beads.db");
fs::write(&db_path, b"not a sqlite database")?;
let wal_path = PathBuf::from(format!("{}-wal", db_path.to_string_lossy()));
fs::write(&wal_path, b"frankensqlite wal without shm")?;
let mut checks = Vec::new();
check_database_sidecars(&db_path, &mut checks)?;
assert_eq!(
find_check(&checks, "db.sidecars").map(|check| &check.status),
Some(&CheckStatus::Ok)
);
let report = DoctorReport {
ok: true,
workspace_health: None,
reliability_audit: None,
checks,
};
let repair = repair_recoverable_db_state(
&beads_dir,
&db_path,
&report,
None,
&FixerFilter::default(),
);
assert!(
repair.quarantined_artifacts.is_empty(),
"valid FrankenSQLite WAL should not be quarantined"
);
assert!(
wal_path.exists(),
"valid FrankenSQLite WAL should remain untouched"
);
Ok(())
}
#[test]
fn test_report_has_blocked_cache_stale_finding_detects_detail_entry() {
let report = DoctorReport {
ok: false,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "db.recoverable_anomalies".to_string(),
status: CheckStatus::Error,
message: Some("config contains duplicate rows".to_string()),
details: Some(serde_json::json!({
"findings": [
"config contains duplicate rows for key 'issue_prefix' (2 rows)",
BLOCKED_CACHE_STALE_FINDING,
]
})),
}],
};
assert!(report_has_blocked_cache_stale_finding(&report));
}
#[test]
fn test_report_has_blocked_cache_stale_finding_ignores_other_recoverable_errors() {
let report = DoctorReport {
ok: false,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "db.recoverable_anomalies".to_string(),
status: CheckStatus::Error,
message: Some("config contains duplicate rows".to_string()),
details: Some(serde_json::json!({
"findings": [
"config contains duplicate rows for key 'issue_prefix' (2 rows)",
"metadata contains duplicate rows for key 'project' (2 rows)",
]
})),
}],
};
assert!(!report_has_blocked_cache_stale_finding(&report));
}
#[test]
fn test_check_issue_write_probe_succeeds_on_healthy_database() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
{
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.create_issue(&sample_issue("bd-probe", "Probe me"), "tester")
.unwrap();
}
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_issue_write_probe(&conn, &mut checks);
let check = find_check(&checks, "db.write_probe").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
assert!(
check
.message
.as_deref()
.is_some_and(|message| message.contains("bd-probe")),
"unexpected check message: {:?}",
check.message
);
}
#[test]
fn test_inspect_existing_doctor_database_uses_snapshot_write_probe() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
{
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.create_issue(&sample_issue("bd-probe", "Probe me"), "tester")
.unwrap();
}
let lock_conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
lock_conn.execute("BEGIN IMMEDIATE").unwrap();
let mut checks = Vec::new();
inspect_existing_doctor_database(
&db_path,
None,
JsonlCountState::Missing,
DoctorInspectionMode::Full,
&mut checks,
);
let check = find_check(&checks, "db.write_probe").expect("check present");
assert!(
matches!(check.status, CheckStatus::Ok),
"unexpected snapshot write probe status: {:?}",
check.status
);
assert!(
check.message.as_deref().is_some_and(|message| {
message.contains("Rollback-only issue write succeeded for bd-probe")
}),
"unexpected check message: {:?}",
check.message
);
lock_conn.execute("ROLLBACK").unwrap();
}
#[test]
fn test_build_issue_write_probe_check_marks_rollback_failure_as_error() {
let check = build_issue_write_probe_check(
"bd-probe",
Ok(1),
Err(FrankenError::Internal("rollback failed".to_string())),
);
assert!(matches!(check.status, CheckStatus::Error));
assert!(
check
.message
.as_deref()
.is_some_and(|message| message.contains("rollback failed")),
"unexpected check message: {:?}",
check.message
);
assert_eq!(check.details.unwrap()["issue_id"], "bd-probe");
}
#[test]
fn test_build_issue_write_probe_check_marks_zero_row_update_as_error() {
let check = build_issue_write_probe_check("bd-probe", Ok(0), Ok(0));
assert!(matches!(check.status, CheckStatus::Error));
assert!(
check
.message
.as_deref()
.is_some_and(|message| message.contains("affected 0 rows")),
"unexpected check message: {:?}",
check.message
);
let details = check
.details
.expect("zero-row error should include details");
assert_eq!(details["issue_id"], "bd-probe");
assert_eq!(details["affected_rows"], 0);
}
#[test]
fn test_build_issue_write_probe_check_reports_zero_row_update_before_rollback_failure() {
let check = build_issue_write_probe_check(
"bd-probe",
Ok(0),
Err(FrankenError::Internal("rollback failed".to_string())),
);
assert!(matches!(check.status, CheckStatus::Error));
assert!(
check
.message
.as_deref()
.is_some_and(|message| message.contains("affected 0 rows")),
"unexpected check message: {:?}",
check.message
);
let details = check
.details
.expect("rollback failure should include details");
assert_eq!(details["issue_id"], "bd-probe");
assert_eq!(details["affected_rows"], 0);
assert!(
details["rollback_error"]
.as_str()
.is_some_and(|message| message.contains("rollback failed")),
"unexpected rollback error detail: {}",
details["rollback_error"]
);
}
#[test]
fn test_build_issue_write_probe_check_preserves_write_failure() {
let check = build_issue_write_probe_check(
"bd-probe",
Err(FrankenError::Internal("write failed".to_string())),
Ok(0),
);
assert!(matches!(check.status, CheckStatus::Error));
assert!(
check
.message
.as_deref()
.is_some_and(|message| message.contains("write failed")),
"unexpected check message: {:?}",
check.message
);
}
#[test]
fn test_repair_database_from_jsonl_restores_original_db_on_import_failure() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let jsonl_path = beads_dir.join("issues.jsonl");
{
let mut storage = SqliteStorage::open(&db_path).unwrap();
let issue = Issue {
id: "bd-keep".to_string(),
content_hash: None,
title: "Keep me".to_string(),
description: None,
design: None,
acceptance_criteria: None,
notes: None,
status: Status::Open,
priority: Priority::MEDIUM,
issue_type: IssueType::Task,
assignee: None,
owner: None,
estimated_minutes: None,
created_at: Utc::now(),
created_by: None,
updated_at: Utc::now(),
closed_at: None,
close_reason: None,
closed_by_session: None,
bypassed_policy: None,
bypass_reason: None,
policy_gates_fired: None,
due_at: None,
defer_until: None,
external_ref: None,
source_system: None,
source_repo: None,
source_repo_path: None,
agent_context: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: Vec::new(),
dependencies: Vec::new(),
comments: Vec::new(),
};
storage.create_issue(&issue, "tester").unwrap();
}
fs::write(&jsonl_path, "not valid json\n").unwrap();
let err = repair_database_from_jsonl(
&beads_dir,
&db_path,
&jsonl_path,
&config::CliOverrides::default(),
false,
None,
)
.unwrap_err();
let err_msg = err.to_string();
assert!(
err_msg.contains("invalid issue record")
|| err_msg.contains("Preflight checks failed")
|| err_msg.contains("Invalid JSON"),
"unexpected error: {err}"
);
let reopened = SqliteStorage::open(&db_path).unwrap();
let issue = reopened
.get_issue("bd-keep")
.unwrap()
.expect("original DB should be restored after failed repair");
assert_eq!(issue.title, "Keep me");
let recovery_dir = beads_dir.join(".br_recovery");
let backup_count =
fs::read_dir(&recovery_dir).map_or(0, |entries| entries.flatten().count());
assert_eq!(
backup_count, 0,
"preflight failures should not create recovery backups"
);
}
#[test]
fn test_repair_database_from_jsonl_refuses_conflict_markers_without_backup() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let jsonl_path = beads_dir.join("issues.jsonl");
{
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.create_issue(&sample_issue("bd-keep", "Keep me"), "tester")
.unwrap();
}
fs::write(
&jsonl_path,
"<<<<<<< HEAD\n{\"id\":\"bd-keep\"}\n=======\n{\"id\":\"bd-other\"}\n>>>>>>> branch\n",
)
.unwrap();
let err = repair_database_from_jsonl(
&beads_dir,
&db_path,
&jsonl_path,
&config::CliOverrides::default(),
false,
None,
)
.unwrap_err();
let err_msg = err.to_string();
assert!(
err_msg.contains(JSONL_REBUILD_AUTHORITY_ERROR_PREFIX)
&& err_msg.contains("merge conflict marker"),
"unexpected error: {err_msg}"
);
assert_eq!(jsonl_rebuild_failure_outcome(&err), "refused");
let reopened = SqliteStorage::open(&db_path).unwrap();
let issue = reopened
.get_issue("bd-keep")
.unwrap()
.expect("original DB should remain untouched after refused repair");
assert_eq!(issue.title, "Keep me");
let recovery_dir = beads_dir.join(".br_recovery");
let backup_count =
fs::read_dir(&recovery_dir).map_or(0, |entries| entries.flatten().count());
assert_eq!(
backup_count, 0,
"JSONL authority preflight failures should not create recovery backups"
);
}
#[test]
fn test_repair_database_from_jsonl_refuses_duplicate_ids_without_backup() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let jsonl_path = beads_dir.join("issues.jsonl");
{
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.create_issue(&sample_issue("bd-keep", "Keep me"), "tester")
.unwrap();
}
let issue = sample_issue("bd-dup", "Duplicate");
let issue_json = serde_json::to_string(&issue).unwrap();
fs::write(&jsonl_path, format!("{issue_json}\n{issue_json}\n")).unwrap();
let err = repair_database_from_jsonl(
&beads_dir,
&db_path,
&jsonl_path,
&config::CliOverrides::default(),
false,
None,
)
.unwrap_err();
let err_msg = err.to_string();
assert!(
err_msg.contains(JSONL_REBUILD_AUTHORITY_ERROR_PREFIX)
&& err_msg.contains("Duplicate issue id 'bd-dup'"),
"unexpected error: {err_msg}"
);
assert_eq!(jsonl_rebuild_failure_outcome(&err), "refused");
let reopened = SqliteStorage::open(&db_path).unwrap();
let issue = reopened
.get_issue("bd-keep")
.unwrap()
.expect("original DB should remain untouched after refused repair");
assert_eq!(issue.title, "Keep me");
let recovery_dir = beads_dir.join(".br_recovery");
let backup_count =
fs::read_dir(&recovery_dir).map_or(0, |entries| entries.flatten().count());
assert_eq!(
backup_count, 0,
"JSONL authority preflight failures should not create recovery backups"
);
}
#[test]
fn locked_database_failure_does_not_blame_the_jsonl() {
let err = BeadsError::Database(FrankenError::CannotOpen {
path: PathBuf::from("/tmp/ws/.beads/beads.db"),
});
assert_eq!(
err.to_string(),
"Database error: unable to open database file: '/tmp/ws/.beads/beads.db'",
"the engine wording this classifier exists for"
);
assert!(is_database_unavailable_failure(&err));
assert!(!is_jsonl_content_failure(&err));
for held in [
BeadsError::Database(FrankenError::DatabaseLocked {
path: PathBuf::from("/tmp/ws/.beads/beads.db"),
}),
BeadsError::Database(FrankenError::LockFailed {
detail: "F_SETLK contention beyond busy_timeout".to_string(),
}),
BeadsError::Database(FrankenError::Busy),
] {
assert!(
is_database_unavailable_failure(&held),
"should classify as unavailable: {held}"
);
}
let message = jsonl_rebuild_failure_message(&err);
assert!(
message.contains("the JSONL is not implicated"),
"message must clear the export: {message}"
);
assert!(
message.contains(".beads/.write.lock") && message.contains("br serve"),
"message must name the real remedy: {message}"
);
assert!(
!message.to_lowercase().contains("jsonl file may be corrupt")
&& !message.contains("manually editing"),
"message must not advise editing a healthy JSONL: {message}"
);
}
#[test]
fn jsonl_content_failure_still_points_at_the_export() {
let err = BeadsError::JsonlParse {
line: 12,
reason: "missing field `title`".to_string(),
};
assert!(is_jsonl_content_failure(&err));
assert!(!is_database_unavailable_failure(&err));
let message = jsonl_rebuild_failure_message(&err);
assert!(
message.contains(".beads/issues.jsonl") && message.contains("rejected records"),
"message should direct the operator at the export: {message}"
);
}
#[test]
fn unclassified_failure_attributes_no_cause() {
let err = BeadsError::Config("run directory is not writable".to_string());
assert!(!is_database_unavailable_failure(&err));
assert!(!is_jsonl_content_failure(&err));
let message = jsonl_rebuild_failure_message(&err);
assert!(
message.contains("No database writes were applied"),
"message should state the workspace is unchanged: {message}"
);
assert!(
!message.to_lowercase().contains("corrupt"),
"residual failures must not assert corruption: {message}"
);
}
#[test]
fn database_unavailable_classification_sees_through_context_wrappers() {
let wrapped = BeadsError::WithContext {
context: "rebuilding database family".to_string(),
source: Box::new(BeadsError::DatabaseLocked {
path: PathBuf::from("/tmp/ws/.beads/beads.db"),
}),
};
assert!(is_database_unavailable_failure(&wrapped));
}
#[test]
fn repair_lock_timeout_preserves_inspection_guidance() {
let wrapped = BeadsError::WithContext {
context: "acquiring repair publication authority".to_string(),
source: Box::new(BeadsError::WriteLockTimeout {
role: "JSONL-family write lock".to_string(),
path_display: "<redacted authority>".to_string(),
timeout_ms: 75,
retryable: false,
}),
};
assert!(is_database_unavailable_failure(&wrapped));
assert!(!is_jsonl_content_failure(&wrapped));
let message = jsonl_rebuild_failure_message(&wrapped);
assert!(message.contains("Inspect the operation state before retrying"));
assert!(message.contains("Do not delete the lock file"));
assert!(!message.contains("No database writes were applied"));
assert!(!message.contains("corrupt"));
assert!(!message.contains("The database could not be opened"));
}
#[test]
fn dry_run_skip_is_reported_as_skipped_not_as_a_failure() {
let err = BeadsError::Config(JSONL_REBUILD_DRY_RUN_SKIP_MESSAGE.to_string());
let outcome = jsonl_rebuild_failure_outcome(&err);
assert_eq!(outcome, "skipped");
assert!(jsonl_rebuild_error_is_self_describing(outcome));
let refused = BeadsError::Config(format!(
"{JSONL_REBUILD_AUTHORITY_ERROR_PREFIX}: found 1 merge conflict marker(s)"
));
assert!(jsonl_rebuild_error_is_self_describing(
jsonl_rebuild_failure_outcome(&refused)
));
assert!(!jsonl_rebuild_error_is_self_describing(
jsonl_rebuild_failure_outcome(&BeadsError::Config("disk full".to_string()))
));
}
#[test]
fn test_repair_database_from_jsonl_restores_issue_prefix_from_jsonl() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let jsonl_path = beads_dir.join("issues.jsonl");
let issue = Issue {
id: "proj-abc123".to_string(),
content_hash: None,
title: "Imported".to_string(),
description: None,
design: None,
acceptance_criteria: None,
notes: None,
status: Status::Open,
priority: Priority::MEDIUM,
issue_type: IssueType::Task,
assignee: None,
owner: None,
estimated_minutes: None,
created_at: Utc::now(),
created_by: None,
updated_at: Utc::now(),
closed_at: None,
close_reason: None,
closed_by_session: None,
bypassed_policy: None,
bypass_reason: None,
policy_gates_fired: None,
due_at: None,
defer_until: None,
external_ref: None,
source_system: None,
source_repo: None,
source_repo_path: None,
agent_context: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: Vec::new(),
dependencies: Vec::new(),
comments: Vec::new(),
};
fs::write(
&jsonl_path,
format!("{}\n", serde_json::to_string(&issue).unwrap()),
)
.unwrap();
let result = repair_database_from_jsonl(
&beads_dir,
&db_path,
&jsonl_path,
&config::CliOverrides::default(),
false,
None,
)
.unwrap();
assert_eq!(result.imported, 1);
let reopened = SqliteStorage::open(&db_path).unwrap();
assert_eq!(
reopened.get_config("issue_prefix").unwrap().as_deref(),
Some("proj")
);
}
#[test]
fn test_repair_database_from_jsonl_records_legacy_op_when_session_present() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let jsonl_path = beads_dir.join("issues.jsonl");
{
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.create_issue(&sample_issue("bd-old", "Old"), "tester")
.unwrap();
}
let pre_rebuild_db = fs::read(&db_path).unwrap();
let issue = sample_issue("bd-new", "New");
fs::write(
&jsonl_path,
format!("{}\n", serde_json::to_string(&issue).unwrap()),
)
.unwrap();
let mut session = DoctorRepairSession::new(temp.path(), false).unwrap();
let result = repair_database_from_jsonl(
&beads_dir,
&db_path,
&jsonl_path,
&config::CliOverrides::default(),
false,
Some(&mut session),
)
.unwrap();
assert_eq!(result.imported, 1);
let backup = session.run.root.join("backups").join(".beads/beads.db");
assert_eq!(fs::read(&backup).unwrap(), pre_rebuild_db);
let actions = fs::read_to_string(&session.run.actions_file).unwrap();
let mut found_jsonl_rebuild_action = false;
for line in actions.lines() {
let action: serde_json::Value = serde_json::from_str(line).unwrap();
if action["op"] == "legacy_op"
&& action["fixer_id"] == "doctor.jsonl_rebuild"
&& action["path"] == ".beads/beads.db"
&& action["ok"] == true
{
found_jsonl_rebuild_action = true;
}
}
assert!(
found_jsonl_rebuild_action,
"expected a successful doctor.jsonl_rebuild action; got {actions}"
);
let reopened = SqliteStorage::open(&db_path).unwrap();
assert!(
reopened.get_issue("bd-new").unwrap().is_some(),
"rebuilt database should contain the JSONL issue"
);
}
fn on_disk_db_family_names(beads_dir: &Path, db_file_name: &str) -> BTreeSet<String> {
let mut names = BTreeSet::new();
for entry in fs::read_dir(beads_dir).unwrap() {
let entry = entry.unwrap();
if !entry.file_type().unwrap().is_file() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with(db_file_name) {
names.insert(format!(".beads/{name}"));
}
}
names
}
#[test]
fn test_existing_sqlite_family_paths_for_legacy_op_includes_only_existing_sidecars() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let wal_path = sqlite_wal_sidecar_path(&db_path);
let shm_path = sqlite_shm_sidecar_path(&db_path);
let journal_path = sqlite_journal_sidecar_path(&db_path);
let wal_cert_paths = fsqlite_wal_cert_sidecar_paths(&db_path);
let wal_cert_path = wal_cert_paths[0].clone();
let wal_cert_head_path = wal_cert_paths[1].clone();
fs::write(&db_path, b"db").unwrap();
fs::write(&wal_path, b"wal").unwrap();
fs::write(&journal_path, b"journal").unwrap();
fs::write(&wal_cert_path, b"wal-cert").unwrap();
let paths = existing_sqlite_family_paths_for_legacy_op(&db_path);
assert_eq!(paths, vec![db_path, wal_path, journal_path, wal_cert_path]);
assert!(!paths.contains(&shm_path));
assert!(!paths.contains(&wal_cert_head_path));
}
#[test]
fn test_repair_via_vacuum_records_existing_sqlite_family_paths() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
{
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.create_issue(&sample_issue("bd-vac-family", "Vacuum family"), "tester")
.unwrap();
}
let wal_path = sqlite_wal_sidecar_path(&db_path);
let journal_path = sqlite_journal_sidecar_path(&db_path);
fs::write(&wal_path, b"wal-before-vacuum").unwrap();
fs::write(&journal_path, b"journal-before-vacuum").unwrap();
let write_authority = Arc::new(
crate::sync::blocking_database_family_write_lock_with_timeout(
&beads_dir,
&db_path,
Some(1_000),
)
.unwrap(),
);
write_authority.bind_database_inode_for_mutation().unwrap();
let mut session = DoctorRepairSession::new(temp.path(), false).unwrap();
let mut repair = LocalRepairResult::default();
repair_via_vacuum(&db_path, &mut repair, Some(&mut session), &write_authority);
let actions = fs::read_to_string(&session.run.actions_file).unwrap();
let action_paths: BTreeSet<String> = actions
.lines()
.map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
.filter(|action| {
action["op"] == "legacy_op" && action["fixer_id"] == "repair_via_vacuum"
})
.map(|action| action["path"].as_str().unwrap().to_string())
.collect();
assert_eq!(
action_paths,
on_disk_db_family_names(&beads_dir, "beads.db"),
"VACUUM legacy audit must cover the existing SQLite file family; actions={actions}"
);
assert_eq!(
fs::read(session.run.root.join("backups/.beads/beads.db-wal")).unwrap(),
b"wal-before-vacuum"
);
assert_eq!(
fs::read(session.run.root.join("backups/.beads/beads.db-journal")).unwrap(),
b"journal-before-vacuum"
);
}
#[test]
fn test_repair_partial_indexes_records_existing_sqlite_family_paths() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
{
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.create_issue(&sample_issue("bd-ri-family", "Index family"), "tester")
.unwrap();
}
let wal_path = sqlite_wal_sidecar_path(&db_path);
let journal_path = sqlite_journal_sidecar_path(&db_path);
fs::write(&wal_path, b"wal-prestate").unwrap();
fs::write(&journal_path, b"journal-prestate").unwrap();
let mut session = DoctorRepairSession::new(temp.path(), false).unwrap();
let mut repair = LocalRepairResult::default();
repair_partial_indexes(&db_path, &mut repair, Some(&mut session));
let actions = fs::read_to_string(&session.run.actions_file).unwrap();
let action_paths: BTreeSet<String> = actions
.lines()
.map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
.filter(|action| {
action["op"] == "legacy_op" && action["fixer_id"] == "repair_partial_indexes"
})
.map(|action| action["path"].as_str().unwrap().to_string())
.collect();
assert_eq!(
action_paths,
on_disk_db_family_names(&beads_dir, "beads.db"),
"REINDEX legacy audit must cover the existing SQLite file family; actions={actions}"
);
assert_eq!(
fs::read(session.run.root.join("backups/.beads/beads.db-wal")).unwrap(),
b"wal-prestate"
);
assert_eq!(
fs::read(session.run.root.join("backups/.beads/beads.db-journal")).unwrap(),
b"journal-prestate"
);
}
#[test]
fn test_repair_recoverable_db_state_records_existing_sqlite_family_paths() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
{
let mut storage = SqliteStorage::open(&db_path).unwrap();
let blocker = sample_issue("bd-cache-blocker", "Cache blocker");
let target = sample_issue("bd-cache-target", "Cache target");
storage.create_issue(&blocker, "tester").unwrap();
storage.create_issue(&target, "tester").unwrap();
storage
.add_dependency(&target.id, &blocker.id, "blocks", "tester")
.unwrap();
assert!(storage.ensure_blocked_cache_fresh().unwrap());
storage
.execute_test_sql(
"UPDATE blocked_issues_cache
SET blocked_by = '[\"bd-other:open\"]'
WHERE issue_id = 'bd-cache-target'",
)
.unwrap();
}
let wal_path = sqlite_wal_sidecar_path(&db_path);
fs::write(&wal_path, b"wal-before-blocked-cache").unwrap();
let expected_family = on_disk_db_family_names(&beads_dir, "beads.db");
let report = DoctorReport {
ok: true,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "db.recoverable_anomalies".to_string(),
status: CheckStatus::Warn,
message: Some(BLOCKED_CACHE_CONTENT_MISMATCH_FINDING.to_string()),
details: Some(serde_json::json!({
"findings": [BLOCKED_CACHE_CONTENT_MISMATCH_FINDING],
})),
}],
};
let mut session = DoctorRepairSession::new(temp.path(), false).unwrap();
let repair = repair_recoverable_db_state(
&beads_dir,
&db_path,
&report,
Some(&mut session),
&FixerFilter::default(),
);
assert!(repair.blocked_cache_rebuilt);
let actions = fs::read_to_string(&session.run.actions_file).unwrap();
let action_paths: BTreeSet<String> = actions
.lines()
.map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
.filter(|action| {
action["op"] == "legacy_op" && action["fixer_id"] == "repair_recoverable_db_state"
})
.map(|action| action["path"].as_str().unwrap().to_string())
.collect();
assert_eq!(
action_paths, expected_family,
"blocked-cache rebuild legacy audit must cover the existing SQLite file family; actions={actions}"
);
assert_eq!(
fs::read(session.run.root.join("backups/.beads/beads.db-wal")).unwrap(),
b"wal-before-blocked-cache",
"the pre-repair WAL must be backed up verbatim before it is quarantined"
);
}
#[test]
fn test_repair_recoverable_db_state_skips_missing_db() {
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = temp.path().join("missing.db");
let report = DoctorReport {
ok: false,
workspace_health: None,
reliability_audit: None,
checks: Vec::new(),
};
let local_repair = repair_recoverable_db_state(
&beads_dir,
&db_path,
&report,
None,
&FixerFilter::default(),
);
assert!(!local_repair.blocked_cache_rebuilt);
}
#[test]
fn report_has_warn_level_page_anomaly_matches_orphan_page_warn() {
for msg in [
"page 55 is never used",
"*** in database main ***; Page 55: never used; Page 264: never used",
"database disk image is malformed",
"Tree 28 page 28: free space corruption",
] {
let report = DoctorReport {
ok: true, workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "sqlite.integrity_check".to_string(),
status: CheckStatus::Warn,
message: Some(msg.to_string()),
details: None,
}],
};
assert!(
report_has_warn_level_page_anomaly(&report),
"expected WARN-level page anomaly to match: {msg:?}"
);
}
let report = DoctorReport {
ok: true,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "sqlite3.integrity_check".to_string(),
status: CheckStatus::Warn,
message: Some("Page 55: never used".to_string()),
details: None,
}],
};
assert!(report_has_warn_level_page_anomaly(&report));
}
#[test]
fn report_has_warn_level_page_anomaly_ignores_non_page_warns() {
let report = DoctorReport {
ok: true,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "sqlite.integrity_check".to_string(),
status: CheckStatus::Warn,
message: Some("row 42 missing from index idx_foo".to_string()),
details: None,
}],
};
assert!(!report_has_warn_level_page_anomaly(&report));
let report = DoctorReport {
ok: true,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "sqlite.integrity_check".to_string(),
status: CheckStatus::Warn,
message: Some("out of order index idx_foo".to_string()),
details: None,
}],
};
assert!(!report_has_warn_level_page_anomaly(&report));
}
#[test]
fn report_has_warn_level_page_anomaly_ignores_error_level_findings() {
let report = DoctorReport {
ok: false,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "sqlite.integrity_check".to_string(),
status: CheckStatus::Error,
message: Some("page 55 is never used".to_string()),
details: None,
}],
};
assert!(!report_has_warn_level_page_anomaly(&report));
}
#[test]
fn report_has_warn_level_page_anomaly_ignores_non_integrity_checks() {
let report = DoctorReport {
ok: true,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "db.sidecars".to_string(),
status: CheckStatus::Warn,
message: Some("WAL sidecar exists without a matching SHM sidecar".to_string()),
details: None,
}],
};
assert!(!report_has_warn_level_page_anomaly(&report));
}
#[test]
fn warning_repair_verified_requires_repaired_page_warning_to_clear() {
let dirty_report = DoctorReport {
ok: true,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "sqlite3.integrity_check".to_string(),
status: CheckStatus::Warn,
message: Some("Page 55: never used".to_string()),
details: None,
}],
};
assert!(!warning_repair_verified(&dirty_report, false, false));
let clean_report = DoctorReport {
ok: true,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "sqlite3.integrity_check".to_string(),
status: CheckStatus::Ok,
message: None,
details: None,
}],
};
assert!(warning_repair_verified(&clean_report, false, false));
}
#[test]
fn jsonl_rebuild_verification_tolerates_benign_post_rebuild_warnings() {
let report = DoctorReport {
ok: true,
workspace_health: None,
reliability_audit: None,
checks: vec![
CheckResult {
name: "rust_log".to_string(),
status: CheckStatus::Warn,
message: Some("RUST_LOG=beads_rust=debug is verbose".to_string()),
details: None,
},
CheckResult {
name: "db.recovery_artifacts".to_string(),
status: CheckStatus::Warn,
message: Some("Preserved recovery artifacts remain (2 item(s))".to_string()),
details: None,
},
CheckResult {
name: "sqlite.integrity_check".to_string(),
status: CheckStatus::Ok,
message: None,
details: None,
},
],
};
assert!(!repair_report_verified(&report));
assert!(jsonl_rebuild_repair_verified(&report, &[]));
}
#[test]
fn jsonl_rebuild_verification_still_fails_on_real_defects() {
let error_report = DoctorReport {
ok: false,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "db.open".to_string(),
status: CheckStatus::Error,
message: Some("Failed to open DB snapshot for inspection".to_string()),
details: None,
}],
};
assert!(!jsonl_rebuild_repair_verified(&error_report, &[]));
let page_warn_report = DoctorReport {
ok: true,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "sqlite3.integrity_check".to_string(),
status: CheckStatus::Warn,
message: Some("Page 55: never used".to_string()),
details: None,
}],
};
assert!(!jsonl_rebuild_repair_verified(&page_warn_report, &[]));
let sidecar_error_report = DoctorReport {
ok: false,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "db.sidecars".to_string(),
status: CheckStatus::Error,
message: Some("WAL sidecar is a directory instead of a regular file".to_string()),
details: None,
}],
};
assert!(!jsonl_rebuild_repair_verified(&sidecar_error_report, &[]));
}
fn sync_metadata_warn(pending_import: bool, pending_export: bool) -> CheckResult {
CheckResult {
name: "sync.metadata".to_string(),
status: CheckStatus::Warn,
message: Some("Local changes exist but no export is recorded".to_string()),
details: Some(serde_json::json!({
"pending_import": pending_import,
"pending_export": pending_export,
"jsonl_newer": pending_import,
"db_newer": pending_export,
"dirty_issues": 1,
})),
}
}
fn report_with(checks: Vec<CheckResult>) -> DoctorReport {
DoctorReport {
ok: true,
workspace_health: None,
reliability_audit: None,
checks,
}
}
#[test]
fn jsonl_rebuild_verification_tolerates_pending_export_after_rebuild() {
let report = report_with(vec![sync_metadata_warn(false, true)]);
assert!(jsonl_rebuild_repair_verified(&report, &[]));
assert!(!repair_report_verified(&report));
}
#[test]
fn jsonl_rebuild_verification_rejects_pending_import_after_rebuild() {
assert!(!jsonl_rebuild_repair_verified(
&report_with(vec![sync_metadata_warn(true, false)]),
&[]
));
assert!(!jsonl_rebuild_repair_verified(
&report_with(vec![sync_metadata_warn(true, true)]),
&[]
));
}
#[test]
fn sync_metadata_benignity_fails_closed_without_drift_details() {
let no_details = CheckResult {
name: "sync.metadata".to_string(),
status: CheckStatus::Warn,
message: Some("Local changes pending export".to_string()),
details: None,
};
assert!(!is_pending_export_only_sync_metadata(&no_details));
assert!(!jsonl_rebuild_repair_verified(
&report_with(vec![no_details]),
&[]
));
let half_details = CheckResult {
name: "sync.metadata".to_string(),
status: CheckStatus::Warn,
message: Some("Local changes pending export".to_string()),
details: Some(serde_json::json!({ "pending_export": true })),
};
assert!(!is_pending_export_only_sync_metadata(&half_details));
}
fn count_divergence_warn(only_db: &[&str], only_jsonl: &[&str]) -> CheckResult {
CheckResult {
name: "counts.db_vs_jsonl".to_string(),
status: CheckStatus::Warn,
message: Some("DB and JSONL counts differ".to_string()),
details: Some(serde_json::json!({
"db": 1 + only_db.len(),
"jsonl": 1 + only_jsonl.len(),
"id_delta": {
"only_db_count": only_db.len(),
"only_jsonl_count": only_jsonl.len(),
"both_count": 1,
"only_db": only_db,
"only_jsonl": only_jsonl,
"preview_limit": 50,
},
})),
}
}
#[test]
fn jsonl_rebuild_verification_tolerates_preserved_dirty_count_divergence() {
let preserved = vec!["bd-kept".to_string()];
let report = report_with(vec![count_divergence_warn(&["bd-kept"], &[])]);
assert!(jsonl_rebuild_repair_verified(&report, &preserved));
assert!(!jsonl_rebuild_repair_verified(&report, &[]));
}
#[test]
fn preserved_dirty_count_divergence_fails_closed() {
let preserved = vec!["bd-kept".to_string()];
assert!(!jsonl_rebuild_repair_verified(
&report_with(vec![count_divergence_warn(&["bd-kept", "bd-mystery"], &[])]),
&preserved
));
assert!(!jsonl_rebuild_repair_verified(
&report_with(vec![count_divergence_warn(&["bd-kept"], &["bd-lost"])]),
&preserved
));
let bare_warn = CheckResult {
name: "counts.db_vs_jsonl".to_string(),
status: CheckStatus::Warn,
message: Some("DB and JSONL counts differ".to_string()),
details: Some(serde_json::json!({ "db": 2, "jsonl": 1 })),
};
assert!(!jsonl_rebuild_repair_verified(
&report_with(vec![bare_warn]),
&preserved
));
let mut clipped = count_divergence_warn(&["bd-kept"], &[]);
if let Some(details) = clipped.details.as_mut() {
details["id_delta"]["only_db_count"] = serde_json::json!(2);
}
assert!(!jsonl_rebuild_repair_verified(
&report_with(vec![clipped]),
&preserved
));
}
#[test]
fn fresh_recovery_backups_are_not_classified_as_stale() {
let mut anomalies = Vec::new();
append_doctor_check_anomalies(
&CheckResult {
name: "db.recovery_artifacts".to_string(),
status: CheckStatus::Warn,
message: Some(
"Preserved recovery artifacts remain for this database family (3 item(s))"
.to_string(),
),
details: None,
},
&mut anomalies,
);
assert!(
anomalies.is_empty(),
"a fresh preserved backup is the success signal of a repair, not a \
workspace anomaly: {anomalies:?}"
);
let mut aged_anomalies = Vec::new();
append_doctor_check_anomalies(
&CheckResult {
name: "db.recovery_artifacts.aged".to_string(),
status: CheckStatus::Warn,
message: Some("2 recovery artifact(s) older than 30 days".to_string()),
details: None,
},
&mut aged_anomalies,
);
assert_eq!(aged_anomalies, vec![AnomalyClass::StaleRecoveryArtifacts]);
}
#[test]
fn warning_repair_verified_rejects_page_warning_introduced_by_other_repair() {
let report = DoctorReport {
ok: true,
workspace_health: None,
reliability_audit: None,
checks: vec![
CheckResult {
name: "db.recoverable_anomalies".to_string(),
status: CheckStatus::Ok,
message: None,
details: None,
},
CheckResult {
name: "sqlite3.integrity_check".to_string(),
status: CheckStatus::Warn,
message: Some("Page 55: never used".to_string()),
details: None,
},
],
};
assert!(!warning_repair_verified(&report, true, false));
}
#[test]
fn repair_report_verified_rejects_residual_warn_findings() {
let dirty_report = DoctorReport {
ok: true,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "sync.metadata".to_string(),
status: CheckStatus::Warn,
message: Some("Local changes pending export".to_string()),
details: Some(serde_json::json!({
"finding_id": "fm-state_files-dirty-flag-divergence",
"dirty_issues": 1,
"pending_export": true,
})),
}],
};
assert!(
!repair_report_verified(&dirty_report),
"repair verification must not claim success while WARN findings remain"
);
let clean_report = DoctorReport {
ok: true,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "sync.metadata".to_string(),
status: CheckStatus::Ok,
message: None,
details: Some(serde_json::json!({
"dirty_issues": 0,
"pending_export": false,
})),
}],
};
assert!(repair_report_verified(&clean_report));
}
fn closed_issue_with_reason(id: &str, title: &str, reason: &str) -> Issue {
let mut issue = sample_issue(id, title);
issue.status = Status::Closed;
issue.closed_at = Some(Utc::now());
issue.close_reason = Some(reason.to_string());
issue
}
#[test]
fn check_suspect_close_reasons_finds_matching_bead() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let bad = closed_issue_with_reason(
"br-bad",
"Bad close",
"Implemented foo. Forced close due to cycle.",
);
storage.create_issue(&bad, "tester").unwrap();
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_suspect_close_reasons(&conn, &mut checks);
let check = find_check(&checks, "audit.suspect_close_reasons").expect("check present");
assert!(
matches!(check.status, CheckStatus::Warn),
"expected Warn, got {:?}",
check.status
);
let details = check.details.as_ref().expect("details present");
let matches_arr = details["matches"].as_array().expect("matches array");
assert_eq!(matches_arr.len(), 1);
assert_eq!(matches_arr[0]["bead_id"], "br-bad");
}
#[test]
fn check_suspect_close_reasons_skips_beads_with_historical_label() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let mut triaged = closed_issue_with_reason(
"br-triaged",
"Triaged",
"Implemented bar. Forced close due to cycle.",
);
triaged.labels = vec!["audit-historical-cycle-close-2026-05-09".to_string()];
storage.create_issue(&triaged, "tester").unwrap();
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_suspect_close_reasons(&conn, &mut checks);
let check = find_check(&checks, "audit.suspect_close_reasons").expect("check present");
assert!(
matches!(check.status, CheckStatus::Ok),
"historical-label bead must NOT trigger warn; got {:?}",
check.status
);
}
#[test]
fn check_suspect_close_reasons_rejects_malformed_historical_label() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let mut malformed = closed_issue_with_reason(
"br-malformed",
"Malformed label",
"Implemented bar. Forced close due to cycle.",
);
malformed.labels = vec!["audit-historical-cycle-close-not-a-date".to_string()];
storage.create_issue(&malformed, "tester").unwrap();
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_suspect_close_reasons(&conn, &mut checks);
let check = find_check(&checks, "audit.suspect_close_reasons").expect("check present");
assert!(
matches!(check.status, CheckStatus::Warn),
"malformed historical-label bead must trigger warn; got {:?}",
check.status
);
let details = check.details.as_ref().expect("details present");
let matches_arr = details["matches"].as_array().expect("matches array");
assert_eq!(matches_arr.len(), 1);
assert_eq!(matches_arr[0]["bead_id"], "br-malformed");
}
#[test]
fn check_suspect_close_reasons_does_not_honor_undocumented_allowed_label() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let mut suspect = closed_issue_with_reason(
"br-allowed",
"Undocumented allow label",
"Implemented baz. Forced close due to cycle.",
);
suspect.labels = vec!["audit-suspect-allowed".to_string()];
storage.create_issue(&suspect, "tester").unwrap();
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_suspect_close_reasons(&conn, &mut checks);
let check = find_check(&checks, "audit.suspect_close_reasons").expect("check present");
assert!(
matches!(check.status, CheckStatus::Warn),
"undocumented allow label must trigger warn; got {:?}",
check.status
);
let details = check.details.as_ref().expect("details present");
let matches_arr = details["matches"].as_array().expect("matches array");
assert_eq!(matches_arr.len(), 1);
assert_eq!(matches_arr[0]["bead_id"], "br-allowed");
}
#[test]
fn check_suspect_close_reasons_returns_ok_when_no_matches() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let normal = closed_issue_with_reason(
"br-normal",
"Normal close",
"Verified by tests/e2e_basic_lifecycle.rs::list_basic.",
);
storage.create_issue(&normal, "tester").unwrap();
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_suspect_close_reasons(&conn, &mut checks);
let check = find_check(&checks, "audit.suspect_close_reasons").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn check_suspect_close_reasons_skips_default_allowlist() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let auto = closed_issue_with_reason(
"br-auto",
"Auto-closed",
"auto-closed by doctor: stale recovery artifact",
);
storage.create_issue(&auto, "tester").unwrap();
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let mut checks = Vec::new();
check_suspect_close_reasons(&conn, &mut checks);
let check = find_check(&checks, "audit.suspect_close_reasons").expect("check present");
assert!(
matches!(check.status, CheckStatus::Ok),
"allowlist entry must NOT trigger warn; got {:?}",
check.status
);
}
fn make_doctor_fixture(tmp: &Path) -> (PathBuf, PathBuf, DoctorReport) {
let beads_dir = tmp.join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let gitignore = tmp.join(".gitignore");
fs::write(&gitignore, b"keep-me\n.beads/\n").unwrap();
let report = DoctorReport {
ok: false,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "gitignore.beads_inner".to_string(),
status: CheckStatus::Warn,
message: Some("offending pattern".to_string()),
details: None,
}],
};
(beads_dir, gitignore, report)
}
fn quiet_ctx() -> OutputContext {
OutputContext::from_output_format(crate::cli::OutputFormat::Json, false, true)
}
#[test]
fn wp3_repair_dry_run_writes_no_files() {
let tmp = TempDir::new().unwrap();
let (beads_dir, gitignore, report) = make_doctor_fixture(tmp.path());
let mut session =
DoctorRepairSession::new(tmp.path(), true).expect("session must build");
let post_session_baseline = fs::read(&gitignore).unwrap();
let actions_path = session.run.actions_file.clone();
let result =
fix_root_gitignore_if_warned(&beads_dir, &report, &quiet_ctx(), Some(&mut session));
assert!(result, "fixer must report success even in dry-run");
assert_eq!(
fs::read(&gitignore).unwrap(),
post_session_baseline,
"dry-run must not mutate the target file"
);
let actions = fs::read(&actions_path).unwrap_or_default();
assert!(
actions.is_empty(),
"dry-run must not append to actions.jsonl; got {} bytes",
actions.len()
);
}
#[test]
fn wp3_repair_writes_actions_jsonl() {
let tmp = TempDir::new().unwrap();
let (beads_dir, _gitignore, report) = make_doctor_fixture(tmp.path());
let mut session = DoctorRepairSession::new(tmp.path(), false)
.expect("session must build");
let actions_path = session.run.actions_file.clone();
let result =
fix_root_gitignore_if_warned(&beads_dir, &report, &quiet_ctx(), Some(&mut session));
assert!(result, "fixer must report success");
let actions = fs::read_to_string(&actions_path).expect("actions.jsonl readable");
let lines: Vec<&str> = actions.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
lines.len(),
1,
"exactly one action recorded; got {actions:?}"
);
let value: serde_json::Value = serde_json::from_str(lines[0]).expect("valid JSON line");
assert_eq!(value["op"], "write_file");
assert_eq!(value["fixer_id"], "doctor.gitignore_repair");
assert_eq!(value["ok"], true);
assert!(
value["before_hash"]
.as_str()
.unwrap()
.starts_with("sha256:"),
"before_hash present"
);
assert!(
value["after_hash"].as_str().unwrap().starts_with("sha256:"),
"after_hash present"
);
}
#[test]
fn wp3_repair_skips_locked_root_gitignore() {
let tmp = TempDir::new().unwrap();
let (beads_dir, gitignore, mut report) = make_doctor_fixture(tmp.path());
report.checks.push(CheckResult {
name: "permissions.root_gitignore".to_string(),
status: CheckStatus::Warn,
message: Some("root .gitignore is not owner-writable".to_string()),
details: None,
});
let mut session = DoctorRepairSession::new(tmp.path(), false)
.expect("session must build");
fs::set_permissions(&gitignore, fs::Permissions::from_mode(0o444)).unwrap();
let before = fs::read(&gitignore).unwrap();
let actions_path = session.run.actions_file.clone();
let result =
fix_root_gitignore_if_warned(&beads_dir, &report, &quiet_ctx(), Some(&mut session));
assert!(!result, "locked root .gitignore repair must be skipped");
assert_eq!(
fs::read(&gitignore).unwrap(),
before,
"locked root .gitignore must not be rewritten"
);
assert!(
fs::read(&actions_path).unwrap_or_default().is_empty(),
"skipped repair must not append an action"
);
fs::set_permissions(&gitignore, fs::Permissions::from_mode(0o644)).unwrap();
}
#[test]
fn wp3_repair_creates_undo_sh() {
let tmp = TempDir::new().unwrap();
let session = DoctorRepairSession::new(tmp.path(), false)
.expect("session must build");
run_dir::write_undo_sh(&session.run).expect("undo.sh write");
let undo = &session.run.undo_script;
assert!(undo.is_file(), "undo.sh exists at {}", undo.display());
let mode = fs::metadata(undo).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o755,
"undo.sh must be world-executable; got {mode:o}"
);
let body = fs::read_to_string(undo).unwrap();
assert!(body.starts_with("#!/usr/bin/env bash"));
}
#[test]
fn wp3_quarantine_renames_not_deletes() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let orphan_wal = beads_dir.join("beads.db-wal");
fs::write(&orphan_wal, b"orphan-wal-bytes").unwrap();
let mut session = DoctorRepairSession::new(tmp.path(), false)
.expect("session must build");
session.set_fixer("doctor.test.quarantine");
let quarantine_dst = session.run.root.join("quarantine/.beads/beads.db-wal");
let result = chokepoint::mutate(
&session.ctx,
&orphan_wal,
Op::Rename {
to: quarantine_dst.clone(),
},
)
.expect("rename into quarantine must succeed");
assert!(result.ok);
assert!(!orphan_wal.exists(), "source must be moved out of .beads/");
assert!(
quarantine_dst.exists(),
"destination must exist at {}",
quarantine_dst.display()
);
assert_eq!(fs::read(&quarantine_dst).unwrap(), b"orphan-wal-bytes");
let actions = fs::read_to_string(&session.run.actions_file).unwrap();
let line = actions
.lines()
.find(|l| !l.is_empty())
.expect("actions.jsonl has a line");
let value: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(value["op"], "rename");
assert!(value.get("rename_to").is_some(), "rename_to recorded");
}
#[test]
fn check_permissions_beads_dir_reports_ok_for_temp_beads_dir() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let mut checks = Vec::new();
check_permissions_beads_dir(&beads_dir, &mut checks);
let check = find_check(&checks, "permissions.beads_dir").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok), "{check:?}");
}
#[test]
fn check_permissions_beads_dir_defers_when_metadata_unavailable() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads-missing");
let mut checks = Vec::new();
check_permissions_beads_dir(&beads_dir, &mut checks);
let check = find_check(&checks, "permissions.beads_dir").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok), "{check:?}");
assert!(
check
.message
.as_deref()
.is_some_and(|message| message.contains("deferring to beads_dir")),
"{check:?}"
);
}
#[test]
fn check_routes_jsonl_missing_is_ok() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let mut checks = Vec::new();
check_routes_jsonl(&beads_dir, &mut checks);
let check = find_check(&checks, "routes_jsonl").expect("routes_jsonl present");
assert!(
matches!(check.status, CheckStatus::Ok),
"missing routes.jsonl must be Ok (routing is optional); got {:?}",
check.status
);
}
#[test]
fn check_routes_jsonl_well_formed_is_ok() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let routes = beads_dir.join("routes.jsonl");
fs::write(
&routes,
"{\"prefix\":\"api-\",\"path\":\"../api\"}\n\
{\"prefix\":\"ops-\",\"path\":\"/srv/projects/ops/.beads\"}\n",
)
.unwrap();
let mut checks = Vec::new();
check_routes_jsonl(&beads_dir, &mut checks);
let check = find_check(&checks, "routes_jsonl").expect("routes_jsonl present");
assert!(
matches!(check.status, CheckStatus::Ok),
"well-formed routes.jsonl must be Ok; got {:?}",
check.status
);
let details = check.details.as_ref().expect("details present");
assert_eq!(details["valid_count"], 2);
}
#[test]
fn check_routes_jsonl_parse_error_warns() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let routes = beads_dir.join("routes.jsonl");
fs::write(
&routes,
"{\"prefix\":\"api-\",\"path\":\"../api\"}\n\
{not json at all}\n",
)
.unwrap();
let mut checks = Vec::new();
check_routes_jsonl(&beads_dir, &mut checks);
let check = find_check(&checks, "routes_jsonl").expect("routes_jsonl present");
assert!(
matches!(check.status, CheckStatus::Warn),
"malformed line must trigger Warn; got {:?}",
check.status
);
let details = check.details.as_ref().expect("details present");
let bad = details["malformed_lines"].as_array().unwrap();
assert_eq!(bad.len(), 1);
assert_eq!(bad[0]["line"], 2);
assert!(
bad[0]["reason"].as_str().unwrap().contains("parse_error"),
"reason must name parse_error"
);
}
#[test]
fn check_routes_jsonl_missing_prefix_field_warns() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let routes = beads_dir.join("routes.jsonl");
fs::write(&routes, "{\"path\":\"../api\"}\n").unwrap();
let mut checks = Vec::new();
check_routes_jsonl(&beads_dir, &mut checks);
let check = find_check(&checks, "routes_jsonl").expect("routes_jsonl present");
assert!(matches!(check.status, CheckStatus::Warn));
let bad = check.details.as_ref().unwrap()["malformed_lines"]
.as_array()
.unwrap();
assert!(
bad[0]["reason"]
.as_str()
.unwrap()
.contains("missing `prefix`")
);
}
#[test]
fn check_routes_jsonl_non_string_fields_warn_clearly() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let routes = beads_dir.join("routes.jsonl");
fs::write(&routes, "{\"prefix\":42,\"path\":[\"../api\"]}\n").unwrap();
let mut checks = Vec::new();
check_routes_jsonl(&beads_dir, &mut checks);
let check = find_check(&checks, "routes_jsonl").expect("routes_jsonl present");
assert!(matches!(check.status, CheckStatus::Warn));
let bad = check.details.as_ref().unwrap()["malformed_lines"]
.as_array()
.unwrap();
let reason = bad[0]["reason"].as_str().unwrap();
assert!(reason.contains("non-string `prefix`"), "{reason}");
assert!(reason.contains("non-string `path`"), "{reason}");
}
#[test]
fn check_routes_jsonl_empty_path_warns() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let routes = beads_dir.join("routes.jsonl");
fs::write(&routes, "{\"prefix\":\"api-\",\"path\":\"\"}\n").unwrap();
let mut checks = Vec::new();
check_routes_jsonl(&beads_dir, &mut checks);
let check = find_check(&checks, "routes_jsonl").expect("routes_jsonl present");
assert!(matches!(check.status, CheckStatus::Warn));
let bad = check.details.as_ref().unwrap()["malformed_lines"]
.as_array()
.unwrap();
assert!(bad[0]["reason"].as_str().unwrap().contains("empty `path`"));
}
#[test]
fn check_routes_jsonl_skips_blank_lines() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let routes = beads_dir.join("routes.jsonl");
fs::write(
&routes,
"\n\
{\"prefix\":\"api-\",\"path\":\"../api\"}\n\
\n\
{\"prefix\":\"ops-\",\"path\":\"../ops\"}\n\
\n",
)
.unwrap();
let mut checks = Vec::new();
check_routes_jsonl(&beads_dir, &mut checks);
let check = find_check(&checks, "routes_jsonl").expect("routes_jsonl present");
assert!(matches!(check.status, CheckStatus::Ok));
let details = check.details.as_ref().expect("details present");
assert_eq!(
details["valid_count"], 2,
"blank lines must not be counted as routes"
);
}
#[test]
fn check_routes_jsonl_skips_comment_lines() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let routes = beads_dir.join("routes.jsonl");
fs::write(
&routes,
"# local routes\n\
{\"prefix\":\"api-\",\"path\":\"../api\"}\n\
\n\
# town-level route intentionally omitted in this fixture\n",
)
.unwrap();
let mut checks = Vec::new();
check_routes_jsonl(&beads_dir, &mut checks);
let check = find_check(&checks, "routes_jsonl").expect("routes_jsonl present");
assert!(matches!(check.status, CheckStatus::Ok));
let details = check.details.as_ref().expect("details present");
assert_eq!(
details["valid_count"], 1,
"comment lines must not be counted as routes"
);
}
#[test]
fn check_routes_targets_resolve_missing_routes_file_is_ok() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let mut checks = Vec::new();
check_routes_targets_resolve(&beads_dir, &mut checks);
let check = find_check(&checks, "routes.targets").expect("routes.targets present");
assert!(matches!(check.status, CheckStatus::Ok), "{check:?}");
}
#[test]
fn check_routes_targets_resolve_project_root_route_is_ok() {
let tmp = TempDir::new().unwrap();
let project_root = tmp.path();
let beads_dir = project_root.join(".beads");
let api_beads = project_root.join("api").join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::create_dir_all(&api_beads).unwrap();
fs::write(
beads_dir.join("routes.jsonl"),
"{\"prefix\":\"api-\",\"path\":\"api\"}\n",
)
.unwrap();
let mut checks = Vec::new();
check_routes_targets_resolve(&beads_dir, &mut checks);
let check = find_check(&checks, "routes.targets").expect("routes.targets present");
assert!(matches!(check.status, CheckStatus::Ok), "{check:?}");
let details = check.details.as_ref().expect("details present");
assert_eq!(details["route_count"], 1);
assert_eq!(details["resolved_count"], 1);
}
#[test]
fn check_routes_targets_resolve_missing_target_warns() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(
beads_dir.join("routes.jsonl"),
"{\"prefix\":\"api-\",\"path\":\"missing\"}\n",
)
.unwrap();
let mut checks = Vec::new();
check_routes_targets_resolve(&beads_dir, &mut checks);
let check = find_check(&checks, "routes.targets").expect("routes.targets present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
let details = check.details.as_ref().expect("details present");
let unresolved = details["unresolved_routes"].as_array().unwrap();
assert_eq!(unresolved.len(), 1);
assert!(
unresolved[0]["reason"]
.as_str()
.unwrap()
.contains("Redirect target not found"),
"{unresolved:?}"
);
}
#[test]
fn check_routes_targets_resolve_town_root_routes() {
let tmp = TempDir::new().unwrap();
let town_root = tmp.path().join("town");
let project_root = town_root.join("projects").join("client");
let beads_dir = project_root.join(".beads");
let town_beads_dir = town_root.join(".beads");
fs::create_dir_all(town_root.join("mayor")).unwrap();
fs::create_dir_all(&beads_dir).unwrap();
fs::create_dir_all(&town_beads_dir).unwrap();
fs::write(town_root.join("mayor").join("town.json"), "{}").unwrap();
fs::write(
town_beads_dir.join("routes.jsonl"),
"{\"prefix\":\"ghost-\",\"path\":\"missing-project\"}\n",
)
.unwrap();
let mut checks = Vec::new();
check_routes_targets_resolve(&beads_dir, &mut checks);
let check = find_check(&checks, "routes.targets").expect("routes.targets present");
assert!(matches!(check.status, CheckStatus::Warn), "{check:?}");
let details = check.details.as_ref().expect("details present");
assert_eq!(details["route_count"], 1);
let unresolved = details["unresolved_routes"].as_array().unwrap();
assert_eq!(unresolved.len(), 1);
assert_eq!(unresolved[0]["prefix"], "ghost-");
assert_eq!(
unresolved[0]["target"],
town_root
.join("missing-project/.beads")
.display()
.to_string()
);
assert_eq!(
unresolved[0]["route_file"],
town_beads_dir.join("routes.jsonl").display().to_string()
);
}
#[test]
fn rust_log_volume_classifies_unset_like_compiled_default() {
assert_eq!(rust_log_volume(None), rust_log_default_volume());
}
#[test]
fn rust_log_volume_classifies_blank_as_quiet() {
assert_eq!(rust_log_volume(Some("")), RustLogVolume::Quiet);
assert_eq!(rust_log_volume(Some(" ")), RustLogVolume::Quiet);
}
#[test]
fn rust_log_volume_classifies_quiet_levels_as_quiet() {
for level in &["off", "error", "warn", "OFF", "Error", "WARN"] {
assert_eq!(
rust_log_volume(Some(level)),
RustLogVolume::Quiet,
"level {level} should be quiet"
);
}
}
#[test]
fn rust_log_volume_classifies_bare_info_as_noisy() {
let v = rust_log_volume(Some("info"));
match v {
RustLogVolume::Noisy { reason } => assert_eq!(reason, "bare_level_info"),
RustLogVolume::Quiet => panic!("expected Noisy, got {v:?}"),
}
}
#[test]
fn rust_log_volume_classifies_bare_debug_as_noisy() {
let v = rust_log_volume(Some("debug"));
match v {
RustLogVolume::Noisy { reason } => assert_eq!(reason, "bare_level_debug"),
RustLogVolume::Quiet => panic!("expected Noisy, got {v:?}"),
}
}
#[test]
fn rust_log_volume_classifies_bare_trace_as_noisy() {
let v = rust_log_volume(Some("trace"));
match v {
RustLogVolume::Noisy { reason } => assert_eq!(reason, "bare_level_trace"),
RustLogVolume::Quiet => panic!("expected Noisy, got {v:?}"),
}
}
#[test]
fn rust_log_volume_classifies_per_module_directive_with_info_as_noisy() {
let v = rust_log_volume(Some("beads_rust=info"));
match v {
RustLogVolume::Noisy { reason } => assert_eq!(reason, "directive_info"),
RustLogVolume::Quiet => panic!("expected Noisy, got {v:?}"),
}
}
#[test]
fn rust_log_volume_classifies_target_only_directive_as_noisy() {
let v = rust_log_volume(Some("beads_rust"));
match v {
RustLogVolume::Noisy { reason } => assert_eq!(reason, "directive_target_only"),
RustLogVolume::Quiet => panic!("expected Noisy, got {v:?}"),
}
}
#[test]
fn rust_log_volume_classifies_composite_with_one_noisy_directive_as_noisy() {
let v = rust_log_volume(Some("warn,beads_rust=debug"));
match v {
RustLogVolume::Noisy { reason } => assert_eq!(reason, "directive_debug"),
RustLogVolume::Quiet => panic!("expected Noisy, got {v:?}"),
}
}
#[test]
fn rust_log_volume_classifies_composite_with_target_only_directive_as_noisy() {
let v = rust_log_volume(Some("error,beads_rust"));
match v {
RustLogVolume::Noisy { reason } => assert_eq!(reason, "directive_target_only"),
RustLogVolume::Quiet => panic!("expected Noisy, got {v:?}"),
}
}
#[test]
fn rust_log_volume_classifies_span_filter_without_level_as_noisy() {
let v = rust_log_volume(Some("[span{field=value}]"));
match v {
RustLogVolume::Noisy { reason } => assert_eq!(reason, "directive_unclassified"),
RustLogVolume::Quiet => panic!("expected Noisy, got {v:?}"),
}
}
#[test]
fn rust_log_volume_classifies_composite_all_quiet_as_quiet() {
let v = rust_log_volume(Some("error,fsqlite=warn,beads_rust=off"));
assert_eq!(v, RustLogVolume::Quiet);
}
#[test]
fn check_permissions_beads_dir_normal_workspace_is_ok() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(beads_dir.join("issues.jsonl"), b"").unwrap();
fs::write(beads_dir.join("beads.db"), b"").unwrap();
let mode = fs::metadata(&beads_dir).unwrap().permissions().mode() & 0o777;
assert!(mode & 0o200 != 0, "test setup: .beads must start writable");
let mut checks = Vec::new();
check_permissions_beads_dir(&beads_dir, &mut checks);
let check =
find_check(&checks, "permissions.beads_dir").expect("permissions.beads_dir present");
assert!(
matches!(check.status, CheckStatus::Ok),
"writable workspace must be Ok; got {:?}",
check.status
);
}
#[test]
fn check_permissions_beads_dir_readonly_directory_warns() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let mut perms = fs::metadata(&beads_dir).unwrap().permissions();
perms.set_mode(0o555);
fs::set_permissions(&beads_dir, perms).unwrap();
let mut checks = Vec::new();
check_permissions_beads_dir(&beads_dir, &mut checks);
let mut restore = fs::metadata(&beads_dir).unwrap().permissions();
restore.set_mode(0o755);
fs::set_permissions(&beads_dir, restore).ok();
let check =
find_check(&checks, "permissions.beads_dir").expect("permissions.beads_dir present");
assert!(
matches!(check.status, CheckStatus::Warn),
"readonly .beads/ must trigger Warn; got {:?}",
check.status
);
let details = check.details.as_ref().expect("details present");
let readonly = details["readonly_paths"].as_array().unwrap();
assert!(
!readonly.is_empty(),
"readonly_paths must enumerate the dir"
);
assert_eq!(readonly[0]["kind"], "directory");
assert!(
readonly[0]["fix"]
.as_str()
.unwrap()
.starts_with("chmod u+w "),
"fix must name the canonical chmod command: {readonly:?}"
);
}
#[test]
fn check_permissions_beads_dir_readonly_issues_jsonl_warns() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let jsonl = beads_dir.join("issues.jsonl");
fs::write(&jsonl, b"").unwrap();
let mut perms = fs::metadata(&jsonl).unwrap().permissions();
perms.set_mode(0o444);
fs::set_permissions(&jsonl, perms).unwrap();
let mut checks = Vec::new();
check_permissions_beads_dir(&beads_dir, &mut checks);
let mut restore = fs::metadata(&jsonl).unwrap().permissions();
restore.set_mode(0o644);
fs::set_permissions(&jsonl, restore).ok();
let check =
find_check(&checks, "permissions.beads_dir").expect("permissions.beads_dir present");
assert!(matches!(check.status, CheckStatus::Warn));
let details = check.details.as_ref().expect("details present");
let readonly = details["readonly_paths"].as_array().unwrap();
assert!(
readonly
.iter()
.filter_map(|e| e["kind"].as_str())
.any(|kind| kind == "file"),
"readonly_paths must flag the file kind: {readonly:?}"
);
}
#[test]
fn check_permissions_beads_dir_missing_beads_dir_does_not_panic() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join("does_not_exist");
let mut checks = Vec::new();
check_permissions_beads_dir(&beads_dir, &mut checks);
let check =
find_check(&checks, "permissions.beads_dir").expect("permissions.beads_dir present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn check_config_yaml_missing_is_ok() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let mut checks = Vec::new();
check_config_yaml(&beads_dir, &mut checks);
let check = find_check(&checks, "config.yaml").expect("config.yaml present");
assert!(
matches!(check.status, CheckStatus::Ok),
"missing config.yaml must be Ok (project config is optional); got {:?}",
check.status
);
}
#[test]
fn check_config_yaml_empty_is_ok() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(beads_dir.join("config.yaml"), b"").unwrap();
let mut checks = Vec::new();
check_config_yaml(&beads_dir, &mut checks);
let check = find_check(&checks, "config.yaml").expect("config.yaml present");
assert!(
matches!(check.status, CheckStatus::Ok),
"empty config.yaml means all-defaults; must be Ok"
);
}
#[test]
fn check_config_yaml_valid_is_ok() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let yaml = "id:\n prefix: \"proj\"\ndefaults:\n priority: 2\n type: task\n";
fs::write(beads_dir.join("config.yaml"), yaml).unwrap();
let mut checks = Vec::new();
check_config_yaml(&beads_dir, &mut checks);
let check = find_check(&checks, "config.yaml").expect("config.yaml present");
assert!(
matches!(check.status, CheckStatus::Ok),
"valid config.yaml must be Ok; got {:?}",
check.status
);
let details = check.details.as_ref().expect("details present");
assert!(details["bytes"].as_u64().unwrap() > 0);
}
#[test]
fn check_config_yaml_malformed_warns_with_parse_error() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let yaml = "id:\n prefix: \"proj\"\n - bad\n invalid_block_mapping\n";
fs::write(beads_dir.join("config.yaml"), yaml).unwrap();
let mut checks = Vec::new();
check_config_yaml(&beads_dir, &mut checks);
let check = find_check(&checks, "config.yaml").expect("config.yaml present");
assert!(
matches!(check.status, CheckStatus::Warn),
"malformed YAML must trigger Warn; got {:?}",
check.status
);
let details = check.details.as_ref().expect("details present");
assert!(
details["parse_error"].as_str().is_some(),
"parse_error must be populated"
);
assert!(
details["recommended_fix"]
.as_str()
.unwrap()
.contains("Open"),
"recommended_fix should advise the operator to open the file"
);
}
#[test]
fn check_config_yaml_completely_invalid_warns() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let yaml = "[broken: yaml,\n with: unterminated: \"quote\n another: line\n";
fs::write(beads_dir.join("config.yaml"), yaml).unwrap();
let mut checks = Vec::new();
check_config_yaml(&beads_dir, &mut checks);
let check = find_check(&checks, "config.yaml").expect("config.yaml present");
assert!(
matches!(check.status, CheckStatus::Warn),
"syntax-error YAML must trigger Warn; got {:?}",
check.status
);
}
#[test]
fn check_metadata_json_missing_is_ok() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let mut checks = Vec::new();
check_metadata_json(&beads_dir, &mut checks);
let check = find_check(&checks, "metadata.json").expect("metadata.json present");
assert!(
matches!(check.status, CheckStatus::Ok),
"missing metadata.json must be Ok (br uses defaults); got {:?}",
check.status
);
}
#[test]
fn check_metadata_json_empty_warns() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(beads_dir.join("metadata.json"), b"").unwrap();
let mut checks = Vec::new();
check_metadata_json(&beads_dir, &mut checks);
let check = find_check(&checks, "metadata.json").expect("metadata.json present");
assert!(
matches!(check.status, CheckStatus::Warn),
"empty metadata.json must be Warn (br loader rejects); got {:?}",
check.status
);
let details = check.details.as_ref().expect("details present");
assert_eq!(details["reason"], "empty_file");
}
#[test]
fn check_metadata_json_valid_with_existing_targets_is_ok() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
fs::write(&db_path, b"").unwrap();
fs::write(beads_dir.join("issues.jsonl"), b"").unwrap();
let metadata = serde_json::json!({
"database": db_path.display().to_string(),
"jsonl_export": "issues.jsonl",
});
fs::write(beads_dir.join("metadata.json"), metadata.to_string()).unwrap();
let mut checks = Vec::new();
check_metadata_json(&beads_dir, &mut checks);
let check = find_check(&checks, "metadata.json").expect("metadata.json present");
assert!(
matches!(check.status, CheckStatus::Ok),
"valid metadata.json with present targets must be Ok; got {:?}",
check.status
);
}
#[test]
fn check_metadata_json_whitespace_targets_use_defaults() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(
beads_dir.join("metadata.json"),
br#"{"database":" ","jsonl_export":"\t"}"#,
)
.unwrap();
let mut checks = Vec::new();
check_metadata_json(&beads_dir, &mut checks);
let check = find_check(&checks, "metadata.json").expect("metadata.json present");
assert!(
matches!(check.status, CheckStatus::Ok),
"whitespace metadata targets load as defaults and must not drift-warn; got {:?}",
check.status
);
}
#[test]
fn check_metadata_json_preserves_nonempty_target_whitespace() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(beads_dir.join("beads.db"), b"").unwrap();
fs::write(beads_dir.join("issues.jsonl"), b"").unwrap();
fs::write(
beads_dir.join("metadata.json"),
br#"{"database":" beads.db ","jsonl_export":" issues.jsonl "}"#,
)
.unwrap();
let mut checks = Vec::new();
check_metadata_json(&beads_dir, &mut checks);
let check = find_check(&checks, "metadata.json").expect("metadata.json present");
assert!(
matches!(check.status, CheckStatus::Warn),
"non-empty whitespace is part of the runtime path and must drift-warn; got {:?}",
check.status
);
let details = check.details.as_ref().expect("details present");
let drift = details["drift"].as_array().expect("drift array");
assert_eq!(
drift.len(),
2,
"both whitespace-padded targets should drift"
);
assert!(
drift
.iter()
.any(|entry| { entry["field"] == "database" && entry["value"] == " beads.db " })
);
assert!(drift.iter().any(|entry| {
entry["field"] == "jsonl_export" && entry["value"] == " issues.jsonl "
}));
}
#[test]
fn check_metadata_json_malformed_warns_with_parse_error() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(
beads_dir.join("metadata.json"),
br#"{ "database": "beads.db", not_quoted_key: "issues.jsonl" }"#,
)
.unwrap();
let mut checks = Vec::new();
check_metadata_json(&beads_dir, &mut checks);
let check = find_check(&checks, "metadata.json").expect("metadata.json present");
assert!(matches!(check.status, CheckStatus::Warn));
let details = check.details.as_ref().expect("details present");
assert_eq!(details["reason"], "parse_error");
assert!(
details["parse_error"].as_str().is_some(),
"parse_error must be populated"
);
}
#[test]
fn check_metadata_json_non_object_top_level_warns() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(
beads_dir.join("metadata.json"),
br#"["not", "an", "object"]"#,
)
.unwrap();
let mut checks = Vec::new();
check_metadata_json(&beads_dir, &mut checks);
let check = find_check(&checks, "metadata.json").expect("metadata.json present");
assert!(matches!(check.status, CheckStatus::Warn));
let details = check.details.as_ref().expect("details present");
assert_eq!(details["reason"], "wrong_top_level_shape");
}
#[test]
fn check_metadata_json_drift_warns() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let metadata = serde_json::json!({
"database": tmp.path().join("renamed.db").display().to_string(),
"jsonl_export": tmp.path().join("renamed.jsonl").display().to_string(),
});
fs::write(beads_dir.join("metadata.json"), metadata.to_string()).unwrap();
let mut checks = Vec::new();
check_metadata_json(&beads_dir, &mut checks);
let check = find_check(&checks, "metadata.json").expect("metadata.json present");
assert!(matches!(check.status, CheckStatus::Warn));
let details = check.details.as_ref().expect("details present");
let drift = details["drift"].as_array().expect("drift array");
assert_eq!(drift.len(), 2, "both declared targets should be flagged");
let fields: Vec<&str> = drift.iter().filter_map(|e| e["field"].as_str()).collect();
assert!(fields.contains(&"database"));
assert!(fields.contains(&"jsonl_export"));
}
#[test]
fn check_metadata_json_partial_drift_warns_only_for_missing() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
fs::write(&db_path, b"").unwrap();
let metadata = serde_json::json!({
"database": db_path.display().to_string(),
"jsonl_export": "missing.jsonl",
});
fs::write(beads_dir.join("metadata.json"), metadata.to_string()).unwrap();
let mut checks = Vec::new();
check_metadata_json(&beads_dir, &mut checks);
let check = find_check(&checks, "metadata.json").expect("metadata.json present");
assert!(matches!(check.status, CheckStatus::Warn));
let details = check.details.as_ref().expect("details present");
let drift = details["drift"].as_array().expect("drift array");
assert_eq!(drift.len(), 1, "only the missing field should be flagged");
assert_eq!(drift[0]["field"], "jsonl_export");
}
#[test]
fn parse_cargo_toml_package_field_finds_version() {
let body = r#"
[package]
name = "beads_rust"
version = "0.2.6"
edition = "2024"
"#;
assert_eq!(
parse_cargo_toml_package_field(body, "version").as_deref(),
Some("0.2.6")
);
assert_eq!(
parse_cargo_toml_package_field(body, "name").as_deref(),
Some("beads_rust")
);
}
#[test]
fn parse_cargo_toml_package_field_ignores_other_sections() {
let body = r#"
[package]
name = "beads_rust"
version = "0.2.6"
[dependencies]
version = "1.0.0"
name = "other"
"#;
assert_eq!(
parse_cargo_toml_package_field(body, "version").as_deref(),
Some("0.2.6")
);
assert_eq!(
parse_cargo_toml_package_field(body, "name").as_deref(),
Some("beads_rust")
);
}
#[test]
fn parse_cargo_toml_package_field_handles_inline_comments() {
let body = r#"
[package]
name = "beads_rust"
version = "0.2.6" # release candidate
"#;
assert_eq!(
parse_cargo_toml_package_field(body, "version").as_deref(),
Some("0.2.6")
);
}
#[test]
fn parse_cargo_toml_package_field_returns_none_for_missing() {
let body = r#"
[package]
name = "beads_rust"
"#;
assert!(parse_cargo_toml_package_field(body, "version").is_none());
}
#[test]
fn find_beads_rust_repo_root_finds_self() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let cargo = tmp.path().join("Cargo.toml");
fs::write(
&cargo,
r#"[package]
name = "beads_rust"
version = "0.99.0"
edition = "2024"
"#,
)
.unwrap();
let root = find_beads_rust_repo_root(&beads_dir);
assert_eq!(root.as_deref(), Some(tmp.path()));
}
#[test]
fn find_beads_rust_repo_root_returns_none_for_other_repo() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
r#"[package]
name = "beads_rust"
version = "0.99.0"
"#,
)
.unwrap();
let other_repo = tmp.path().join("other_repo");
let beads_dir = other_repo.join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let cargo = other_repo.join("Cargo.toml");
fs::write(
&cargo,
r#"[package]
name = "some_other_crate"
version = "1.0.0"
"#,
)
.unwrap();
let root = find_beads_rust_repo_root(&beads_dir);
assert!(root.is_none(), "must NOT match unrelated Cargo.toml");
}
#[test]
fn check_binary_version_mismatch_not_in_repo_is_ok() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let mut checks = Vec::new();
check_binary_version_mismatch(&beads_dir, &mut checks);
let check = find_check(&checks, "binary_version").expect("binary_version present");
assert!(
matches!(check.status, CheckStatus::Ok),
"no beads_rust Cargo.toml reachable should be Ok; got {:?}",
check.status
);
}
#[test]
fn check_binary_version_mismatch_tree_ahead_warns() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
r#"[package]
name = "beads_rust"
version = "99.99.99"
edition = "2024"
"#,
)
.unwrap();
let mut checks = Vec::new();
check_binary_version_mismatch(&beads_dir, &mut checks);
let check = find_check(&checks, "binary_version").expect("binary_version present");
assert!(
matches!(check.status, CheckStatus::Warn),
"tree-ahead-of-binary must trigger Warn; got {:?}",
check.status
);
let details = check.details.as_ref().expect("details present");
assert_eq!(details["tree_version"], "99.99.99");
assert!(
details["recommended_fix"]
.as_str()
.unwrap()
.contains("cargo install --path"),
"recommended_fix should name the canonical rebuild command"
);
}
#[test]
fn check_binary_version_mismatch_tree_behind_is_ok() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
r#"[package]
name = "beads_rust"
version = "0.0.1"
edition = "2024"
"#,
)
.unwrap();
let mut checks = Vec::new();
check_binary_version_mismatch(&beads_dir, &mut checks);
let check = find_check(&checks, "binary_version").expect("binary_version present");
assert!(
matches!(check.status, CheckStatus::Ok),
"tree-behind-binary must be silent (operator on side branch); got {:?}",
check.status
);
}
#[test]
fn check_binary_version_mismatch_non_semver_tree_is_ok() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
r#"[package]
name = "beads_rust"
version = "2026-05-11-abc123"
"#,
)
.unwrap();
let mut checks = Vec::new();
check_binary_version_mismatch(&beads_dir, &mut checks);
let check = find_check(&checks, "binary_version").expect("binary_version present");
assert!(matches!(check.status, CheckStatus::Ok));
}
#[test]
fn check_orphaned_write_lock_missing_is_ok() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let mut checks = Vec::new();
check_orphaned_write_lock(&beads_dir, &mut checks);
let check = find_check(&checks, "write_lock").expect("write_lock present");
assert!(
matches!(check.status, CheckStatus::Ok),
"missing .write.lock must be Ok (no contention); got {:?}",
check.status
);
}
#[test]
fn check_orphaned_write_lock_fresh_is_ok() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(beads_dir.join(".write.lock"), b"").unwrap();
let mut checks = Vec::new();
check_orphaned_write_lock(&beads_dir, &mut checks);
let check = find_check(&checks, "write_lock").expect("write_lock present");
assert!(
matches!(check.status, CheckStatus::Ok),
"fresh .write.lock must be Ok; got {:?}",
check.status
);
}
#[test]
fn check_orphaned_write_lock_symlink_fails_closed() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let target = beads_dir.join("target_file");
fs::write(&target, b"").unwrap();
std::os::unix::fs::symlink(&target, beads_dir.join(".write.lock")).unwrap();
let mut checks = Vec::new();
check_orphaned_write_lock(&beads_dir, &mut checks);
let check = find_check(&checks, "write_lock").expect("write_lock present");
assert!(
matches!(check.status, CheckStatus::Error),
"symlink .write.lock must fail closed; got {:?}",
check.status
);
let details = check.details.as_ref().expect("details");
assert_eq!(
details.get("reason").and_then(|v| v.as_str()),
Some("non_regular_lock_node")
);
assert_eq!(
details.get("node_kind").and_then(|v| v.as_str()),
Some("symlink")
);
let lock_meta = fs::symlink_metadata(beads_dir.join(".write.lock")).unwrap();
assert!(lock_meta.file_type().is_symlink());
assert!(target.exists());
}
#[test]
fn check_orphaned_write_lock_directory_fails_closed() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(beads_dir.join(".write.lock")).unwrap();
let mut checks = Vec::new();
check_orphaned_write_lock(&beads_dir, &mut checks);
let check = find_check(&checks, "write_lock").expect("write_lock present");
assert!(matches!(check.status, CheckStatus::Error), "{check:?}");
let details = check.details.as_ref().expect("details");
assert_eq!(
details.get("node_kind").and_then(|v| v.as_str()),
Some("directory")
);
assert!(beads_dir.join(".write.lock").is_dir());
}
#[test]
fn check_write_lock_writable_defers_on_symlink_with_message() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let target = beads_dir.join("target_file");
fs::write(&target, b"").unwrap();
std::os::unix::fs::symlink(&target, beads_dir.join(".write.lock")).unwrap();
let mut checks = Vec::new();
check_write_lock_writable(&beads_dir, &mut checks);
let check = find_check(&checks, "permissions.write_lock").expect("check present");
assert!(matches!(check.status, CheckStatus::Ok));
assert!(
check
.message
.as_deref()
.is_some_and(|m| m.contains("not a regular file")),
"{check:?}"
);
}
#[test]
fn check_orphaned_write_lock_old_but_free_probes_ok() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let lock_path = beads_dir.join(".write.lock");
fs::write(&lock_path, b"").unwrap();
let old = std::time::SystemTime::now() - std::time::Duration::from_secs(3_600_000);
let file = fs::OpenOptions::new().write(true).open(&lock_path).unwrap();
file.set_times(fs::FileTimes::new().set_modified(old))
.unwrap();
drop(file);
let mut checks = Vec::new();
check_orphaned_write_lock(&beads_dir, &mut checks);
let check = find_check(&checks, "write_lock").expect("write_lock present");
assert!(
matches!(check.status, CheckStatus::Ok),
"an ancient but free lock must be Ok; got {:?} ({:?})",
check.status,
check.message
);
assert_eq!(
check
.details
.as_ref()
.and_then(|d| d.get("reason"))
.and_then(|r| r.as_str()),
Some("probe_acquired_free")
);
}
#[test]
#[allow(clippy::incompatible_msrv)]
fn check_orphaned_write_lock_live_holder_probes_ok() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let lock_path = beads_dir.join(".write.lock");
fs::write(&lock_path, b"").unwrap();
let old = std::time::SystemTime::now() - std::time::Duration::from_secs(3_600_000);
let holder = fs::OpenOptions::new()
.read(true)
.write(true)
.open(&lock_path)
.unwrap();
holder
.set_times(fs::FileTimes::new().set_modified(old))
.unwrap();
holder.lock().unwrap();
let mut checks = Vec::new();
check_orphaned_write_lock(&beads_dir, &mut checks);
drop(holder);
let check = find_check(&checks, "write_lock").expect("write_lock present");
assert!(
matches!(check.status, CheckStatus::Ok),
"a live holder must be Ok; got {:?} ({:?})",
check.status,
check.message
);
assert_eq!(
check
.details
.as_ref()
.and_then(|d| d.get("reason"))
.and_then(|r| r.as_str()),
Some("persistent_advisory_inode")
);
}
fn insert_minimal_issue(storage: &mut SqliteStorage, id: &str) {
let issue = sample_issue(id, "test");
storage.create_issue(&issue, "test").unwrap();
}
fn write_jsonl_with_ids(path: &Path, ids: &[&str]) {
let mut buf = String::new();
for id in ids {
buf.push_str(&format!(
"{{\"id\":\"{}\",\"title\":\"t\",\"status\":\"open\",\"priority\":2,\"issue_type\":\"task\",\"created_at\":\"2026-05-01T00:00:00Z\",\"updated_at\":\"2026-05-01T00:00:00Z\"}}\n",
id
));
}
fs::write(path, buf).unwrap();
}
#[test]
fn compute_db_jsonl_id_delta_detects_only_db_only_jsonl_and_both() {
let tmp = TempDir::new().unwrap();
let db_path = tmp.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
insert_minimal_issue(&mut storage, "bd-1");
insert_minimal_issue(&mut storage, "bd-2");
insert_minimal_issue(&mut storage, "bd-only-db");
drop(storage);
let jsonl_path = tmp.path().join("issues.jsonl");
write_jsonl_with_ids(&jsonl_path, &["bd-1", "bd-2", "bd-only-jsonl"]);
let conn =
Connection::open(db_path.to_string_lossy().into_owned()).expect("open db for read");
let delta = compute_db_jsonl_id_delta(&conn, &jsonl_path).expect("id delta should succeed");
assert_eq!(delta.both_count, 2);
assert_eq!(delta.only_db, vec!["bd-only-db".to_string()]);
assert_eq!(delta.only_jsonl, vec!["bd-only-jsonl".to_string()]);
}
#[test]
fn compute_db_jsonl_id_delta_is_empty_when_stores_agree() {
let tmp = TempDir::new().unwrap();
let db_path = tmp.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
insert_minimal_issue(&mut storage, "bd-a");
insert_minimal_issue(&mut storage, "bd-b");
drop(storage);
let jsonl_path = tmp.path().join("issues.jsonl");
write_jsonl_with_ids(&jsonl_path, &["bd-a", "bd-b"]);
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let delta = compute_db_jsonl_id_delta(&conn, &jsonl_path).unwrap();
assert_eq!(delta.both_count, 2);
assert!(delta.only_db.is_empty());
assert!(delta.only_jsonl.is_empty());
}
#[test]
fn wal_checkpoint_stats_complete_accepts_full_checkpoint() {
let stats = WalCheckpointStats {
busy: 0,
log_frames: 7,
checkpointed_frames: 7,
};
assert!(stats.complete());
}
#[test]
fn wal_checkpoint_stats_complete_accepts_non_wal_sentinel() {
let stats = WalCheckpointStats {
busy: 0,
log_frames: -1,
checkpointed_frames: -1,
};
assert!(stats.complete());
}
#[test]
fn wal_checkpoint_stats_complete_rejects_busy_checkpoint() {
let stats = WalCheckpointStats {
busy: 1,
log_frames: 7,
checkpointed_frames: 0,
};
assert!(!stats.complete());
}
#[test]
fn wal_checkpoint_stats_complete_rejects_partial_checkpoint() {
let stats = WalCheckpointStats {
busy: 0,
log_frames: 7,
checkpointed_frames: 3,
};
assert!(!stats.complete());
}
#[test]
fn execute_repair_indexes_succeeds_against_healthy_db_and_retains_snapshot() {
use crate::config::ConfigPaths;
use crate::output::OutputContext;
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let issue = sample_issue("bd-ri-1", "indexable");
storage.create_issue(&issue, "test").unwrap();
drop(storage);
let paths = ConfigPaths {
beads_dir: beads_dir.clone(),
db_path: db_path.clone(),
jsonl_path: beads_dir.join("issues.jsonl"),
metadata: crate::config::Metadata::default(),
};
let args = DoctorArgs {
repair: false,
repair_indexes: true,
allow_repeated_repair: false,
dry_run: false,
robot_triage: false,
quick: false,
only: Vec::new(),
skip: Vec::new(),
unsafe_auto_fix: false,
selftest: false,
selftest_dir: None,
keep: false,
bundle: None,
include_db: false,
include_jsonl: false,
bundle_events: 200,
subcommand: None,
};
let ctx = OutputContext::from_flags(false, true, true);
let cli = crate::config::CliOverrides::default();
let result = execute_repair_indexes(&beads_dir, &paths, &ctx, &args, &cli);
assert!(
result.is_ok(),
"repair-indexes against a healthy DB must succeed: {:?}",
result
);
let snapshot_path = db_path.with_extension("db.pre-repair-indexes");
assert!(
snapshot_path.exists(),
"pre-snapshot backup must be retained at {}",
snapshot_path.display(),
);
let storage = SqliteStorage::open(&db_path).unwrap();
let reloaded = storage.get_issue("bd-ri-1").unwrap();
assert!(reloaded.is_some(), "issue row must survive REINDEX");
}
#[test]
fn execute_repair_indexes_clears_stale_sidecar_snapshots_from_previous_run() {
use crate::config::ConfigPaths;
use crate::output::OutputContext;
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.create_issue(&sample_issue("bd-stale-sidecar", "test"), "test")
.unwrap();
drop(storage);
let snapshot_path = db_path.with_extension("db.pre-repair-indexes");
let stale_wal = PathBuf::from(format!("{}-wal", snapshot_path.to_string_lossy()));
let stale_shm = PathBuf::from(format!("{}-shm", snapshot_path.to_string_lossy()));
fs::write(&stale_wal, b"stale wal from a previous run").unwrap();
fs::write(&stale_shm, b"stale shm from a previous run").unwrap();
let paths = ConfigPaths {
beads_dir: beads_dir.clone(),
db_path: db_path.clone(),
jsonl_path: beads_dir.join("issues.jsonl"),
metadata: crate::config::Metadata::default(),
};
let args = DoctorArgs {
repair: false,
repair_indexes: true,
allow_repeated_repair: false,
dry_run: false,
robot_triage: false,
quick: false,
only: Vec::new(),
skip: Vec::new(),
unsafe_auto_fix: false,
selftest: false,
selftest_dir: None,
keep: false,
bundle: None,
include_db: false,
include_jsonl: false,
bundle_events: 200,
subcommand: None,
};
let ctx = OutputContext::from_flags(false, true, true);
let cli = crate::config::CliOverrides::default();
execute_repair_indexes(&beads_dir, &paths, &ctx, &args, &cli)
.expect("repair-indexes must succeed on healthy DB");
assert!(
!stale_wal.exists(),
"stale snapshot-wal from previous run must be cleared on a successful checkpoint; still present at {}",
stale_wal.display(),
);
assert!(
!stale_shm.exists(),
"stale snapshot-shm from previous run must be cleared on a successful checkpoint; still present at {}",
stale_shm.display(),
);
assert!(
snapshot_path.exists(),
"current run's pre-snapshot must be retained at {}",
snapshot_path.display(),
);
}
#[test]
fn checkpoint_and_snapshot_repair_indexes_refuses_unremovable_stale_sidecar_snapshot() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.create_issue(&sample_issue("bd-stale-sidecar-dir", "test"), "test")
.unwrap();
drop(storage);
let snapshot_path = db_path.with_extension("db.pre-repair-indexes");
let stale_wal = PathBuf::from(format!("{}-wal", snapshot_path.to_string_lossy()));
fs::create_dir(&stale_wal).unwrap();
let write_authority =
acquire_doctor_database_write_authority(&beads_dir, &db_path, Some(1_000)).unwrap();
let err =
checkpoint_and_snapshot_repair_indexes(&db_path, &snapshot_path, &write_authority)
.expect_err("unremovable stale sidecar snapshot must fail closed");
let message = err.to_string();
assert!(
message.contains("failed to remove stale sidecar snapshot")
&& message.contains("refusing to continue"),
"unexpected error: {message}",
);
assert!(
!snapshot_path.exists(),
"must fail before writing a new DB snapshot at {}",
snapshot_path.display(),
);
}
#[cfg(unix)]
#[test]
fn checkpoint_and_snapshot_repair_indexes_refuses_symlinked_snapshot_target() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.create_issue(&sample_issue("bd-snapshot-symlink", "test"), "test")
.unwrap();
drop(storage);
let outside = TempDir::new().unwrap();
let outside_target = outside.path().join("must-not-overwrite");
fs::write(&outside_target, b"preserve me").unwrap();
let snapshot_path = db_path.with_extension("db.pre-repair-indexes");
symlink(&outside_target, &snapshot_path).unwrap();
let write_authority =
acquire_doctor_database_write_authority(&beads_dir, &db_path, Some(1_000)).unwrap();
let err =
checkpoint_and_snapshot_repair_indexes(&db_path, &snapshot_path, &write_authority)
.expect_err("symlinked pre-snapshot target must fail closed");
let message = err.to_string();
assert!(
message.contains("refusing to write pre-snapshot backup through symlink"),
"unexpected error: {message}",
);
assert_eq!(
fs::read(&outside_target).unwrap(),
b"preserve me",
"snapshot creation must not write through the symlink target"
);
assert!(
fs::symlink_metadata(&snapshot_path)
.unwrap()
.file_type()
.is_symlink(),
"refused snapshot symlink should be left in place for operator inspection"
);
}
#[cfg(unix)]
#[test]
fn test_fix_recovery_artifacts_aged_is_idempotent_no_op_on_second_call() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.create_issue(&sample_issue("bd-aged-recovery", "test"), "test")
.unwrap();
drop(storage);
let recovery_dir = beads_dir.join(".br_recovery");
fs::create_dir_all(&recovery_dir).unwrap();
let aged = recovery_dir.join("beads.db.20250101T000000Z");
let recent = recovery_dir.join("beads.db.20260512T000000Z");
let bad_sibling = beads_dir.join("beads.db.bad_20250101T000000Z");
fs::copy(&db_path, &aged).unwrap();
fs::copy(&db_path, &recent).unwrap();
fs::copy(&db_path, &bad_sibling).unwrap();
for path in [&aged, &bad_sibling] {
let status = Command::new("touch")
.args(["-d", "60 days ago"])
.arg(path)
.status()
.unwrap();
assert!(status.success(), "touch failed for {}", path.display());
}
let report = DoctorReport {
ok: false,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "db.recovery_artifacts.aged".to_string(),
status: CheckStatus::Warn,
message: None,
details: None,
}],
};
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Json, false, true);
let mut session = DoctorRepairSession::new(tmp.path(), false)
.expect("session must build");
assert!(fix_recovery_artifacts_aged_if_warned(
&beads_dir,
&db_path,
&report,
&ctx,
Some(&mut session),
));
assert!(!aged.exists());
assert!(!bad_sibling.exists());
assert!(
recent.is_file(),
"recent recovery artifacts must remain in place"
);
let actions_before = fs::read_to_string(&session.run.actions_file).unwrap();
assert_eq!(actions_before.matches("\"op\":\"rename\"").count(), 2);
assert!(!fix_recovery_artifacts_aged_if_warned(
&beads_dir,
&db_path,
&report,
&ctx,
Some(&mut session),
));
let actions_after = fs::read_to_string(&session.run.actions_file).unwrap();
assert_eq!(actions_after, actions_before, "second pass must be a no-op");
}
#[test]
fn test_fix_export_hash_cache_uses_resolved_database_path() {
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let default_db = beads_dir.join("beads.db");
let custom_db = beads_dir.join("custom.db");
let jsonl_path = beads_dir.join("issues.jsonl");
fs::write(&jsonl_path, br#"{"id":"custom-db-path"}"#).unwrap();
let mut default_storage = SqliteStorage::open(&default_db).unwrap();
default_storage
.set_metadata(
crate::sync::METADATA_JSONL_CONTENT_HASH,
"default-db-must-not-change",
)
.unwrap();
drop(default_storage);
let mut custom_storage = SqliteStorage::open(&custom_db).unwrap();
custom_storage
.set_metadata(crate::sync::METADATA_JSONL_CONTENT_HASH, "custom-db-stale")
.unwrap();
drop(custom_storage);
let report = DoctorReport {
ok: false,
workspace_health: None,
reliability_audit: None,
checks: vec![CheckResult {
name: "db.export_hash_cache".to_string(),
status: CheckStatus::Warn,
message: None,
details: None,
}],
};
let ctx = OutputContext::from_output_format(crate::cli::OutputFormat::Json, false, true);
let mut session = DoctorRepairSession::new(tmp.path(), false)
.expect("session must build");
let expected_hash = crate::sync::compute_jsonl_hash(&jsonl_path).unwrap();
assert!(fix_export_hash_cache_divergence_if_warned(
&custom_db,
Some(&jsonl_path),
&report,
&ctx,
Some(&mut session),
));
let custom_storage = SqliteStorage::open(&custom_db).unwrap();
assert_eq!(
custom_storage
.get_metadata(crate::sync::METADATA_JSONL_CONTENT_HASH)
.unwrap(),
Some(expected_hash)
);
let default_storage = SqliteStorage::open(&default_db).unwrap();
assert_eq!(
default_storage
.get_metadata(crate::sync::METADATA_JSONL_CONTENT_HASH)
.unwrap(),
Some("default-db-must-not-change".to_string())
);
}
#[test]
fn execute_repair_indexes_reuses_startup_write_lock() {
use crate::config::{CliOverrides, ConfigPaths};
use crate::output::OutputContext;
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.create_issue(&sample_issue("bd-ri-lock-1", "lock reuse"), "test")
.unwrap();
drop(storage);
let startup_lock = std::sync::Arc::new(
crate::sync::blocking_database_family_write_lock_with_timeout(
&beads_dir,
&db_path,
Some(0),
)
.unwrap(),
);
let mut cli = CliOverrides::default();
cli.mark_database_family_lock_held(&beads_dir, &startup_lock);
let paths = ConfigPaths {
beads_dir: beads_dir.clone(),
db_path: db_path.clone(),
jsonl_path: beads_dir.join("issues.jsonl"),
metadata: crate::config::Metadata::default(),
};
let args = DoctorArgs {
repair: false,
repair_indexes: true,
allow_repeated_repair: false,
dry_run: false,
robot_triage: false,
quick: false,
only: Vec::new(),
skip: Vec::new(),
unsafe_auto_fix: false,
selftest: false,
selftest_dir: None,
keep: false,
bundle: None,
include_db: false,
include_jsonl: false,
bundle_events: 200,
subcommand: None,
};
let ctx = OutputContext::from_flags(false, true, true);
execute_repair_indexes(&beads_dir, &paths, &ctx, &args, &cli).unwrap();
let snapshot_path = db_path.with_extension("db.pre-repair-indexes");
assert!(
snapshot_path.exists(),
"held-lock repair-indexes should still retain the pre-snapshot"
);
let storage = SqliteStorage::open(&db_path).unwrap();
assert!(storage.get_issue("bd-ri-lock-1").unwrap().is_some());
}
#[test]
fn execute_repair_indexes_quotes_names_that_need_quoting() {
use crate::config::ConfigPaths;
use crate::output::OutputContext;
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.create_issue(&sample_issue("bd-ri-quoted-1", "quoted index"), "test")
.unwrap();
drop(storage);
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
conn.execute(r#"CREATE INDEX "123doctor_weird" ON issues(title)"#)
.unwrap();
conn.execute(r#"CREATE INDEX "doctor""quoted" ON issues(status)"#)
.unwrap();
conn.close().unwrap();
let paths = ConfigPaths {
beads_dir: beads_dir.clone(),
db_path: db_path.clone(),
jsonl_path: beads_dir.join("issues.jsonl"),
metadata: crate::config::Metadata::default(),
};
let args = DoctorArgs {
repair: false,
repair_indexes: true,
allow_repeated_repair: false,
dry_run: false,
robot_triage: false,
quick: false,
only: Vec::new(),
skip: Vec::new(),
unsafe_auto_fix: false,
selftest: false,
selftest_dir: None,
keep: false,
bundle: None,
include_db: false,
include_jsonl: false,
bundle_events: 200,
subcommand: None,
};
let ctx = OutputContext::from_flags(false, true, true);
let cli = crate::config::CliOverrides::default();
execute_repair_indexes(&beads_dir, &paths, &ctx, &args, &cli).unwrap();
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let issue = conn
.query_row("SELECT id FROM issues WHERE id = 'bd-ri-quoted-1'")
.unwrap();
assert_eq!(
issue.get(0).and_then(SqliteValue::as_text),
Some("bd-ri-quoted-1")
);
conn.close().unwrap();
}
#[test]
fn quote_sql_identifier_escapes_embedded_quotes() {
assert_eq!(quote_sql_identifier("doctor_idx"), "\"doctor_idx\"");
assert_eq!(quote_sql_identifier("123doctor_idx"), "\"123doctor_idx\"");
assert_eq!(
quote_sql_identifier("doctor\"quoted"),
"\"doctor\"\"quoted\""
);
}
#[test]
fn execute_repair_indexes_dry_run_skips_mutation() {
use crate::config::ConfigPaths;
use crate::output::OutputContext;
let tmp = TempDir::new().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.create_issue(&sample_issue("bd-ri-dry-1", "untouched"), "test")
.unwrap();
drop(storage);
let pre_mtime = fs::metadata(&db_path).unwrap().modified().unwrap();
let paths = ConfigPaths {
beads_dir: beads_dir.clone(),
db_path: db_path.clone(),
jsonl_path: beads_dir.join("issues.jsonl"),
metadata: crate::config::Metadata::default(),
};
let args = DoctorArgs {
repair: false,
repair_indexes: true,
allow_repeated_repair: false,
dry_run: true,
robot_triage: false,
quick: false,
only: Vec::new(),
skip: Vec::new(),
unsafe_auto_fix: false,
selftest: false,
selftest_dir: None,
keep: false,
bundle: None,
include_db: false,
include_jsonl: false,
bundle_events: 200,
subcommand: None,
};
let ctx = OutputContext::from_flags(false, true, true);
let cli = crate::config::CliOverrides::default();
execute_repair_indexes(&beads_dir, &paths, &ctx, &args, &cli).unwrap();
let snapshot_path = db_path.with_extension("db.pre-repair-indexes");
assert!(
!snapshot_path.exists(),
"dry-run must not create a snapshot: {}",
snapshot_path.display(),
);
let post_mtime = fs::metadata(&db_path).unwrap().modified().unwrap();
assert_eq!(
pre_mtime, post_mtime,
"dry-run must not modify the live DB file"
);
}
#[test]
fn compute_db_jsonl_id_delta_skips_wisp_ids() {
let tmp = TempDir::new().unwrap();
let db_path = tmp.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
insert_minimal_issue(&mut storage, "bd-a");
drop(storage);
let jsonl_path = tmp.path().join("issues.jsonl");
write_jsonl_with_ids(&jsonl_path, &["bd-a", "bd-wisp-ephemeral"]);
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let delta = compute_db_jsonl_id_delta(&conn, &jsonl_path).unwrap();
assert_eq!(delta.both_count, 1);
assert!(delta.only_db.is_empty(), "{:?}", delta.only_db);
assert!(
delta.only_jsonl.is_empty(),
"wisp ids must be filtered from the JSONL side too: {:?}",
delta.only_jsonl
);
}
#[test]
fn finding_id_table_has_no_duplicate_check_names() {
let mut seen: std::collections::HashSet<&'static str> = std::collections::HashSet::new();
for (name, _) in CHECK_NAME_TO_FINDING_ID {
assert!(
seen.insert(*name),
"duplicate check_name {name} in CHECK_NAME_TO_FINDING_ID"
);
}
}
#[test]
fn finding_id_table_uses_canonical_fm_form() {
for (name, fm_id) in CHECK_NAME_TO_FINDING_ID {
assert!(
fm_id.starts_with("fm-"),
"finding_id for {name} must start with `fm-`: {fm_id}"
);
assert!(
fm_id.len() > "fm-".len(),
"finding_id for {name} is just the prefix: {fm_id}"
);
assert!(
!fm_id.contains(' '),
"finding_id for {name} contains whitespace: {fm_id}"
);
}
}
#[test]
fn finding_id_for_returns_canonical_form() {
assert_eq!(
finding_id_for("routes_jsonl"),
Some("fm-routes_external-routes-jsonl-corrupt")
);
assert_eq!(
finding_id_for("rust_log"),
Some("fm-observability-rust-log-noisy-breaks-json")
);
assert_eq!(
finding_id_for("audit.suspect_close_reasons"),
Some("fm-agent_coordination-suspect-close-reason")
);
}
#[test]
fn finding_id_for_returns_none_for_unmapped() {
assert_eq!(finding_id_for("definitely_not_a_real_check"), None);
assert_eq!(finding_id_for(""), None);
}
#[test]
fn push_check_injects_finding_id_into_object_details() {
let mut checks = Vec::new();
push_check(
&mut checks,
"db.exists",
CheckStatus::Ok,
None,
Some(serde_json::json!({ "path": ".beads/beads.db" })),
);
let details = checks[0]
.details
.as_ref()
.expect("mapped checks should carry finding_id details");
assert_eq!(
details
.get("finding_id")
.and_then(serde_json::Value::as_str),
Some("fm-state_files-empty-or-truncated-database")
);
assert_eq!(
details.get("path").and_then(serde_json::Value::as_str),
Some(".beads/beads.db")
);
}
#[test]
fn push_check_wraps_non_object_details_with_finding_id() {
let mut checks = Vec::new();
push_check(
&mut checks,
"db.exists",
CheckStatus::Warn,
None,
Some(serde_json::json!(["legacy-shape"])),
);
let details = checks[0]
.details
.as_ref()
.expect("mapped checks should carry finding_id details");
assert_eq!(
details
.get("finding_id")
.and_then(serde_json::Value::as_str),
Some("fm-state_files-empty-or-truncated-database")
);
assert_eq!(
details.get("data"),
Some(&serde_json::json!(["legacy-shape"]))
);
}
#[test]
fn push_check_leaves_unmapped_details_unchanged() {
let mut checks = Vec::new();
let original = serde_json::json!({ "path": ".beads/other" });
push_check(
&mut checks,
"unmapped.check",
CheckStatus::Error,
None,
Some(original.clone()),
);
assert_eq!(checks[0].details, Some(original));
}
}