use std::collections::BTreeSet;
use std::fmt;
use octl_core::plan;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const BASELINE_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)]
pub struct TestId {
pub package: String,
pub target_kind: String,
pub target: String,
pub name: String,
}
impl TestId {
pub fn new(
package: impl Into<String>,
target_kind: impl Into<String>,
target: impl Into<String>,
name: impl Into<String>,
) -> Self {
Self {
package: package.into(),
target_kind: target_kind.into(),
target: target.into(),
name: name.into(),
}
}
#[must_use]
pub fn canonical(&self) -> String {
format!(
"{}/{}/{}::{}",
self.package, self.target_kind, self.target, self.name
)
}
}
impl fmt::Display for TestId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.canonical())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TestSnapshot {
pub passed: BTreeSet<TestId>,
pub failed: BTreeSet<TestId>,
pub ignored: BTreeSet<TestId>,
#[serde(default)]
pub targets: BTreeSet<String>,
}
impl TestSnapshot {
#[must_use]
pub fn all_ids(&self) -> BTreeSet<TestId> {
self.passed
.iter()
.chain(&self.failed)
.chain(&self.ignored)
.cloned()
.collect()
}
#[must_use]
pub fn total(&self) -> usize {
self.all_ids().len()
}
#[must_use]
pub fn passed_canonical(&self) -> BTreeSet<String> {
self.passed.iter().map(TestId::canonical).collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)]
pub struct ClippyWarning {
pub lint: String,
pub package: String,
pub file: String,
pub message: String,
}
impl ClippyWarning {
#[must_use]
pub fn canonical(&self) -> String {
format!(
"{}\u{1f}{}\u{1f}{}\u{1f}{}",
self.lint, self.package, self.file, self.message
)
}
}
impl fmt::Display for ClippyWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.file.is_empty() {
write!(f, "[{}] {}: {}", self.package, self.lint, self.message)
} else {
write!(
f,
"[{}] {}:{}: {}",
self.package, self.file, self.lint, self.message
)
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClippySnapshot {
pub warnings: BTreeSet<ClippyWarning>,
}
impl ClippySnapshot {
#[must_use]
pub fn canonical(&self) -> BTreeSet<String> {
self.warnings.iter().map(ClippyWarning::canonical).collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Coverage {
pub covered_lines: u64,
pub total_lines: u64,
}
impl Coverage {
#[must_use]
pub fn fraction(&self) -> f64 {
if self.total_lines == 0 {
0.0
} else {
self.covered_lines as f64 / self.total_lines as f64
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunSnapshot {
pub tests: TestSnapshot,
pub clippy: ClippySnapshot,
pub coverage: Option<Coverage>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BaselineSnapshot {
#[serde(default = "default_baseline_schema_version")]
pub schema_version: u32,
pub r#ref: String,
pub commit_oid: String,
#[serde(default)]
pub toolchain: String,
pub snapshot: RunSnapshot,
}
fn default_baseline_schema_version() -> u32 {
BASELINE_SCHEMA_VERSION
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BaselineMismatch {
MissingProvenance {
field: &'static str,
side: &'static str,
},
MalformedCommitOid {
side: &'static str,
value: String,
},
UnknownToolchain {
side: &'static str,
},
Ref {
plan: String,
live: String,
},
CommitOid {
plan: String,
live: String,
},
Toolchain {
plan: String,
live: String,
},
TestPasslistHash,
ClippyWarningsHash,
EnumeratedTargetsHash,
}
impl fmt::Display for BaselineMismatch {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BaselineMismatch::MissingProvenance { field, side } => {
write!(f, "baseline provenance field {field:?} is empty on the {side} side; failing closed")
}
BaselineMismatch::MalformedCommitOid { side, value } => {
write!(
f,
"baseline commit_oid on the {side} side is not a full-length git OID: {value:?}; failing closed"
)
}
BaselineMismatch::UnknownToolchain { side } => {
write!(
f,
"baseline toolchain on the {side} side is \"unknown\"; failing closed"
)
}
BaselineMismatch::Ref { plan, live } => {
write!(f, "baseline ref mismatch: plan={plan:?} live={live:?}")
}
BaselineMismatch::CommitOid { plan, live } => {
write!(
f,
"baseline commit-oid mismatch: plan={plan:?} live={live:?}"
)
}
BaselineMismatch::Toolchain { plan, live } => {
write!(
f,
"baseline toolchain mismatch: plan={plan:?} live={live:?}"
)
}
BaselineMismatch::TestPasslistHash => {
f.write_str("baseline test-passlist hash mismatch")
}
BaselineMismatch::ClippyWarningsHash => {
f.write_str("baseline clippy-warnings hash mismatch")
}
BaselineMismatch::EnumeratedTargetsHash => {
f.write_str("baseline enumerated-targets hash mismatch")
}
}
}
}
impl std::error::Error for BaselineMismatch {}
#[must_use]
pub fn is_full_git_oid(s: &str) -> bool {
matches!(s.len(), 40 | 64) && s.chars().all(|c| c.is_ascii_hexdigit())
}
#[must_use]
pub fn toolchains_compatible(plan: &str, live: &str) -> bool {
match (parse_rustc_version(plan), parse_rustc_version(live)) {
(Some(a), Some(b)) => a.major == b.major && a.minor == b.minor,
_ => plan == live,
}
}
fn parse_rustc_version(s: &str) -> Option<semver::Version> {
let mut it = s.split_whitespace();
if it.next()? != "rustc" {
return None;
}
let ver = it.next()?;
semver::Version::parse(ver).ok()
}
impl BaselineSnapshot {
pub fn new(
r#ref: impl Into<String>,
commit_oid: impl Into<String>,
toolchain: impl Into<String>,
snapshot: RunSnapshot,
) -> Self {
Self {
schema_version: BASELINE_SCHEMA_VERSION,
r#ref: r#ref.into(),
commit_oid: commit_oid.into(),
toolchain: toolchain.into(),
snapshot,
}
}
#[must_use]
pub fn to_plan_baseline(&self) -> plan::Baseline {
plan::Baseline {
r#ref: self.r#ref.clone(),
commit_oid: self.commit_oid.clone(),
toolchain: self.toolchain.clone(),
test_passlist_hash: hash_sorted(&self.snapshot.tests.passed_canonical()),
clippy_warnings_hash: hash_sorted(&self.snapshot.clippy.canonical()),
enumerated_targets_hash: hash_sorted(&self.snapshot.tests.targets),
extra: serde_json::Map::new(),
}
}
pub fn verify_plan_baseline(&self, plan: &plan::Baseline) -> Result<(), BaselineMismatch> {
for (field, plan_val, live_val) in [
("commit_oid", &plan.commit_oid, &self.commit_oid),
("toolchain", &plan.toolchain, &self.toolchain),
] {
if plan_val.is_empty() {
return Err(BaselineMismatch::MissingProvenance {
field,
side: "plan",
});
}
if live_val.is_empty() {
return Err(BaselineMismatch::MissingProvenance {
field,
side: "live",
});
}
}
if self.toolchain == "unknown" {
return Err(BaselineMismatch::UnknownToolchain { side: "live" });
}
if plan.toolchain == "unknown" {
return Err(BaselineMismatch::UnknownToolchain { side: "plan" });
}
if !is_full_git_oid(&self.commit_oid) {
return Err(BaselineMismatch::MalformedCommitOid {
side: "live",
value: self.commit_oid.clone(),
});
}
if !is_full_git_oid(&plan.commit_oid) {
return Err(BaselineMismatch::MalformedCommitOid {
side: "plan",
value: plan.commit_oid.clone(),
});
}
if plan.r#ref != self.r#ref {
return Err(BaselineMismatch::Ref {
plan: plan.r#ref.clone(),
live: self.r#ref.clone(),
});
}
if plan.commit_oid != self.commit_oid {
return Err(BaselineMismatch::CommitOid {
plan: plan.commit_oid.clone(),
live: self.commit_oid.clone(),
});
}
if !toolchains_compatible(&plan.toolchain, &self.toolchain) {
return Err(BaselineMismatch::Toolchain {
plan: plan.toolchain.clone(),
live: self.toolchain.clone(),
});
}
let live = self.to_plan_baseline();
if plan.test_passlist_hash != live.test_passlist_hash {
return Err(BaselineMismatch::TestPasslistHash);
}
if plan.clippy_warnings_hash != live.clippy_warnings_hash {
return Err(BaselineMismatch::ClippyWarningsHash);
}
if plan.enumerated_targets_hash != live.enumerated_targets_hash {
return Err(BaselineMismatch::EnumeratedTargetsHash);
}
Ok(())
}
}
#[must_use]
pub fn hash_sorted(items: &BTreeSet<String>) -> String {
let mut h = Sha256::new();
for item in items {
h.update((item.len() as u64).to_be_bytes());
h.update(item.as_bytes());
}
format!("sha256:{:x}", h.finalize())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckRun {
pub desc: String,
pub run: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
pub passed: bool,
pub exit_code: Option<i32>,
pub stdout: String,
pub stderr: String,
}
#[cfg(test)]
mod tests {
use super::*;
const OID: &str = "0123456789abcdef0123456789abcdef01234567";
const OID2: &str = "fedcba9876543210fedcba9876543210fedcba98";
fn set(items: &[&str]) -> BTreeSet<String> {
items.iter().map(ToString::to_string).collect()
}
fn tid(target_kind: &str, target: &str, name: &str) -> TestId {
TestId::new("pkg", target_kind, target, name)
}
fn tset(ids: &[TestId]) -> BTreeSet<TestId> {
ids.iter().cloned().collect()
}
#[test]
fn total_counts_distinct_ids_across_partitions() {
let ts = TestSnapshot {
passed: tset(&[tid("lib", "pkg", "a"), tid("lib", "pkg", "b")]),
failed: tset(&[tid("lib", "pkg", "c")]),
ignored: tset(&[tid("lib", "pkg", "d")]),
..Default::default()
};
assert_eq!(ts.total(), 4);
assert_eq!(ts.all_ids().len(), 4);
}
#[test]
fn same_name_in_different_target_is_a_distinct_test() {
let unit = tid("lib", "octl-cli", "export::roundtrip");
let integ = tid("test", "e2e", "export::roundtrip");
assert_ne!(unit, integ);
let both = tset(&[unit.clone(), integ.clone()]);
assert_eq!(both.len(), 2);
assert_ne!(unit.canonical(), integ.canonical());
}
#[test]
fn test_id_canonical_is_stable_and_display_matches() {
let id = TestId::new("octl-cli", "lib", "octl-cli", "a::b");
assert_eq!(id.canonical(), "octl-cli/lib/octl-cli::a::b");
assert_eq!(id.to_string(), id.canonical());
}
#[test]
fn clippy_warning_identity_ignores_line_but_keeps_lint_and_message() {
let a = ClippyWarning {
lint: "clippy::needless_return".into(),
package: "pkg".into(),
file: "src/a.rs".into(),
message: "unneeded return".into(),
};
let b = a.clone();
assert_eq!(a.canonical(), b.canonical());
let mut c = a.clone();
c.lint = "clippy::redundant_clone".into();
assert_ne!(a.canonical(), c.canonical());
}
#[test]
fn hash_is_order_independent_and_content_sensitive() {
let a = set(&["x", "y", "z"]);
let b = set(&["z", "y", "x"]);
assert_eq!(hash_sorted(&a), hash_sorted(&b));
assert_ne!(
hash_sorted(&set(&["a", "bc"])),
hash_sorted(&set(&["ab", "c"]))
);
assert_ne!(hash_sorted(&set(&["a\nb"])), hash_sorted(&set(&["a", "b"])));
assert_ne!(hash_sorted(&a), hash_sorted(&set(&["x", "y"])));
assert!(hash_sorted(&a).starts_with("sha256:"));
}
#[test]
fn empty_set_hashes_stably() {
let e1 = BTreeSet::new();
let e2 = BTreeSet::new();
assert_eq!(hash_sorted(&e1), hash_sorted(&e2));
}
#[test]
fn to_plan_baseline_carries_ref_and_hashes() {
let base = BaselineSnapshot::new(
"feat/x@fork",
"deadbeef",
"rustc 1.97.1",
RunSnapshot {
tests: TestSnapshot {
passed: tset(&[tid("lib", "pkg", "t::a")]),
..Default::default()
},
clippy: ClippySnapshot {
warnings: [ClippyWarning {
lint: "unused_variables".into(),
package: "pkg".into(),
file: "src/a.rs".into(),
message: "unused variable: `x`".into(),
}]
.into_iter()
.collect(),
},
coverage: None,
},
);
let pb = base.to_plan_baseline();
assert_eq!(pb.r#ref, "feat/x@fork");
assert_eq!(
pb.test_passlist_hash,
hash_sorted(&set(&["pkg/lib/pkg::t::a"]))
);
assert!(pb.test_passlist_hash.starts_with("sha256:"));
assert!(pb.clippy_warnings_hash.starts_with("sha256:"));
}
#[test]
fn verify_plan_baseline_accepts_matching_and_rejects_drift() {
let base = BaselineSnapshot::new(
"feat/x@fork",
OID,
"rustc 1.97.1",
RunSnapshot {
tests: TestSnapshot {
passed: tset(&[tid("lib", "pkg", "t::a")]),
..Default::default()
},
..Default::default()
},
);
assert!(base.verify_plan_baseline(&base.to_plan_baseline()).is_ok());
let mut bad = base.to_plan_baseline();
bad.test_passlist_hash = "sha256:0".into();
assert_eq!(
base.verify_plan_baseline(&bad),
Err(BaselineMismatch::TestPasslistHash)
);
let mut bad_ref = base.to_plan_baseline();
bad_ref.r#ref = "feat/other@fork".into();
assert!(matches!(
base.verify_plan_baseline(&bad_ref),
Err(BaselineMismatch::Ref { .. })
));
let mut bad_oid = base.to_plan_baseline();
bad_oid.commit_oid = OID2.into();
assert!(matches!(
base.verify_plan_baseline(&bad_oid),
Err(BaselineMismatch::CommitOid { .. })
));
let mut bad_tc = base.to_plan_baseline();
bad_tc.toolchain = "rustc 1.0.0".into();
assert!(matches!(
base.verify_plan_baseline(&bad_tc),
Err(BaselineMismatch::Toolchain { .. })
));
let mut patch_bump = base.to_plan_baseline();
patch_bump.toolchain = "rustc 1.97.9 (abcdef0 2026-09-01)".into();
assert!(base.verify_plan_baseline(&patch_bump).is_ok());
}
#[test]
fn verify_plan_baseline_rejects_malformed_oid_and_unknown_toolchain() {
let short = BaselineSnapshot::new(
"feat/x@fork",
"deadbeef",
"rustc 1.97.1",
RunSnapshot::default(),
);
assert!(matches!(
short.verify_plan_baseline(&short.to_plan_baseline()),
Err(BaselineMismatch::MalformedCommitOid { side: "live", .. })
));
let unknown = BaselineSnapshot::new("feat/x@fork", OID, "unknown", RunSnapshot::default());
assert!(matches!(
unknown.verify_plan_baseline(&unknown.to_plan_baseline()),
Err(BaselineMismatch::UnknownToolchain { side: "live" })
));
let live =
BaselineSnapshot::new("feat/x@fork", OID, "rustc 1.97.1", RunSnapshot::default());
let mut plan = live.to_plan_baseline();
plan.commit_oid = "xyz".into();
assert!(matches!(
live.verify_plan_baseline(&plan),
Err(BaselineMismatch::MalformedCommitOid { side: "plan", .. })
));
}
#[test]
fn is_full_git_oid_accepts_sha1_and_sha256_only() {
assert!(is_full_git_oid(OID));
assert!(is_full_git_oid(&"a".repeat(64)));
assert!(!is_full_git_oid("deadbeef"));
assert!(!is_full_git_oid(&"a".repeat(41)));
assert!(!is_full_git_oid(&"g".repeat(40)));
}
#[test]
fn toolchains_compatible_tolerates_patch_but_not_minor() {
assert!(toolchains_compatible(
"rustc 1.97.1 (a 2026-01-01)",
"rustc 1.97.9 (b 2026-09-09)"
));
assert!(!toolchains_compatible("rustc 1.97.1", "rustc 1.98.0"));
assert!(toolchains_compatible("weird", "weird"));
assert!(!toolchains_compatible("weird", "different"));
}
#[test]
fn verify_plan_baseline_fails_closed_on_missing_provenance() {
let live = BaselineSnapshot::new(
"feat/x@fork",
"deadbeef",
"rustc 1.97.1",
RunSnapshot::default(),
);
let mut legacy = live.to_plan_baseline();
legacy.commit_oid = String::new();
legacy.toolchain = String::new();
assert_eq!(
live.verify_plan_baseline(&legacy),
Err(BaselineMismatch::MissingProvenance {
field: "commit_oid",
side: "plan"
})
);
let empty_live = BaselineSnapshot::new("feat/x@fork", "", "", RunSnapshot::default());
assert!(matches!(
empty_live.verify_plan_baseline(&empty_live.to_plan_baseline()),
Err(BaselineMismatch::MissingProvenance { .. })
));
}
#[test]
fn verify_plan_baseline_rejects_enumerated_targets_drift() {
let base = BaselineSnapshot::new(
"feat/x@fork",
OID,
"rustc 1.97.1",
RunSnapshot {
tests: TestSnapshot {
passed: tset(&[tid("lib", "pkg", "t::a")]),
targets: ["pkg/lib/pkg".to_string(), "pkg/test/e2e".to_string()]
.into_iter()
.collect(),
..Default::default()
},
..Default::default()
},
);
assert!(base.verify_plan_baseline(&base.to_plan_baseline()).is_ok());
let mut bad = base.to_plan_baseline();
assert!(!bad.enumerated_targets_hash.is_empty());
bad.enumerated_targets_hash = "sha256:0".into();
assert_eq!(
base.verify_plan_baseline(&bad),
Err(BaselineMismatch::EnumeratedTargetsHash)
);
}
#[test]
fn to_plan_baseline_carries_provenance_fields() {
let base = BaselineSnapshot::new(
"feat/x@fork",
"deadbeefoid",
"rustc 1.97.1 (abc 2026-06-01)",
RunSnapshot {
tests: TestSnapshot {
targets: ["pkg/lib/pkg".to_string()].into_iter().collect(),
..Default::default()
},
..Default::default()
},
);
let pb = base.to_plan_baseline();
assert_eq!(pb.commit_oid, "deadbeefoid");
assert_eq!(pb.toolchain, "rustc 1.97.1 (abc 2026-06-01)");
assert_eq!(
pb.enumerated_targets_hash,
hash_sorted(&["pkg/lib/pkg".to_string()].into_iter().collect())
);
assert!(pb.enumerated_targets_hash.starts_with("sha256:"));
}
#[test]
fn baseline_snapshot_round_trips_and_defaults_schema() {
let base = BaselineSnapshot::new(
"feat/x@fork",
"deadbeef",
"rustc 1.97.1",
RunSnapshot::default(),
);
assert_eq!(base.schema_version, BASELINE_SCHEMA_VERSION);
let json = serde_json::to_string(&base).unwrap();
let back: BaselineSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(base, back);
}
#[test]
fn coverage_fraction_handles_zero_total() {
assert!(
(Coverage {
covered_lines: 0,
total_lines: 0
})
.fraction()
.abs()
< f64::EPSILON
);
assert!(
((Coverage {
covered_lines: 3,
total_lines: 4
})
.fraction()
- 0.75)
.abs()
< 1e-9
);
}
#[test]
fn snapshots_round_trip_through_serde() {
let snap = RunSnapshot {
tests: TestSnapshot {
passed: tset(&[tid("lib", "pkg", "a")]),
failed: tset(&[tid("lib", "pkg", "b")]),
ignored: tset(&[tid("lib", "pkg", "c")]),
targets: ["pkg/lib/pkg".to_string()].into_iter().collect(),
},
clippy: ClippySnapshot {
warnings: [ClippyWarning {
lint: "unused_variables".into(),
package: "pkg".into(),
file: "src/a.rs".into(),
message: "w".into(),
}]
.into_iter()
.collect(),
},
coverage: Some(Coverage {
covered_lines: 1,
total_lines: 2,
}),
};
let json = serde_json::to_string(&snap).unwrap();
let back: RunSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(snap, back);
}
}