1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
//! Build identity + hub-pin comparison for update detection.
//!
//! Semantic-version parsing/ordering comes from the standard [`semver`] crate (the
//! one Cargo uses) — we don't hand-roll it. On top we add the confer-specific bits:
//! grading a version gap `major`/`minor`/`patch` (`Drift`), and comparing an agent's
//! BUILT id against the hub's pin.
//!
//! A build is `(semver, sha)`. The hub records the version it expects in
//! `.confer-version` as `"<semver> <sha>"` (legacy pins are a bare `"<sha>"`). The
//! verdict is a single grade a consumer can branch on: `current`/`ahead` (fine) ·
//! `rebuild` (same version, newer build) · `patch`/`minor`/`major` (behind by that
//! much) · `drift` (legacy sha-only mismatch).
use semver::{Version, VersionReq};
/// Does a build's semver satisfy a hub REQUIREMENT floor/range (a `VersionReq` like
/// `>=0.1.0`)? The "fuzzy repo-level" version the whole hub declares; each agent reports
/// its exact build, and this is the per-agent compatibility check (a build with no
/// parseable semver never satisfies — treated as incompatible/unknown).
pub fn satisfies(build: &BuildId, req: &VersionReq) -> bool {
build.version.as_ref().is_some_and(|v| req.matches(v))
}
/// The lowest semver among a set of builds (for the "safe to raise the floor to X once
/// everyone is at least X" auto-bump). `None` if no build has a parseable semver.
pub fn min_version(builds: &[BuildId]) -> Option<Version> {
builds.iter().filter_map(|b| b.version.clone()).min()
}
/// How far a BUILT version is from the hub PIN — graded so a consumer can branch:
/// `Major`/`Minor`/`Patch` = behind (act-now → later → low-noise); `Ahead` = newer
/// than the pin; `Current` = exactly the pin.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Drift {
Current,
Ahead,
Patch,
Minor,
Major,
}
impl Drift {
/// Grade a built version against the pin. When behind, the grade is the *highest*
/// differing core component (a major gap dominates a coincident minor/patch gap).
/// Behind by only a pre-release (same core, built is `-rc`) grades `Patch`.
pub fn grade(built: &Version, pin: &Version) -> Drift {
use std::cmp::Ordering::*;
match built.cmp(pin) {
Equal => Drift::Current,
Greater => Drift::Ahead,
Less => {
if built.major != pin.major {
Drift::Major
} else if built.minor != pin.minor {
Drift::Minor
} else {
Drift::Patch
}
}
}
}
pub fn label(self) -> &'static str {
match self {
Drift::Current => "current",
Drift::Ahead => "ahead",
Drift::Patch => "patch",
Drift::Minor => "minor",
Drift::Major => "major",
}
}
}
/// A confer build: an optional semver (unparseable/legacy → `None`) + a short git sha.
#[derive(Clone)]
pub struct BuildId {
pub version: Option<Version>,
pub sha: String,
}
impl BuildId {
/// Parse a pin/build token: `"<semver> <sha>"` | `"<semver>"` | `"<sha>"`.
pub fn parse(s: &str) -> BuildId {
let s = s.trim();
let mut parts = s.split_whitespace();
let first = parts.next().unwrap_or("");
match Version::parse(first) {
Ok(v) => BuildId { version: Some(v), sha: parts.next().unwrap_or("").to_string() },
Err(_) => BuildId { version: None, sha: first.to_string() }, // legacy sha-only pin
}
}
/// Core `major.minor.patch[-pre]` without build metadata (what we pin/compare on).
fn core(v: &Version) -> String {
if v.pre.is_empty() {
format!("{}.{}.{}", v.major, v.minor, v.patch)
} else {
format!("{}.{}.{}-{}", v.major, v.minor, v.patch, v.pre)
}
}
/// The canonical `"<semver> <sha>"` form written into a pin.
pub fn pin_string(&self) -> String {
match &self.version {
Some(v) if !self.sha.is_empty() => format!("{} {}", Self::core(v), self.sha),
Some(v) => Self::core(v),
None => self.sha.clone(),
}
}
/// Human label, e.g. `0.2.0 (67a1148)`.
pub fn label(&self) -> String {
match (&self.version, self.sha.is_empty()) {
(Some(v), false) => format!("{} ({})", Self::core(v), self.sha),
(Some(v), true) => Self::core(v),
(None, false) => self.sha.clone(),
(None, true) => "unknown".to_string(),
}
}
}
/// The graded relationship of a built id to the hub pin.
pub struct Assessment {
/// `no-pin` | `current` | `ahead` | `rebuild` | `patch` | `minor` | `major` | `drift`.
pub grade: &'static str,
/// Behind the pin (an update is available) → callers exit non-zero.
pub outdated: bool,
}
/// Grade a built id against the hub pin (if any). Prefers semver grading; falls back
/// to a bare sha compare for legacy pins. Same semver + different sha = a `rebuild`
/// (a newer build to adopt even without a version bump — the common case today).
pub fn assess(built: &BuildId, pin: Option<&BuildId>) -> Assessment {
let Some(pin) = pin else {
return Assessment { grade: "no-pin", outdated: false };
};
match (&built.version, &pin.version) {
(Some(b), Some(p)) => {
use std::cmp::Ordering::*;
match b.cmp(p) {
Greater => Assessment { grade: "ahead", outdated: false },
Less => Assessment { grade: Drift::grade(b, p).label(), outdated: true },
Equal => {
if built.sha == pin.sha {
Assessment { grade: "current", outdated: false }
} else {
Assessment { grade: "rebuild", outdated: true }
}
}
}
}
// No comparable semver on at least one side → fall back to sha identity.
_ => {
if pin.sha.is_empty() {
Assessment { grade: "no-pin", outdated: false }
} else if built.sha == pin.sha {
Assessment { grade: "current", outdated: false }
} else {
Assessment { grade: "drift", outdated: true }
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ver(s: &str) -> Version {
Version::parse(s).unwrap()
}
fn b(s: &str) -> BuildId {
BuildId::parse(s)
}
#[test]
fn drift_grades_by_highest_differing_component() {
use Drift::*;
assert_eq!(Drift::grade(&ver("1.2.3"), &ver("1.2.3")), Current);
assert_eq!(Drift::grade(&ver("1.3.0"), &ver("1.2.9")), Ahead);
assert_eq!(Drift::grade(&ver("1.2.3"), &ver("1.2.4")), Patch);
assert_eq!(Drift::grade(&ver("1.2.9"), &ver("1.3.0")), Minor);
assert_eq!(Drift::grade(&ver("1.9.9"), &ver("2.0.0")), Major);
assert_eq!(Drift::grade(&ver("1.2.3"), &ver("2.3.4")), Major); // major dominates
assert_eq!(Drift::grade(&ver("1.0.0-rc.1"), &ver("1.0.0")), Patch); // pre-release behind
}
#[test]
fn parses_pin_forms() {
let x = b("0.2.0 67a1148");
assert_eq!(x.version.as_ref().unwrap().to_string(), "0.2.0");
assert_eq!(x.sha, "67a1148");
assert!(b("abc1234").version.is_none()); // legacy sha-only (not a semver)
assert_eq!(b("abc1234").sha, "abc1234");
assert_eq!(b("1.0.0").sha, "");
assert_eq!(b("0.2.0 abc").pin_string(), "0.2.0 abc");
}
#[test]
fn assess_grades_every_case() {
let g = |built: &str, pin: Option<&str>| {
let pinb = pin.map(b);
assess(&b(built), pinb.as_ref())
};
assert_eq!(g("0.1.0 aaa", None).grade, "no-pin");
let cur = g("0.2.0 aaa", Some("0.2.0 aaa"));
assert_eq!(cur.grade, "current");
assert!(!cur.outdated);
assert_eq!(g("0.3.0 zzz", Some("0.2.0 aaa")).grade, "ahead");
let r = g("0.2.0 bbb", Some("0.2.0 aaa"));
assert_eq!(r.grade, "rebuild");
assert!(r.outdated);
assert_eq!(g("0.2.0 x", Some("0.2.4 y")).grade, "patch");
assert_eq!(g("0.2.0 x", Some("0.5.0 y")).grade, "minor");
assert_eq!(g("0.2.0 x", Some("1.0.0 y")).grade, "major");
assert!(g("0.2.0 x", Some("1.0.0 y")).outdated);
// legacy sha-only pins
assert_eq!(g("0.1.0 aaa", Some("aaa")).grade, "current");
assert_eq!(g("0.1.0 aaa", Some("bbb")).grade, "drift");
assert!(g("0.1.0 aaa", Some("bbb")).outdated);
}
#[test]
fn floor_satisfaction_and_min() {
let req = VersionReq::parse(">=0.2.0").unwrap();
assert!(satisfies(&b("0.2.0 aaa"), &req));
assert!(satisfies(&b("0.3.1 aaa"), &req));
assert!(!satisfies(&b("0.1.9 aaa"), &req));
assert!(!satisfies(&b("abc1234"), &req)); // no semver → not compatible
let builds = [b("0.2.0 a"), b("0.3.0 b"), b("0.1.5 c")];
assert_eq!(min_version(&builds).unwrap().to_string(), "0.1.5");
}
}