use std::path::Path;
use anyhow::Context;
use serde::Deserialize;
use crate::entity::{Acquired, Claim, ClaimCtx, LocalFs};
use crate::git;
pub(crate) type ScanSource = Box<dyn FnMut(&[u32]) -> anyhow::Result<Vec<u32>>>;
pub(crate) type PromptFn = fn(&str) -> anyhow::Result<bool>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Reach {
Local,
Shared,
#[default]
Auto,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "kebab-case", default)]
pub(crate) struct ReservationConfig {
pub(crate) reach: Reach,
pub(crate) remote: Option<String>,
pub(crate) allow_local_fallback: bool,
}
#[derive(Debug, Default, Deserialize)]
struct ReservationDoc {
#[serde(default)]
reservation: ReservationConfig,
}
fn parse_reservation_config(text: &str) -> anyhow::Result<ReservationConfig> {
let doc: ReservationDoc = toml::from_str(text)?;
Ok(doc.reservation)
}
fn load_reservation_config(root: &Path) -> anyhow::Result<ReservationConfig> {
match crate::dtoml::read_doctrine_toml_text(root)? {
Some(text) => parse_reservation_config(&text)
.with_context(|| "Failed to parse [reservation] in doctrine.toml".to_owned()),
None => Ok(ReservationConfig::default()),
}
}
const ENV_FALLBACK: &str = "DOCTRINE_RESERVATION_FALLBACK";
fn env_fallback_optin() -> bool {
std::env::var_os(ENV_FALLBACK).is_some_and(|v| v == std::ffi::OsStr::new("1"))
}
const RESERVATION_REF_PREFIX: &str = "refs/doctrine/reservation";
const RESERVATION_REFSPEC: &str = "+refs/doctrine/reservation/*:refs/doctrine/reservation/*";
struct GitRef {
root: std::path::PathBuf,
prefix: String,
remote: String,
holder_name: String,
holder_email: String,
}
impl GitRef {
fn refname(&self, id: u32) -> String {
format!("{RESERVATION_REF_PREFIX}/{}/{id:03}", self.prefix)
}
}
impl Claim for GitRef {
fn claim(&self, ctx: &ClaimCtx<'_>) -> anyhow::Result<Acquired> {
let refname = self.refname(ctx.id);
let canonical = format!("{}-{:03}", self.prefix, ctx.id);
let new_oid = git::commit_empty_tree_as(
&self.root,
&canonical,
&self.holder_name,
&self.holder_email,
)
.with_context(|| format!("Failed to build reservation commit for {canonical}"))?;
match git::push_ref_cas(&self.root, &self.remote, &refname, &new_oid, git::ZERO_OID)
.with_context(|| format!("Failed to push reservation {refname}"))?
{
git::RefCas::Updated => {
match std::fs::create_dir(ctx.dir) {
Ok(()) => Ok(Acquired::Won),
Err(_) => Err(anyhow::anyhow!(
"reservation {canonical} pushed to the remote but its local dir \
{} could not be created (split state). Run `doctrine reseat {canonical}` \
and pick another id.",
ctx.dir.display()
)),
}
}
git::RefCas::Moved { .. } => Ok(Acquired::AlreadyHeld),
}
}
#[cfg(test)]
fn is_remote(&self) -> bool {
true
}
}
fn gitref_scan_source(root: &Path, remote: &str) -> ScanSource {
let root = root.to_path_buf();
let remote = remote.to_owned();
Box::new(move |local: &[u32]| {
git::fetch_refspec(&root, &remote, RESERVATION_REFSPEC)
.with_context(|| format!("Failed to fetch reservations from {remote}"))?;
let mut ids: Vec<u32> = local.to_vec();
ids.extend(remote_reservation_ids(&root)?);
Ok(ids)
})
}
fn remote_reservation_ids(root: &Path) -> anyhow::Result<Vec<u32>> {
let rows = git::for_each_ref(root, &format!("{RESERVATION_REF_PREFIX}/"))
.context("Failed to enumerate reservation refs")?;
Ok(rows
.iter()
.filter_map(|r| r.refname.rsplit('/').next())
.filter_map(|seg| seg.parse::<u32>().ok())
.collect())
}
fn local_scan_source() -> ScanSource {
Box::new(|local: &[u32]| Ok(local.to_vec()))
}
pub(crate) fn backend(
root: &Path,
prefix: &str,
prompt: PromptFn,
) -> anyhow::Result<(Box<dyn Claim>, ScanSource)> {
let cfg = load_reservation_config(root)?;
resolve_backend(root, prefix, &cfg, prompt)
}
fn resolve_backend(
root: &Path,
prefix: &str,
cfg: &ReservationConfig,
prompt: PromptFn,
) -> anyhow::Result<(Box<dyn Claim>, ScanSource)> {
match cfg.reach {
Reach::Local => Ok((Box::new(LocalFs), local_scan_source())),
Reach::Shared => {
let remote = require_remote(root, cfg, "shared")?;
probe_reachability(root, &remote).with_context(|| {
format!("reach=shared: reservation remote {remote} unreachable")
})?;
Ok(gitref(root, prefix, &remote))
}
Reach::Auto => resolve_auto(root, prefix, cfg, prompt),
}
}
fn resolve_auto(
root: &Path,
prefix: &str,
cfg: &ReservationConfig,
prompt: PromptFn,
) -> anyhow::Result<(Box<dyn Claim>, ScanSource)> {
let Some(remote) = configured_remote(root, cfg)? else {
signal_local_fallback("no remote configured");
return Ok((Box::new(LocalFs), local_scan_source()));
};
match probe_reachability(root, &remote) {
Ok(()) => Ok(gitref(root, prefix, &remote)),
Err(e) => {
if env_fallback_optin() || cfg.allow_local_fallback || prompt_fallback(&remote, prompt)?
{
signal_local_fallback(&format!("remote {remote} unreachable: {e}"));
Ok((Box::new(LocalFs), local_scan_source()))
} else {
Err(e).with_context(|| {
format!(
"reach=auto: reservation remote {remote} unreachable and local fallback \
declined. Set [reservation] allow-local-fallback=true or \
{ENV_FALLBACK}=1 to allocate locally."
)
})
}
}
}
}
fn gitref(root: &Path, prefix: &str, remote: &str) -> (Box<dyn Claim>, ScanSource) {
let (holder_name, holder_email) = git::resolve_holder(root);
let backend = GitRef {
root: root.to_path_buf(),
prefix: prefix.to_owned(),
remote: remote.to_owned(),
holder_name,
holder_email,
};
(Box::new(backend), gitref_scan_source(root, remote))
}
fn configured_remote(root: &Path, cfg: &ReservationConfig) -> anyhow::Result<Option<String>> {
if let Some(explicit) = &cfg.remote {
return Ok(Some(explicit.clone()));
}
Ok(git::resolve_remote(root)?)
}
fn require_remote(root: &Path, cfg: &ReservationConfig, reach: &str) -> anyhow::Result<String> {
configured_remote(root, cfg)?.with_context(|| {
format!("reach={reach}: no remote configured for reservation coordination")
})
}
fn probe_reachability(root: &Path, remote: &str) -> anyhow::Result<()> {
git::fetch_refspec(root, remote, RESERVATION_REFSPEC).map_err(anyhow::Error::from)
}
fn prompt_fallback(remote: &str, prompt: PromptFn) -> anyhow::Result<bool> {
use std::io::{IsTerminal, Write};
if !std::io::stdin().is_terminal() {
return Ok(false); }
drop(write!(
std::io::stderr(),
"reservation remote {remote} is unreachable. Allocate this id locally (reduced reach)? [y/N] "
));
prompt("")
}
fn signal_local_fallback(reason: &str) {
use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering};
static SIGNALLED: AtomicBool = AtomicBool::new(false);
if !SIGNALLED.swap(true, Ordering::Relaxed) {
drop(writeln!(
std::io::stderr(),
"doctrine: reservation reach degraded to local ({reason})"
));
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HeldClaim {
pub(crate) canonical: String,
pub(crate) holder: String,
pub(crate) acquired: String,
}
pub(crate) fn survey(
root: &Path,
remote: &str,
kind: Option<&str>,
) -> anyhow::Result<Vec<HeldClaim>> {
git::fetch_refspec(root, remote, RESERVATION_REFSPEC)
.with_context(|| format!("Failed to fetch reservations from {remote}"))?;
let rows = git::for_each_ref(root, &format!("{RESERVATION_REF_PREFIX}/"))
.context("Failed to enumerate reservation refs")?;
Ok(rows
.iter()
.filter_map(parse_held_claim)
.filter(|h| kind.is_none_or(|k| held_prefix(&h.canonical) == k))
.collect())
}
fn parse_held_claim(row: &crate::git::RefRow) -> Option<HeldClaim> {
let tail = row
.refname
.strip_prefix(RESERVATION_REF_PREFIX)?
.strip_prefix('/')?;
let mut segs = tail.rsplit('/');
let num = segs.next()?;
let prefix = segs.next()?;
let id: u32 = num.parse().ok()?;
if segs.next().is_some() || prefix.is_empty() {
return None;
}
Some(HeldClaim {
canonical: format!("{}-{id:03}", prefix.to_ascii_uppercase()),
holder: row.author.clone(),
acquired: row.date.clone(),
})
}
fn held_prefix(canonical: &str) -> &str {
canonical.split('-').next().unwrap_or(canonical)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reservation_namespace_constants() {
assert_eq!(RESERVATION_REF_PREFIX, "refs/doctrine/reservation");
assert_eq!(
RESERVATION_REFSPEC,
"+refs/doctrine/reservation/*:refs/doctrine/reservation/*"
);
}
#[test]
fn absent_table_defaults_to_auto_no_remote() {
let cfg = ReservationConfig::default();
assert_eq!(cfg.reach, Reach::Auto);
assert_eq!(cfg.remote, None);
assert!(!cfg.allow_local_fallback);
assert_eq!(
parse_reservation_config("[dispatch]\ndeliver-to = \"x\"\n").unwrap(),
ReservationConfig::default()
);
}
#[test]
fn explicit_reach_local_still_pins_single_tree() {
let cfg = parse_reservation_config("[reservation]\nreach = \"local\"\n")
.expect("parse explicit local");
assert_eq!(cfg.reach, Reach::Local);
}
#[test]
fn reservation_table_parses_tolerantly() {
let cfg = parse_reservation_config(
"[dispatch]\ndeliver-to = \"x\"\n\
[reservation]\nreach = \"auto\"\nremote = \"fork\"\nallow-local-fallback = true\n",
)
.expect("parse reservation");
assert_eq!(cfg.reach, Reach::Auto);
assert_eq!(cfg.remote.as_deref(), Some("fork"));
assert!(cfg.allow_local_fallback);
}
#[test]
fn reach_tokens_round_trip() {
for (tok, reach) in [
("local", Reach::Local),
("shared", Reach::Shared),
("auto", Reach::Auto),
] {
let cfg = parse_reservation_config(&format!("[reservation]\nreach = \"{tok}\"\n"))
.expect("parse reach");
assert_eq!(cfg.reach, reach);
}
}
#[test]
fn unknown_reach_is_an_error() {
let err = parse_reservation_config("[reservation]\nreach = \"global\"\n").unwrap_err();
assert!(
err.to_string().contains("reach"),
"error names the key: {err}"
);
}
#[test]
fn env_fallback_constant_is_stable() {
assert_eq!(ENV_FALLBACK, "DOCTRINE_RESERVATION_FALLBACK");
}
#[test]
fn local_scan_source_is_identity() {
let mut scan = local_scan_source();
assert_eq!(scan(&[1, 2, 5]).unwrap(), vec![1, 2, 5]);
assert_eq!(scan(&[]).unwrap(), Vec::<u32>::new());
}
use std::path::PathBuf;
use std::process::Command;
fn decline(_p: &str) -> anyhow::Result<bool> {
Ok(false)
}
fn git(dir: &Path, args: &[&str]) -> std::process::Output {
Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.env("GIT_AUTHOR_DATE", "2026-01-01T00:00:00 +0000")
.env("GIT_COMMITTER_DATE", "2026-01-01T00:00:00 +0000")
.output()
.expect("spawn git")
}
fn git_ok(dir: &Path, args: &[&str]) {
let out = git(dir, args);
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
struct Substrate {
_remote: tempfile::TempDir,
remote_path: PathBuf,
_clones: Vec<tempfile::TempDir>,
clone_paths: Vec<PathBuf>,
}
impl Substrate {
fn new(clones: usize) -> Self {
let remote = tempfile::tempdir().expect("remote dir");
let remote_path = remote.path().to_path_buf();
assert!(
Command::new("git")
.args(["init", "--bare", "-b", "main"])
.arg(&remote_path)
.output()
.expect("init bare")
.status
.success()
);
let mut _clones = Vec::new();
let mut clone_paths = Vec::new();
for i in 0..clones {
let c = tempfile::tempdir().expect("clone dir");
let p = c.path().to_path_buf();
git_ok(&p, &["init", "-b", "main"]);
git_ok(&p, &["config", "user.name", &format!("Agent {i}")]);
git_ok(
&p,
&["config", "user.email", &format!("agent{i}@doctrine.test")],
);
std::fs::write(p.join("seed.txt"), "seed").unwrap();
git_ok(&p, &["add", "seed.txt"]);
git_ok(&p, &["commit", "-m", "seed"]);
_clones.push(c);
clone_paths.push(p);
}
Self {
_remote: remote,
remote_path,
_clones,
clone_paths,
}
}
fn remote(&self) -> &str {
self.remote_path.to_str().unwrap()
}
fn clone(&self, i: usize) -> &Path {
&self.clone_paths[i]
}
fn write_config(&self, i: usize, body: &str) {
let path = self.clone(i).join(crate::dtoml::DOCTRINE_TOML);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, body).unwrap();
}
}
#[test]
fn vt1_two_clones_racing_the_same_id_do_not_collide() {
let env = Substrate::new(2);
let (b0, _s0) = gitref(env.clone(0), "TK", env.remote());
let dir0 = env.clone(0).join("tree/001");
std::fs::create_dir_all(env.clone(0).join("tree")).unwrap();
let won0 = b0.claim(&ClaimCtx { dir: &dir0, id: 1 }).unwrap();
assert!(matches!(won0, Acquired::Won), "first clone wins id 1");
let (b1, mut s1) = gitref(env.clone(1), "TK", env.remote());
std::fs::create_dir_all(env.clone(1).join("tree")).unwrap();
let dir1a = env.clone(1).join("tree/001");
let lost = b1.claim(&ClaimCtx { dir: &dir1a, id: 1 }).unwrap();
assert!(
matches!(lost, Acquired::AlreadyHeld),
"second clone loses id 1"
);
let union = s1(&[]).unwrap();
let next = crate::entity::next_id(&union, &[]);
assert_eq!(next, 2, "recompute lands the NEXT free id");
let dir1b = env.clone(1).join("tree/002");
let won1 = b1.claim(&ClaimCtx { dir: &dir1b, id: 2 }).unwrap();
assert!(matches!(won1, Acquired::Won), "second clone lands id 2");
let rows = git::for_each_ref(&env.remote_path, "refs/doctrine/reservation/TK/")
.expect("for_each_ref");
let mut ids: Vec<&str> = rows
.iter()
.filter_map(|r| r.refname.rsplit('/').next())
.collect();
ids.sort_unstable();
assert_eq!(ids, vec!["001", "002"], "one ref each for ids 1 and 2");
}
#[test]
fn vt4_gitref_claim_is_content_free() {
let env = Substrate::new(1);
let (b, _s) = gitref(env.clone(0), "SL", env.remote());
std::fs::create_dir_all(env.clone(0).join("tree")).unwrap();
let dir = env.clone(0).join("tree/148");
assert!(matches!(
b.claim(&ClaimCtx { dir: &dir, id: 148 }).unwrap(),
Acquired::Won
));
let rows = git::for_each_ref(&env.remote_path, "refs/doctrine/reservation/SL/148")
.expect("for_each_ref");
assert_eq!(rows.len(), 1);
let tree = git::git_text(
&env.remote_path,
&["rev-parse", &format!("{}^{{tree}}", rows[0].oid)],
)
.expect("rev-parse tree");
assert_eq!(tree, git::EMPTY_TREE_OID);
}
#[test]
fn vt2_reach_selection() {
let env = Substrate::new(1);
let root = env.clone(0);
env.write_config(
0,
"[reservation]\nreach = \"local\"\nremote = \"/no/such/remote\"\n",
);
let (b, _s) = backend(root, "TK", decline).expect("local backend");
assert!(
!b.is_remote(),
"local backend must be LocalFs (no remote contact)"
);
env.write_config(
0,
"[reservation]\nreach = \"shared\"\nremote = \"/no/such/remote\"\n",
);
assert!(
backend(root, "TK", decline).is_err(),
"shared + absent remote hard-errors"
);
env.write_config(
0,
&format!(
"[reservation]\nreach = \"shared\"\nremote = \"{}\"\n",
env.remote()
),
);
let (b, _s) = backend(root, "TK", decline).expect("shared backend");
assert!(b.is_remote(), "shared + reachable remote selects GitRef");
env.write_config(
0,
&format!(
"[reservation]\nreach = \"auto\"\nremote = \"{}\"\n",
env.remote()
),
);
let (b, _s) = backend(root, "TK", decline).expect("auto backend");
assert!(b.is_remote(), "auto + reachable remote selects GitRef");
}
#[test]
fn vt3_auto_degradation_is_fail_closed_with_explicit_optin() {
let env = Substrate::new(1);
let root = env.clone(0);
env.write_config(0, "[reservation]\nreach = \"auto\"\n");
let (b, _s) = backend(root, "TK", decline).expect("auto no-remote backend");
assert!(!b.is_remote(), "auto + no remote ⇒ LocalFs");
env.write_config(
0,
"[reservation]\nreach = \"auto\"\nremote = \"/no/such/remote\"\n",
);
assert!(
backend(root, "TK", decline).is_err(),
"auto + failing configured remote hard-errors when fallback declined"
);
env.write_config(
0,
"[reservation]\nreach = \"auto\"\nremote = \"/no/such/remote\"\nallow-local-fallback = true\n",
);
let (b, _s) = backend(root, "TK", decline).expect("opt-in fallback backend");
assert!(!b.is_remote(), "explicit opt-in ⇒ LocalFs fallback");
}
#[test]
fn vt2_default_auto_in_a_non_git_dir_degrades_to_localfs() {
let tmp = tempfile::TempDir::new().unwrap();
let (b, _s) = backend(tmp.path(), "TK", decline).expect("auto non-git ⇒ LocalFs");
assert!(
!b.is_remote(),
"default auto in a non-git dir must degrade to LocalFs, not error"
);
}
#[test]
fn vt5_split_state_hard_errors_with_reseat_hint() {
let env = Substrate::new(1);
let (b, _s) = gitref(env.clone(0), "SL", env.remote());
std::fs::create_dir_all(env.clone(0).join("tree")).unwrap();
let dir = env.clone(0).join("tree/009");
std::fs::write(&dir, "squat").unwrap(); let err = b.claim(&ClaimCtx { dir: &dir, id: 9 }).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("doctrine reseat SL-009"),
"reseat hint present: {msg}"
);
let rows = git::for_each_ref(&env.remote_path, "refs/doctrine/reservation/SL/009")
.expect("for_each_ref");
assert_eq!(
rows.len(),
1,
"remote ref is NOT rolled back (harmless gap)"
);
}
#[test]
fn vt6_local_backend_is_back_compatible() {
let env = Substrate::new(1);
env.write_config(0, ""); let (b, mut s) = backend(env.clone(0), "TK", decline).expect("default backend");
assert!(!b.is_remote(), "no [reservation] ⇒ LocalFs (EX-5)");
assert_eq!(s(&[3, 7]).unwrap(), vec![3, 7]);
}
fn hold(env: &Substrate, c: usize, prefix: &str, id: u32) {
let (b, _s) = gitref(env.clone(c), prefix, env.remote());
let tree = env.clone(c).join("tree");
std::fs::create_dir_all(&tree).unwrap();
let dir = tree.join(format!("{id:03}"));
assert!(
matches!(b.claim(&ClaimCtx { dir: &dir, id }).unwrap(), Acquired::Won),
"{prefix}-{id:03} should be claimable"
);
}
#[test]
fn vt1_survey_reports_holder_acquired_and_filters_by_kind() {
let env = Substrate::new(2);
hold(&env, 0, "SL", 148); hold(&env, 1, "SL", 7); hold(&env, 0, "IMP", 12);
let surveyor = env.clone(0);
let all = survey(surveyor, env.remote(), None).expect("survey all");
let mut canon: Vec<&str> = all.iter().map(|h| h.canonical.as_str()).collect();
canon.sort_unstable();
assert_eq!(canon, vec!["IMP-012", "SL-007", "SL-148"]);
for h in &all {
assert!(!h.holder.is_empty(), "holder populated for {}", h.canonical);
assert!(
!h.acquired.is_empty(),
"acquired populated for {}",
h.canonical
);
}
let sl148 = all.iter().find(|h| h.canonical == "SL-148").unwrap();
assert_eq!(sl148.holder, "Agent 0");
let sl7 = all.iter().find(|h| h.canonical == "SL-007").unwrap();
assert_eq!(sl7.holder, "Agent 1");
let sl_only = survey(surveyor, env.remote(), Some("SL")).expect("survey SL");
let mut sl_canon: Vec<&str> = sl_only.iter().map(|h| h.canonical.as_str()).collect();
sl_canon.sort_unstable();
assert_eq!(sl_canon, vec!["SL-007", "SL-148"]);
}
#[test]
fn vt2_malformed_ref_is_skipped_not_fatal() {
let env = Substrate::new(1);
hold(&env, 0, "SL", 1);
let oid = git::git_text(
&env.remote_path,
&["rev-parse", "refs/doctrine/reservation/SL/001"],
)
.expect("rev-parse reservation oid");
git_ok(
&env.remote_path,
&["update-ref", "refs/doctrine/reservation/SL/main", &oid],
);
git_ok(
&env.remote_path,
&["update-ref", "refs/doctrine/reservation/garbage", &oid],
);
let held = survey(env.clone(0), env.remote(), None).expect("survey skips malformed");
let canon: Vec<&str> = held.iter().map(|h| h.canonical.as_str()).collect();
assert_eq!(
canon,
vec!["SL-001"],
"only the well-formed ref survives (E3)"
);
}
#[test]
fn parse_held_claim_derives_canonical_and_skips_malformed() {
let row = |refname: &str| crate::git::RefRow {
refname: refname.to_owned(),
oid: "deadbeef".to_owned(),
author: "Agent 9".to_owned(),
date: "2026-01-01T00:00:00+00:00".to_owned(),
msg: String::new(),
};
let ok = parse_held_claim(&row("refs/doctrine/reservation/sl/148")).expect("well-formed");
assert_eq!(ok.canonical, "SL-148"); assert_eq!(ok.holder, "Agent 9");
assert_eq!(ok.acquired, "2026-01-01T00:00:00+00:00");
for bad in [
"refs/doctrine/reservation/SL/main",
"refs/doctrine/reservation/garbage",
"refs/doctrine/reservation/a/b/001",
"refs/heads/main",
] {
assert!(parse_held_claim(&row(bad)).is_none(), "skips {bad}");
}
}
}