use std::path::{Path, PathBuf};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum LedgerStatus {
Pending,
Verified,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub(crate) struct Journal {
#[serde(default, rename = "row")]
pub rows: Vec<JournalRow>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct JournalRow {
pub source_oid: String,
pub target_ref: String,
pub expected_old_oid: String,
pub planned_new_oid: String,
#[serde(default)]
pub applied_new_oid: String,
pub status: LedgerStatus,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub(crate) struct Boundaries {
#[serde(default, rename = "boundary")]
pub rows: Vec<BoundaryRow>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct BoundaryRow {
pub phase: String,
pub code_start_oid: String,
pub code_end_oid: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub(crate) struct Orthogonal {
#[serde(default, rename = "mark")]
pub rows: Vec<OrthogonalMark>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct OrthogonalMark {
pub entity: String,
pub path: String,
pub status: LedgerStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CandidateKind {
Audit,
Experiment,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CandidateRole {
ReviewSurface,
CloseTarget,
Scratch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CandidatePayload {
ImplBundle,
Code,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CandidateStatus {
Created,
Conflicted,
Abandoned,
Superseded,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub(crate) struct Candidates {
#[serde(default, rename = "candidate")]
pub rows: Vec<CandidateRow>,
#[serde(default)]
pub current_admission: CurrentAdmission,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct CandidateRow {
pub id: String,
pub label: String,
pub kind: CandidateKind,
pub role: CandidateRole,
pub payload: CandidatePayload,
pub target_ref: String,
pub source_ref: String,
pub source_oid: String,
pub base_ref: String,
pub base_oid: String,
pub merge_oid: String,
pub status: CandidateStatus,
#[serde(default)]
pub supersedes: String,
#[serde(default)]
pub reason: String,
pub created_by: String,
pub created_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub(crate) struct CurrentAdmission {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub close_target: Option<Admission>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_surface: Option<Admission>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct Admission {
pub candidate_id: String,
pub candidate_ref: String,
pub expected_ref_oid: String,
pub admitted_oid: String,
pub review: String,
#[serde(default)]
pub supersedes: String,
pub admitted_at: String,
}
impl Candidates {
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "candidate create/status/admit are the first non-test callers (PHASE-02+)"
)
)]
pub(crate) fn parse(text: &str) -> anyhow::Result<Candidates> {
Ok(toml::from_str(text)?)
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "candidate create/status/admit are the first non-test callers (PHASE-02+)"
)
)]
pub(crate) fn to_toml(&self) -> anyhow::Result<String> {
Ok(toml::to_string(self)?)
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "candidate create/status/admit are the first non-test callers (PHASE-02+)"
)
)]
pub(crate) fn set_candidate_status(&mut self, id: &str, status: CandidateStatus) -> bool {
match self.rows.iter_mut().find(|r| r.id == id) {
Some(row) => {
row.status = status;
true
}
None => false,
}
}
}
impl Journal {
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "stage-2 integrate is the first non-test reader (PHASE-05)"
)
)]
pub(crate) fn parse(text: &str) -> anyhow::Result<Journal> {
Ok(toml::from_str(text)?)
}
pub(crate) fn to_toml(&self) -> anyhow::Result<String> {
Ok(toml::to_string(self)?)
}
}
impl Boundaries {
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "stage-2 integrate / funnel are the first non-test callers"
)
)]
pub(crate) fn parse(text: &str) -> anyhow::Result<Boundaries> {
Ok(toml::from_str(text)?)
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "funnel-time recording is the first non-test writer (PHASE-06)"
)
)]
pub(crate) fn to_toml(&self) -> anyhow::Result<String> {
Ok(toml::to_string(self)?)
}
}
impl Orthogonal {
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "stage-2 integrate / funnel are the first non-test callers"
)
)]
pub(crate) fn parse(text: &str) -> anyhow::Result<Orthogonal> {
Ok(toml::from_str(text)?)
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "funnel-time recording is the first non-test writer (PHASE-06)"
)
)]
pub(crate) fn to_toml(&self) -> anyhow::Result<String> {
Ok(toml::to_string(self)?)
}
}
fn dispatch_dir(root: &Path, slice: u32) -> PathBuf {
root.join(".doctrine")
.join("dispatch")
.join(format!("{slice:03}"))
}
fn load<T: DeserializeOwned + Default>(path: &Path) -> anyhow::Result<T> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(toml::from_str(&text)?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
Err(e) => Err(e.into()),
}
}
fn store<T: Serialize>(path: &Path, manifest: &T) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
#[expect(clippy::disallowed_methods, reason = "runtime coordination manifest")]
std::fs::write(path, toml::to_string(manifest)?)?;
Ok(())
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "stage-2 integrate is the first non-test reader (PHASE-05)"
)
)]
pub(crate) fn read_journal(root: &Path, slice: u32) -> anyhow::Result<Journal> {
load(&dispatch_dir(root, slice).join("journal.toml"))
}
pub(crate) fn read_journal_at_ref(root: &Path, slice: u32) -> anyhow::Result<Option<Journal>> {
let refish = format!("refs/heads/dispatch/{slice:03}");
let path = format!(".doctrine/dispatch/{slice:03}/journal.toml");
match crate::git::read_path_at(root, &refish, &path)? {
Some(text) => Ok(Some(toml::from_str(&text)?)),
None => Ok(None),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TrunkIntegration {
NotDispatched,
Integrated,
Blocked(String),
}
pub(crate) fn trunk_integration(
root: &Path,
slice: u32,
trunk_ref: &str,
) -> anyhow::Result<TrunkIntegration> {
let dispatch_ref = format!("refs/heads/dispatch/{slice:03}");
if crate::git::resolve_ref(root, &dispatch_ref)?.is_none() {
return Ok(TrunkIntegration::NotDispatched);
}
let journal = match read_journal_at_ref(root, slice) {
Ok(Some(journal)) => journal,
Ok(None) => return Ok(TrunkIntegration::NotDispatched),
Err(_unreadable) => {
return Ok(TrunkIntegration::Blocked("journal unreadable".to_owned()));
}
};
if journal.rows.is_empty() {
return Ok(TrunkIntegration::NotDispatched);
}
let mut trunk_rows = journal.rows.iter().filter(|r| r.target_ref == trunk_ref);
let Some(row) = trunk_rows.next() else {
return Ok(TrunkIntegration::Blocked(
"dispatched but no trunk row — integrate --trunk never completed".to_owned(),
));
};
if trunk_rows.next().is_some() {
return Ok(TrunkIntegration::Blocked("ambiguous trunk row".to_owned()));
}
if row.planned_new_oid.is_empty() {
return Ok(TrunkIntegration::Blocked(
"trunk row has no planned oid".to_owned(),
));
}
let Some(tip) = crate::git::resolve_ref(root, trunk_ref)? else {
return Ok(TrunkIntegration::Blocked(format!(
"trunk ref {trunk_ref} unresolved"
)));
};
if crate::git::is_ancestor(root, &row.planned_new_oid, &tip)? {
Ok(TrunkIntegration::Integrated)
} else {
Ok(TrunkIntegration::Blocked(
"planned tip not on trunk".to_owned(),
))
}
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "funnel-time read-modify-write side; the sync verb tree-reads instead (read_path_at)"
)
)]
pub(crate) fn read_boundaries(root: &Path, slice: u32) -> anyhow::Result<Boundaries> {
load(&dispatch_dir(root, slice).join("boundaries.toml"))
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "funnel-time read-modify-write side; the sync verb tree-reads instead (read_path_at)"
)
)]
pub(crate) fn read_orthogonal(root: &Path, slice: u32) -> anyhow::Result<Orthogonal> {
load(&dispatch_dir(root, slice).join("orthogonal.toml"))
}
pub(crate) fn record_boundary(root: &Path, slice: u32, row: BoundaryRow) -> anyhow::Result<()> {
let path = dispatch_dir(root, slice).join("boundaries.toml");
let mut manifest: Boundaries = load(&path)?;
manifest.rows.push(row);
store(&path, &manifest)
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "no funnel verb yet — its driver is the OQ-B orthogonal classifier (deferred plan-gate); empty orthogonal.toml is the conservative EXCLUDE fallback (IMP backlog)"
)
)]
pub(crate) fn record_orthogonal(
root: &Path,
slice: u32,
mark: OrthogonalMark,
) -> anyhow::Result<()> {
let path = dispatch_dir(root, slice).join("orthogonal.toml");
let mut manifest: Orthogonal = load(&path)?;
manifest.rows.push(mark);
store(&path, &manifest)
}
pub(crate) fn read_candidates(root: &Path, slice: u32) -> anyhow::Result<Candidates> {
load(&dispatch_dir(root, slice).join("candidates.toml"))
}
pub(crate) fn write_candidates(
root: &Path,
slice: u32,
candidates: &Candidates,
) -> anyhow::Result<()> {
store(
&dispatch_dir(root, slice).join("candidates.toml"),
candidates,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn journal_round_trips_and_pins_field_names() {
let journal = Journal {
rows: vec![JournalRow {
source_oid: "aaa".into(),
target_ref: "refs/review/64".into(),
expected_old_oid: "0".repeat(40),
planned_new_oid: "bbb".into(),
applied_new_oid: String::new(),
status: LedgerStatus::Pending,
}],
};
let text = journal.to_toml().expect("serialize");
assert!(text.contains("[[row]]"), "table header: {text}");
assert!(text.contains("source_oid ="), "{text}");
assert!(text.contains("target_ref ="), "{text}");
assert!(text.contains("expected_old_oid ="), "{text}");
assert!(text.contains("planned_new_oid ="), "{text}");
assert!(text.contains("applied_new_oid ="), "{text}");
assert!(
text.contains("status = \"pending\""),
"lowercase token: {text}"
);
assert_eq!(Journal::parse(&text).expect("parse"), journal);
}
#[test]
fn boundaries_round_trip_and_orthogonal_round_trip() {
let boundaries = Boundaries {
rows: vec![BoundaryRow {
phase: "PHASE-03".into(),
code_start_oid: "s".into(),
code_end_oid: "e".into(),
}],
};
let text = boundaries.to_toml().expect("ser");
assert!(text.contains("[[boundary]]"), "{text}");
assert!(text.contains("phase = \"PHASE-03\""), "{text}");
assert_eq!(Boundaries::parse(&text).expect("parse"), boundaries);
let orthogonal = Orthogonal {
rows: vec![OrthogonalMark {
entity: "ADR-012".into(),
path: ".doctrine/adr/012".into(),
status: LedgerStatus::Verified,
}],
};
let text = orthogonal.to_toml().expect("ser");
assert!(text.contains("[[mark]]"), "{text}");
assert!(text.contains("status = \"verified\""), "{text}");
assert_eq!(Orthogonal::parse(&text).expect("parse"), orthogonal);
}
#[test]
fn empty_manifests_round_trip() {
for text in [
Journal::default().to_toml().unwrap(),
Boundaries::default().to_toml().unwrap(),
Orthogonal::default().to_toml().unwrap(),
] {
assert!(Journal::parse(&text).is_ok());
}
assert_eq!(Journal::parse("").unwrap(), Journal::default());
assert_eq!(Boundaries::parse("").unwrap(), Boundaries::default());
assert_eq!(Orthogonal::parse("").unwrap(), Orthogonal::default());
}
#[test]
fn record_then_read_round_trips_through_disk() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
let slice = 64;
assert_eq!(read_boundaries(root, slice).unwrap(), Boundaries::default());
assert_eq!(read_orthogonal(root, slice).unwrap(), Orthogonal::default());
record_boundary(
root,
slice,
BoundaryRow {
phase: "PHASE-01".into(),
code_start_oid: "s1".into(),
code_end_oid: "e1".into(),
},
)
.expect("record boundary 1");
record_boundary(
root,
slice,
BoundaryRow {
phase: "PHASE-02".into(),
code_start_oid: "s2".into(),
code_end_oid: "e2".into(),
},
)
.expect("record boundary 2");
record_orthogonal(
root,
slice,
OrthogonalMark {
entity: "ADR-012".into(),
path: ".doctrine/adr/012".into(),
status: LedgerStatus::Verified,
},
)
.expect("record mark");
assert!(root.join(".doctrine/dispatch/064/boundaries.toml").exists());
let boundaries = read_boundaries(root, slice).unwrap();
let phases: Vec<&str> = boundaries.rows.iter().map(|r| r.phase.as_str()).collect();
assert_eq!(phases, vec!["PHASE-01", "PHASE-02"]);
let orthogonal = read_orthogonal(root, slice).unwrap();
assert_eq!(orthogonal.rows.len(), 1);
assert_eq!(orthogonal.rows[0].status, LedgerStatus::Verified);
assert_eq!(read_journal(root, slice).unwrap(), Journal::default());
}
fn sample_candidate(id: &str, label: &str, status: CandidateStatus) -> CandidateRow {
CandidateRow {
id: id.into(),
label: label.into(),
kind: CandidateKind::Audit,
role: CandidateRole::ReviewSurface,
payload: CandidatePayload::ImplBundle,
target_ref: format!("refs/heads/candidate/068/{label}"),
source_ref: "refs/heads/review/068".into(),
source_oid: "src-oid".into(),
base_ref: "refs/heads/main".into(),
base_oid: "base-oid".into(),
merge_oid: "merge-oid".into(),
status,
supersedes: String::new(),
reason: String::new(),
created_by: "dispatch candidate create".into(),
created_at: "2026-06-15".into(),
}
}
#[test]
fn candidates_round_trip_and_pin_field_names() {
let manifest = Candidates {
rows: vec![
sample_candidate(
"cand-068-review-001",
"review-001",
CandidateStatus::Created,
),
sample_candidate(
"cand-068-review-002",
"review-002",
CandidateStatus::Conflicted,
),
],
current_admission: CurrentAdmission {
close_target: Some(Admission {
candidate_id: "cand-068-close-001".into(),
candidate_ref: "refs/heads/candidate/068/close-001".into(),
expected_ref_oid: "ref-oid".into(),
admitted_oid: "admitted-oid".into(),
review: "RV-007".into(),
supersedes: String::new(),
admitted_at: "2026-06-15".into(),
}),
review_surface: None,
},
};
let text = manifest.to_toml().expect("serialize");
assert!(text.contains("[[candidate]]"), "table header: {text}");
assert!(text.contains("id ="), "{text}");
assert!(text.contains("label ="), "{text}");
assert!(text.contains("target_ref ="), "{text}");
assert!(text.contains("source_oid ="), "{text}");
assert!(text.contains("base_oid ="), "{text}");
assert!(text.contains("merge_oid ="), "{text}");
assert!(text.contains("created_by ="), "{text}");
assert!(text.contains("created_at ="), "{text}");
assert!(text.contains("status = \"created\""), "{text}");
assert!(text.contains("role = \"review_surface\""), "{text}");
assert!(text.contains("kind = \"audit\""), "{text}");
assert!(text.contains("payload = \"impl_bundle\""), "{text}");
assert!(
text.contains("[current_admission.close_target]"),
"admission table: {text}"
);
assert_eq!(Candidates::parse(&text).expect("parse"), manifest);
}
#[test]
fn candidates_empty_and_absent_default() {
assert_eq!(Candidates::parse("").unwrap(), Candidates::default());
let text = Candidates::default().to_toml().unwrap();
assert_eq!(Candidates::parse(&text).unwrap(), Candidates::default());
let dir = tempfile::tempdir().expect("tempdir");
assert_eq!(
read_candidates(dir.path(), 68).unwrap(),
Candidates::default()
);
}
#[test]
fn candidates_reject_unknown_tokens() {
let bad_role = r#"
[[candidate]]
id = "x"
label = "x"
kind = "audit"
role = "bogus"
payload = "impl_bundle"
target_ref = "r"
source_ref = "r"
source_oid = "o"
base_ref = "r"
base_oid = "o"
merge_oid = "o"
status = "created"
created_by = "v"
created_at = "d"
"#;
assert!(Candidates::parse(bad_role).is_err(), "bogus role must fail");
let bad_kind = bad_role
.replace("role = \"bogus\"", "role = \"review_surface\"")
.replace("kind = \"audit\"", "kind = \"bogus\"");
assert!(
Candidates::parse(&bad_kind).is_err(),
"bogus kind must fail"
);
let bad_payload = bad_role
.replace("role = \"bogus\"", "role = \"review_surface\"")
.replace("payload = \"impl_bundle\"", "payload = \"bogus\"");
assert!(
Candidates::parse(&bad_payload).is_err(),
"bogus payload must fail"
);
}
#[test]
fn set_candidate_status_mutates_only_status() {
let mut manifest = Candidates {
rows: vec![sample_candidate(
"cand-068-review-001",
"review-001",
CandidateStatus::Created,
)],
current_admission: CurrentAdmission::default(),
};
let before = manifest.rows[0].clone();
assert!(manifest.set_candidate_status("cand-068-review-001", CandidateStatus::Abandoned));
assert!(!manifest.set_candidate_status("nope", CandidateStatus::Abandoned));
let after = &manifest.rows[0];
assert_eq!(after.status, CandidateStatus::Abandoned);
assert_eq!(after.id, before.id);
assert_eq!(after.label, before.label);
assert_eq!(after.kind, before.kind);
assert_eq!(after.role, before.role);
assert_eq!(after.payload, before.payload);
assert_eq!(after.target_ref, before.target_ref);
assert_eq!(after.source_ref, before.source_ref);
assert_eq!(after.source_oid, before.source_oid);
assert_eq!(after.base_ref, before.base_ref);
assert_eq!(after.base_oid, before.base_oid);
assert_eq!(after.merge_oid, before.merge_oid);
assert_eq!(after.created_by, before.created_by);
assert_eq!(after.created_at, before.created_at);
}
use std::process::Command;
struct JournalRepo {
_dir: tempfile::TempDir,
path: PathBuf,
}
impl JournalRepo {
fn new() -> Self {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().to_path_buf();
let repo = Self { _dir: dir, path };
repo.git(&["init", "-b", "main"]);
repo.git(&["config", "user.name", "Doctrine Test"]);
repo.git(&["config", "user.email", "test@doctrine.invalid"]);
repo
}
fn git(&self, args: &[&str]) -> String {
let output = Command::new("git")
.arg("-C")
.arg(&self.path)
.args(args)
.env("GIT_AUTHOR_DATE", "2026-06-20T00:00:00 +0000")
.env("GIT_COMMITTER_DATE", "2026-06-20T00:00:00 +0000")
.output()
.expect("spawn git");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn commit_file(&self, rel: &str, contents: &str, message: &str) -> String {
let full = self.path.join(rel);
if let Some(parent) = full.parent() {
std::fs::create_dir_all(parent).expect("create parent");
}
std::fs::write(&full, contents).expect("write file");
self.git(&["add", rel]);
self.git(&["commit", "-m", message]);
self.git(&["rev-parse", "HEAD"])
}
fn commit_journal(&self, slice: u32, body: &str) -> String {
let branch = format!("dispatch/{slice:03}");
self.git(&["checkout", "--orphan", &branch]);
self.git(&["rm", "-rf", "--cached", "--ignore-unmatch", "."]);
let rel = format!(".doctrine/dispatch/{slice:03}/journal.toml");
let oid = self.commit_file(&rel, body, "coordinate: journal");
self.git(&["checkout", "-f", "main"]);
oid
}
fn path(&self) -> &Path {
&self.path
}
}
fn journal_row_toml(target_ref: &str, planned_new_oid: &str) -> String {
format!(
"[[row]]\n\
source_oid = \"src\"\n\
target_ref = \"{target_ref}\"\n\
expected_old_oid = \"{zero}\"\n\
planned_new_oid = \"{planned_new_oid}\"\n\
status = \"pending\"\n",
zero = "0".repeat(40),
)
}
const TRUNK: &str = "refs/heads/main";
#[test]
fn trunk_integration_no_dispatch_ref_is_not_dispatched() {
let repo = JournalRepo::new();
repo.commit_file("a.txt", "hi", "init"); assert_eq!(
trunk_integration(repo.path(), 126, TRUNK).unwrap(),
TrunkIntegration::NotDispatched,
);
}
#[test]
fn trunk_integration_ref_present_journal_absent_is_not_dispatched() {
let repo = JournalRepo::new();
repo.commit_file("a.txt", "hi", "init");
repo.git(&["checkout", "--orphan", "dispatch/126"]);
repo.git(&["rm", "-rf", "--cached", "--ignore-unmatch", "."]);
repo.commit_file("placeholder.txt", "x", "coordinate: no journal");
repo.git(&["checkout", "-f", "main"]);
assert_eq!(
trunk_integration(repo.path(), 126, TRUNK).unwrap(),
TrunkIntegration::NotDispatched,
);
}
#[test]
fn trunk_integration_zero_rows_is_not_dispatched() {
let repo = JournalRepo::new();
repo.commit_file("a.txt", "hi", "init");
repo.commit_journal(126, "");
assert_eq!(
trunk_integration(repo.path(), 126, TRUNK).unwrap(),
TrunkIntegration::NotDispatched,
);
}
#[test]
fn trunk_integration_planned_ancestor_is_integrated() {
let repo = JournalRepo::new();
repo.commit_file("a.txt", "1", "init");
let landed = repo.commit_file("b.txt", "2", "feature landed");
repo.commit_file("c.txt", "3", "advance trunk"); repo.commit_journal(126, &journal_row_toml(TRUNK, &landed));
assert_eq!(
trunk_integration(repo.path(), 126, TRUNK).unwrap(),
TrunkIntegration::Integrated,
);
}
#[test]
fn trunk_integration_planned_not_ancestor_is_blocked() {
let repo = JournalRepo::new();
repo.commit_file("a.txt", "1", "init");
repo.git(&["checkout", "-b", "side"]);
let orphaned = repo.commit_file("side.txt", "x", "divergent work");
repo.git(&["checkout", "-f", "main"]);
repo.commit_file("b.txt", "2", "advance trunk");
repo.commit_journal(126, &journal_row_toml(TRUNK, &orphaned));
assert_eq!(
trunk_integration(repo.path(), 126, TRUNK).unwrap(),
TrunkIntegration::Blocked("planned tip not on trunk".to_owned()),
);
}
#[test]
fn trunk_integration_no_trunk_row_is_blocked() {
let repo = JournalRepo::new();
let oid = repo.commit_file("a.txt", "1", "init");
repo.commit_journal(126, &journal_row_toml("refs/heads/edge", &oid));
assert_eq!(
trunk_integration(repo.path(), 126, TRUNK).unwrap(),
TrunkIntegration::Blocked(
"dispatched but no trunk row — integrate --trunk never completed".to_owned()
),
);
}
#[test]
fn trunk_integration_two_trunk_rows_is_ambiguous() {
let repo = JournalRepo::new();
let oid = repo.commit_file("a.txt", "1", "init");
let body = format!(
"{}{}",
journal_row_toml(TRUNK, &oid),
journal_row_toml(TRUNK, &oid),
);
repo.commit_journal(126, &body);
assert_eq!(
trunk_integration(repo.path(), 126, TRUNK).unwrap(),
TrunkIntegration::Blocked("ambiguous trunk row".to_owned()),
);
}
#[test]
fn trunk_integration_malformed_journal_is_unreadable() {
let repo = JournalRepo::new();
repo.commit_file("a.txt", "1", "init");
repo.commit_journal(126, "this = = not valid toml [[[");
assert_eq!(
trunk_integration(repo.path(), 126, TRUNK).unwrap(),
TrunkIntegration::Blocked("journal unreadable".to_owned()),
);
}
#[test]
fn trunk_integration_empty_planned_oid_is_blocked() {
let repo = JournalRepo::new();
repo.commit_file("a.txt", "1", "init");
repo.commit_journal(126, &journal_row_toml(TRUNK, ""));
assert_eq!(
trunk_integration(repo.path(), 126, TRUNK).unwrap(),
TrunkIntegration::Blocked("trunk row has no planned oid".to_owned()),
);
}
#[test]
fn trunk_integration_exact_match_selects_trunk_among_edge() {
let repo = JournalRepo::new();
repo.commit_file("a.txt", "1", "init");
let landed = repo.commit_file("b.txt", "2", "feature landed");
repo.commit_file("c.txt", "3", "advance trunk");
let body = format!(
"{}{}",
journal_row_toml("refs/heads/edge", "deadbeef"),
journal_row_toml(TRUNK, &landed),
);
repo.commit_journal(126, &body);
assert_eq!(
trunk_integration(repo.path(), 126, TRUNK).unwrap(),
TrunkIntegration::Integrated,
);
}
#[test]
fn read_journal_at_ref_none_and_some() {
let repo = JournalRepo::new();
let oid = repo.commit_file("a.txt", "1", "init");
assert_eq!(read_journal_at_ref(repo.path(), 126).unwrap(), None);
repo.git(&["checkout", "--orphan", "dispatch/126"]);
repo.git(&["rm", "-rf", "--cached", "--ignore-unmatch", "."]);
repo.commit_file("placeholder.txt", "x", "coordinate: no journal");
repo.git(&["checkout", "-f", "main"]);
assert_eq!(read_journal_at_ref(repo.path(), 126).unwrap(), None);
repo.commit_journal(200, &journal_row_toml(TRUNK, &oid));
let journal = read_journal_at_ref(repo.path(), 200)
.unwrap()
.expect("committed journal parses to Some");
assert_eq!(journal.rows.len(), 1);
assert_eq!(journal.rows[0].target_ref, TRUNK);
assert_eq!(journal.rows[0].planned_new_oid, oid);
}
}