1use std::path::{Path, PathBuf};
19
20use crate::probe::{Toolbox, Version};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Origin {
25 Override,
27 Path,
29 Sibling,
31}
32
33impl Origin {
34 pub fn token(self) -> &'static str {
35 match self {
36 Origin::Override => "override",
37 Origin::Path => "path",
38 Origin::Sibling => "sibling",
39 }
40 }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Skew {
48 Match,
50 Minor,
52 Major,
54}
55
56impl Skew {
57 pub fn classify(driver: Version, compiler: Version) -> Skew {
59 if driver.major != compiler.major {
60 Skew::Major
61 } else if driver.minor != compiler.minor {
62 Skew::Minor
63 } else {
64 Skew::Match
65 }
66 }
67
68 pub fn token(self) -> &'static str {
69 match self {
70 Skew::Match => "match",
71 Skew::Minor => "minor",
72 Skew::Major => "major",
73 }
74 }
75}
76
77#[derive(Debug, Clone)]
79pub struct Compiler {
80 pub path: Option<PathBuf>,
83 pub origin: Option<Origin>,
84 pub version: Option<Version>,
85 pub skew: Option<Skew>,
87}
88
89impl Compiler {
90 pub fn is_resolved(&self) -> bool {
91 self.path.is_some()
92 }
93
94 pub fn has_major_skew(&self) -> bool {
96 self.skew == Some(Skew::Major)
97 }
98}
99
100pub fn resolve(
104 tb: &dyn Toolbox,
105 override_path: Option<&Path>,
106 bynk_bin_dir: Option<&Path>,
107 driver: Version,
108) -> Compiler {
109 let (path, origin) = locate(tb, override_path, bynk_bin_dir);
110 let version = path.as_deref().and_then(|p| tb.version(p));
111 let skew = version.map(|v| Skew::classify(driver, v));
112 Compiler {
113 path,
114 origin,
115 version,
116 skew,
117 }
118}
119
120fn locate(
121 tb: &dyn Toolbox,
122 override_path: Option<&Path>,
123 bynk_bin_dir: Option<&Path>,
124) -> (Option<PathBuf>, Option<Origin>) {
125 if let Some(ovr) = override_path.filter(|p| !p.as_os_str().is_empty()) {
128 let dir = ovr
135 .parent()
136 .filter(|d| !d.as_os_str().is_empty())
137 .unwrap_or(Path::new("."));
138 let name = ovr.file_name().and_then(|n| n.to_str()).unwrap_or_default();
139 if let Some(p) = tb.in_dir(dir, name) {
140 return (Some(p), Some(Origin::Override));
141 }
142 return (None, Some(Origin::Override));
147 }
148 if let Some(p) = tb.on_path("bynkc") {
149 return (Some(p), Some(Origin::Path));
150 }
151 if let Some(dir) = bynk_bin_dir
152 && let Some(p) = tb.in_dir(dir, "bynkc")
153 {
154 return (Some(p), Some(Origin::Sibling));
155 }
156 (None, None)
157}
158
159pub const ALLOW_SKEW_ENV: &str = "BYNK_ALLOW_SKEW";
164
165pub fn skew_allowed_by_env() -> bool {
167 std::env::var(ALLOW_SKEW_ENV).is_ok_and(|v| !v.is_empty())
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
178pub enum SkewVerdict {
179 Run,
181 Warn(String),
183 Refuse(String),
185}
186
187#[derive(Debug, Clone, Copy)]
190pub struct SkewAsker<'a> {
191 pub command: Option<&'a str>,
194 pub has_flag: bool,
197}
198
199pub fn skew_verdict(compiler: &Compiler, asker: SkewAsker, allow: bool) -> SkewVerdict {
202 let (Some(skew), Some(ver), Some(path)) = (compiler.skew, compiler.version, &compiler.path)
203 else {
204 return SkewVerdict::Run;
205 };
206 let driver = crate::DRIVER_VERSION;
207 let prefix = match asker.command {
208 Some(c) => format!("bynk {c}:"),
209 None => "bynk:".to_string(),
210 };
211 let which = format!("`bynkc` {ver} ({})", path.display());
212 let remedy = "install a `bynkc` matching `bynk`, point BYNK_BYNKC at one, or run `bynk doctor`";
213 let override_how = if asker.has_flag {
214 format!("pass --allow-skew or set {ALLOW_SKEW_ENV}=1")
215 } else {
216 format!("set {ALLOW_SKEW_ENV}=1")
217 };
218 match skew {
219 Skew::Match => SkewVerdict::Run,
220 Skew::Minor => SkewVerdict::Warn(format!(
221 "{prefix} warning: {which} is a different minor version from bynk {driver} — it may \
222 check this code differently. To align: {remedy}."
223 )),
224 Skew::Major if allow => SkewVerdict::Warn(format!(
225 "{prefix} warning: {which} is a different major version from bynk {driver}; running it \
226 anyway (skew allowed)."
227 )),
228 Skew::Major => SkewVerdict::Refuse(format!(
229 "{prefix} {which} is a different major version from bynk {driver}, so the two do not \
230 share a contract. Refusing to run it. To align: {remedy}. To run it anyway, \
231 {override_how}."
232 )),
233 }
234}
235
236pub fn apply_skew_verdict(verdict: SkewVerdict) -> bool {
240 use std::collections::HashSet;
241 use std::sync::{Mutex, OnceLock};
242 static WARNED: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
243 match verdict {
244 SkewVerdict::Run => true,
245 SkewVerdict::Warn(msg) => {
246 let first = WARNED
247 .get_or_init(|| Mutex::new(HashSet::new()))
248 .lock()
249 .map(|mut seen| seen.insert(msg.clone()))
250 .unwrap_or(true);
251 if first {
252 eprintln!("{msg}");
253 }
254 true
255 }
256 SkewVerdict::Refuse(msg) => {
257 eprintln!("{msg}");
258 false
259 }
260 }
261}
262
263pub fn skew_gate(compiler: &Compiler, asker: SkewAsker, flag: bool) -> bool {
267 apply_skew_verdict(skew_verdict(compiler, asker, flag || skew_allowed_by_env()))
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 struct FakeToolbox {
277 files: Vec<(PathBuf, String)>,
278 on_path: Vec<String>,
279 }
280
281 impl Toolbox for FakeToolbox {
282 fn on_path(&self, tool: &str) -> Option<PathBuf> {
283 self.on_path
284 .iter()
285 .any(|t| t == tool)
286 .then(|| PathBuf::from("/usr/bin").join(tool))
287 }
288 fn in_dir(&self, dir: &Path, tool: &str) -> Option<PathBuf> {
289 self.files
290 .iter()
291 .any(|(d, n)| d == dir && n == tool)
292 .then(|| dir.join(tool))
293 }
294 fn version(&self, _path: &Path) -> Option<Version> {
295 None
296 }
297 fn npx_available(&self) -> bool {
298 false
299 }
300 }
301
302 #[test]
303 fn missing_override_resolves_to_none() {
304 let tb = FakeToolbox {
307 files: vec![],
308 on_path: vec!["bynkc".into()],
309 };
310 let (path, origin) = locate(&tb, Some(Path::new("/opt/missing/bynkc")), None);
311 assert_eq!(path, None);
312 assert_eq!(origin, Some(Origin::Override));
313 }
314
315 #[test]
316 fn empty_override_is_unset() {
317 let tb = FakeToolbox {
319 files: vec![(PathBuf::from("."), "bynkc".into())],
320 on_path: vec!["bynkc".into()],
321 };
322 let (path, origin) = locate(&tb, Some(Path::new("")), None);
323 assert_eq!(origin, Some(Origin::Path));
324 assert_eq!(path, Some(PathBuf::from("/usr/bin/bynkc")));
325 }
326
327 #[test]
328 fn override_never_resolves_by_stem() {
329 let tb = FakeToolbox {
332 files: vec![(PathBuf::from("/dir"), "bynkc".into())],
333 on_path: vec![],
334 };
335 let (path, origin) = locate(&tb, Some(Path::new("/dir/bynkc.backup")), None);
336 assert_eq!(path, None, "the named backup binary does not exist");
337 assert_eq!(origin, Some(Origin::Override));
338
339 let tb = FakeToolbox {
341 files: vec![(PathBuf::from("/dir"), "bynkc.backup".into())],
342 on_path: vec![],
343 };
344 let (path, _) = locate(&tb, Some(Path::new("/dir/bynkc.backup")), None);
345 assert_eq!(path, Some(PathBuf::from("/dir/bynkc.backup")));
346 }
347
348 #[test]
349 fn skew_classification() {
350 let v = |a, b, c| Version {
351 major: a,
352 minor: b,
353 patch: c,
354 };
355 assert_eq!(Skew::classify(v(0, 46, 0), v(0, 46, 0)), Skew::Match);
356 assert_eq!(Skew::classify(v(0, 46, 0), v(0, 46, 3)), Skew::Match);
358 assert_eq!(Skew::classify(v(0, 46, 0), v(0, 44, 0)), Skew::Minor);
359 assert_eq!(Skew::classify(v(1, 0, 0), v(0, 46, 0)), Skew::Major);
360 }
361
362 const TEST: SkewAsker<'static> = SkewAsker {
363 command: Some("test"),
364 has_flag: true,
365 };
366
367 fn skewed(skew: Skew) -> Compiler {
368 Compiler {
369 path: Some(PathBuf::from("/usr/bin/bynkc")),
370 origin: Some(Origin::Path),
371 version: Some(Version {
372 major: 0,
373 minor: 1,
374 patch: 0,
375 }),
376 skew: Some(skew),
377 }
378 }
379
380 #[test]
382 fn minor_skew_warns_and_runs() {
383 assert_eq!(
384 skew_verdict(&skewed(Skew::Match), TEST, false),
385 SkewVerdict::Run
386 );
387 let SkewVerdict::Warn(msg) = skew_verdict(&skewed(Skew::Minor), TEST, false) else {
388 panic!("minor skew must warn");
389 };
390 assert!(msg.contains("different minor version"), "{msg}");
391 assert!(msg.contains("/usr/bin/bynkc"), "names the binary: {msg}");
392 }
393
394 #[test]
396 fn major_skew_refuses_unless_allowed() {
397 let SkewVerdict::Refuse(msg) = skew_verdict(&skewed(Skew::Major), TEST, false) else {
398 panic!("major skew must refuse");
399 };
400 assert!(
401 msg.contains("--allow-skew") && msg.contains(ALLOW_SKEW_ENV),
402 "{msg}"
403 );
404 let SkewVerdict::Warn(msg) = skew_verdict(&skewed(Skew::Major), TEST, true) else {
405 panic!("allowed major skew must warn, not refuse");
406 };
407 assert!(msg.contains("running it anyway"), "{msg}");
408 }
409
410 #[test]
413 fn unknown_skew_runs() {
414 let mut c = skewed(Skew::Major);
415 c.skew = None;
416 assert_eq!(skew_verdict(&c, TEST, false), SkewVerdict::Run);
417 }
418
419 #[test]
421 fn allowed_minor_skew_still_warns() {
422 let SkewVerdict::Warn(msg) = skew_verdict(&skewed(Skew::Minor), TEST, true) else {
423 panic!("minor skew warns whether or not skew is allowed");
424 };
425 assert!(msg.contains("different minor version"), "{msg}");
426 }
427
428 #[test]
431 fn refusal_advice_matches_the_command() {
432 for asker in [
433 SkewAsker {
434 command: Some("check"),
435 has_flag: false,
436 },
437 SkewAsker {
438 command: None,
439 has_flag: false,
440 },
441 ] {
442 let SkewVerdict::Refuse(msg) = skew_verdict(&skewed(Skew::Major), asker, false) else {
443 panic!("major skew refuses");
444 };
445 assert!(!msg.contains("--allow-skew"), "{msg}");
446 assert!(msg.contains(ALLOW_SKEW_ENV), "{msg}");
447 }
448 let SkewVerdict::Refuse(msg) = skew_verdict(
449 &skewed(Skew::Major),
450 SkewAsker {
451 command: None,
452 has_flag: false,
453 },
454 false,
455 ) else {
456 panic!("major skew refuses");
457 };
458 assert!(msg.starts_with("bynk: "), "{msg}");
459 }
460}