use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
struct PendingMarker {
file: PathBuf,
line: usize,
until: SemVer,
ticket: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct SemVer {
major: u32,
minor: u32,
patch: u32,
}
impl SemVer {
fn parse(s: &str) -> Option<Self> {
let s = s.strip_prefix('v').unwrap_or(s);
let parts: Vec<&str> = s.split('.').collect();
if parts.len() != 3 {
return None;
}
Some(SemVer {
major: parts[0].parse().ok()?,
minor: parts[1].parse().ok()?,
patch: parts[2].parse().ok()?,
})
}
}
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("workspace root")
.to_path_buf()
}
fn workspace_version() -> SemVer {
let cargo_toml =
fs::read_to_string(workspace_root().join("Cargo.toml")).expect("read workspace Cargo.toml");
for line in cargo_toml.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("version = ") {
let v = rest.trim().trim_matches('"');
if let Some(parsed) = SemVer::parse(v) {
return parsed;
}
}
}
panic!("could not parse workspace.package.version from root Cargo.toml");
}
fn parse_pending_markers(root: &Path) -> Vec<PendingMarker> {
let mut out = Vec::new();
let crates_dir = root.join("crates");
if let Ok(entries) = fs::read_dir(&crates_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let src_dir = path.join("src");
if src_dir.is_dir() {
walk_dir(&src_dir, &[".rs"], &mut out);
}
}
}
let lean_dir = root.join("contracts").join("lean");
if lean_dir.is_dir() {
walk_dir(&lean_dir, &[".lean"], &mut out);
}
let kani_dir = root.join("contracts").join("kani");
if kani_dir.is_dir() {
walk_dir(&kani_dir, &[".rs"], &mut out);
}
out
}
fn walk_dir(dir: &Path, exts: &[&str], out: &mut Vec<PendingMarker>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk_dir(&path, exts, out);
} else if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
let dotted = format!(".{ext}");
if exts.contains(&dotted.as_str()) {
scan_file(&path, out);
}
}
}
}
fn scan_file(path: &Path, out: &mut Vec<PendingMarker>) {
let Ok(contents) = fs::read_to_string(path) else {
return;
};
for (lineno, line) in contents.lines().enumerate() {
if let Some(marker) = extract_marker_from_line(path, lineno + 1, line) {
out.push(marker);
}
}
}
fn extract_marker_from_line(file: &Path, line_no: usize, line: &str) -> Option<PendingMarker> {
let needle = "XPILE-PENDING-UNTIL:";
let idx = line.find(needle)?;
let rest = &line[idx + needle.len()..];
let rest = rest.trim_start();
let until_end = rest.find(',').or_else(|| rest.find(']'))?;
let until_str = rest[..until_end].trim();
let until = SemVer::parse(until_str)?;
let ticket_marker = "ticket:";
let ticket_str = if let Some(t_idx) = rest.find(ticket_marker) {
let after = &rest[t_idx + ticket_marker.len()..];
let end = after.find(']').unwrap_or(after.len());
after[..end].trim().to_string()
} else {
String::from("(no ticket)")
};
Some(PendingMarker {
file: file.to_path_buf(),
line: line_no,
until,
ticket: ticket_str,
})
}
#[test]
fn no_xpile_pending_until_has_expired() {
let root = workspace_root();
let current = workspace_version();
let markers = parse_pending_markers(&root);
let expired: Vec<_> = markers.iter().filter(|m| m.until <= current).collect();
if !expired.is_empty() {
let mut msg = String::from(
"XPILE-PENDING-UNTIL deadline(s) reached without the underlying feature shipping.\n\
Either implement the feature and remove the marker, OR bump `until:` to a later\n\
version with a public reason in the commit message.\n\n",
);
for m in &expired {
msg.push_str(&format!(
" - {}:{} until=v{}.{}.{} ticket={}\n",
m.file.display(),
m.line,
m.until.major,
m.until.minor,
m.until.patch,
m.ticket,
));
}
msg.push_str(&format!(
"\nWorkspace version is v{}.{}.{}; deadlines must be strictly greater.",
current.major, current.minor, current.patch
));
panic!("{msg}");
}
}
#[test]
fn scanner_reaches_all_watched_directories() {
let tmp = std::env::temp_dir().join(format!(
"xpile-deadline-scan-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&tmp).expect("create temp root");
let mk = |rel: &str, body: &str| {
let p = tmp.join(rel);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(&p, body).unwrap();
};
mk(
"crates/foo/src/lib.rs",
"// [XPILE-PENDING-UNTIL: v9.9.9, ticket: R1]\nfn main() {}\n",
);
mk(
"crates/bar/src/inner/x.rs",
".expect(\"err [XPILE-PENDING-UNTIL: v9.9.9, ticket: R2]\")\n",
);
mk(
"contracts/lean/Sample.lean",
"-- XPILE-PENDING-UNTIL: v9.9.9, ticket: L1\n",
);
mk(
"contracts/kani/sample.rs",
"// [XPILE-PENDING-UNTIL: v9.9.9, ticket: K1]\n",
);
let markers = parse_pending_markers(&tmp);
let tickets: std::collections::BTreeSet<_> =
markers.iter().map(|m| m.ticket.as_str()).collect();
let _ = fs::remove_dir_all(&tmp);
let want = ["R1", "R2", "L1", "K1"];
for t in want {
assert!(
tickets.contains(t),
"scanner missed marker `{t}`. Found: {tickets:?}. \
If the scanner stopped walking one of the four directories \
(crates/*/src/, contracts/lean/, contracts/kani/), this test \
pinpoints which one."
);
}
}
#[test]
fn parser_handles_canonical_and_missing_ticket() {
let canonical = ".expect(\"foo [XPILE-PENDING-UNTIL: v0.2.0, ticket: PMAT-013-FOLLOWUP]\")";
let m = extract_marker_from_line(Path::new("test.rs"), 42, canonical).unwrap();
assert_eq!(
m.until,
SemVer {
major: 0,
minor: 2,
patch: 0
}
);
assert_eq!(m.ticket, "PMAT-013-FOLLOWUP");
assert_eq!(m.line, 42);
let without_ticket = "// [XPILE-PENDING-UNTIL: v1.0.0]";
let m2 = extract_marker_from_line(Path::new("x.rs"), 1, without_ticket).unwrap();
assert_eq!(
m2.until,
SemVer {
major: 1,
minor: 0,
patch: 0
}
);
assert_eq!(m2.ticket, "(no ticket)");
let no_marker = "let x = 1; // unrelated comment";
assert!(extract_marker_from_line(Path::new("x.rs"), 1, no_marker).is_none());
let malformed_version = "// [XPILE-PENDING-UNTIL: vbad.semver]";
assert!(extract_marker_from_line(Path::new("x.rs"), 1, malformed_version).is_none());
}
#[test]
fn gate_fires_when_current_meets_or_exceeds_until() {
let markers = [
PendingMarker {
file: PathBuf::from("synthetic.rs"),
line: 10,
until: SemVer {
major: 0,
minor: 2,
patch: 0,
},
ticket: "FAKE-001".into(),
},
PendingMarker {
file: PathBuf::from("other.rs"),
line: 20,
until: SemVer {
major: 1,
minor: 0,
patch: 0,
},
ticket: "FAKE-002".into(),
},
];
let v_0_1_0 = SemVer {
major: 0,
minor: 1,
patch: 0,
};
let v_0_2_0 = SemVer {
major: 0,
minor: 2,
patch: 0,
};
let v_1_0_0 = SemVer {
major: 1,
minor: 0,
patch: 0,
};
let expired_at_0_1_0: Vec<_> = markers.iter().filter(|m| m.until <= v_0_1_0).collect();
assert_eq!(
expired_at_0_1_0.len(),
0,
"v0.1.0 < both deadlines — no expiry"
);
let expired_at_0_2_0: Vec<_> = markers.iter().filter(|m| m.until <= v_0_2_0).collect();
assert_eq!(
expired_at_0_2_0.len(),
1,
"v0.2.0 reaches the v0.2.0 deadline; v1.0.0 still safe"
);
assert_eq!(expired_at_0_2_0[0].ticket, "FAKE-001");
let expired_at_1_0_0: Vec<_> = markers.iter().filter(|m| m.until <= v_1_0_0).collect();
assert_eq!(
expired_at_1_0_0.len(),
2,
"v1.0.0 reaches both deadlines (deadlines are inclusive on equality)"
);
}
#[test]
fn semver_orders_correctly() {
let v_0_1_0 = SemVer {
major: 0,
minor: 1,
patch: 0,
};
let v_0_2_0 = SemVer {
major: 0,
minor: 2,
patch: 0,
};
let v_0_1_5 = SemVer {
major: 0,
minor: 1,
patch: 5,
};
let v_1_0_0 = SemVer {
major: 1,
minor: 0,
patch: 0,
};
assert!(v_0_1_0 < v_0_1_5);
assert!(v_0_1_5 < v_0_2_0);
assert!(v_0_2_0 < v_1_0_0);
assert!(v_0_1_0 < v_1_0_0);
assert_eq!(v_0_1_0, SemVer::parse("v0.1.0").unwrap());
assert_eq!(v_0_1_0, SemVer::parse("0.1.0").unwrap());
}