#![cfg_attr(
not(test),
expect(
unused,
reason = "PHASE-02 worker_commit consumes the resolver; prod-dead until then"
)
)]
use super::create::{WORKTREES_SUBDIR, sanitise_name};
use crate::git;
use anyhow::Context;
use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
pub(crate) const RECORD_SUBPATH: &str = ".doctrine/state/dispatch/record";
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(crate) struct DispatchRecord {
pub(crate) name: String,
pub(crate) dir: PathBuf,
pub(crate) branch: String,
pub(crate) base: String,
pub(crate) coord: PathBuf,
}
fn record_path(coord: &Path, name: &str) -> PathBuf {
coord.join(RECORD_SUBPATH).join(format!("{name}.toml"))
}
pub(crate) fn provision_dispatch_record(
coord: &Path,
name: &str,
base: &str,
dir: &Path,
branch: &str,
) -> anyhow::Result<()> {
let record = DispatchRecord {
name: name.to_string(),
dir: dir.to_path_buf(),
branch: branch.to_string(),
base: base.to_string(),
coord: coord.to_path_buf(),
};
let body = toml::to_string(&record)
.with_context(|| format!("serialise dispatch record for {name}"))?;
let dest = record_path(coord, name);
let parent = dest
.parent()
.ok_or_else(|| anyhow::anyhow!("record path {} has no parent", dest.display()))?;
fs::create_dir_all(parent)
.with_context(|| format!("create dispatch record dir {}", parent.display()))?;
crate::fsutil::write_atomic(&dest, body.as_bytes())
.with_context(|| format!("write dispatch record {}", dest.display()))?;
Ok(())
}
pub(crate) fn delete_dispatch_record(coord: &Path, name: &str) -> anyhow::Result<()> {
let path = record_path(coord, name);
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
Err(e) => Err(e).with_context(|| format!("delete dispatch record {}", path.display())),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ResolveRefusal {
UnknownAgent,
AmbiguousAgent,
StaleRecord,
}
impl ResolveRefusal {
pub(crate) fn token(self) -> &'static str {
match self {
ResolveRefusal::UnknownAgent => "unknown-agent",
ResolveRefusal::AmbiguousAgent => "ambiguous-agent",
ResolveRefusal::StaleRecord => "stale-record",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolveFacts {
pub(crate) worktree_hits: usize,
pub(crate) record: Option<DispatchRecord>,
pub(crate) dir_exists: bool,
pub(crate) branch_head: Option<String>,
pub(crate) base_commit: Option<String>,
}
pub(crate) fn classify_resolve(facts: ResolveFacts) -> Result<DispatchRecord, ResolveRefusal> {
if facts.worktree_hits == 0 {
return Err(ResolveRefusal::UnknownAgent);
}
if facts.worktree_hits > 1 {
return Err(ResolveRefusal::AmbiguousAgent);
}
let Some(record) = facts.record else {
return Err(ResolveRefusal::StaleRecord);
};
if !facts.dir_exists {
return Err(ResolveRefusal::StaleRecord);
}
let Some(head) = facts.branch_head else {
return Err(ResolveRefusal::StaleRecord);
};
if facts.base_commit.as_deref() != Some(head.as_str()) {
return Err(ResolveRefusal::StaleRecord);
}
Ok(record)
}
fn coord_from_worktree_dir(dir: &Path, name: &str) -> Option<PathBuf> {
if dir.file_name()?.to_str()? != name {
return None;
}
let worktrees = dir.parent()?;
if worktrees.file_name()?.to_str()? != WORKTREES_SUBDIR {
return None;
}
Some(worktrees.parent()?.to_path_buf())
}
fn read_record(coord: &Path, name: &str) -> Option<DispatchRecord> {
let raw = fs::read_to_string(record_path(coord, name)).ok()?;
toml::from_str(&raw).ok()
}
fn resolve_commit(root: &Path, rev: &str) -> Option<String> {
git::git_opt(
root,
&[
"rev-parse",
"--verify",
"--quiet",
&format!("{rev}^{{commit}}"),
],
)
.ok()
.flatten()
}
pub(crate) fn resolve_agent(root: &Path, agent: &str) -> Result<DispatchRecord, ResolveRefusal> {
let Ok(name) = sanitise_name(agent) else {
return Err(ResolveRefusal::UnknownAgent);
};
let branch_ref = format!("refs/heads/dispatch/{name}");
let worktree = git::worktree_for_ref(root, &branch_ref).unwrap_or(None);
let worktree_hits = usize::from(worktree.is_some());
let record = worktree
.as_deref()
.and_then(|dir| coord_from_worktree_dir(dir, &name))
.and_then(|coord| read_record(&coord, &name));
let dir_exists = record.as_ref().is_some_and(|r| r.dir.exists());
let branch_head = resolve_commit(root, &branch_ref);
let base_commit = record.as_ref().and_then(|r| resolve_commit(root, &r.base));
classify_resolve(ResolveFacts {
worktree_hits,
record,
dir_exists,
branch_head,
base_commit,
})
}