Skip to main content

bnto_core/
version.rs

1//! Version constraint checking for external dependencies.
2//!
3//! Parses `<binary> --version` output, extracts the version number, and
4//! validates it against the constraint string on `Dependency.version`
5//! (e.g., `">=6.0"`). Used by the pre-flight dependency check before
6//! pipeline execution and by `bnto doctor`.
7
8use crate::errors::BntoError;
9
10// --- Types ---
11
12/// A parsed version constraint (e.g., `>=6.0` → `Gte`, `[6, 0]`).
13#[derive(Debug, Clone, PartialEq)]
14pub struct VersionConstraint {
15    pub op: ConstraintOp,
16    pub segments: Vec<u64>,
17}
18
19/// Comparison operator for a version constraint.
20#[derive(Debug, Clone, Copy, PartialEq)]
21pub enum ConstraintOp {
22    /// `>=` — installed version must be greater than or equal to the constraint.
23    Gte,
24}
25
26// --- Parsing ---
27
28/// Extract a version-like string from `--version` output.
29///
30/// Scans the output for the first sequence of dot-separated numbers
31/// (e.g., `"6.1.1"` from `"ffmpeg version 6.1.1 Copyright..."`).
32/// Returns `None` if no version pattern is found.
33pub fn extract_version(output: &str) -> Option<&str> {
34    // Find the first digit-dot-digit pattern.
35    let bytes = output.as_bytes();
36    let mut start = None;
37    let mut end = 0;
38
39    for (i, &b) in bytes.iter().enumerate() {
40        match (start, b) {
41            // No match yet — look for first digit.
42            (None, b'0'..=b'9') => {
43                start = Some(i);
44                end = i + 1;
45            }
46            // Inside a version string — extend on digits or dots.
47            (Some(_), b'0'..=b'9' | b'.') => {
48                end = i + 1;
49            }
50            // Inside a version string — non-digit/dot ends it.
51            (Some(_), _) => break,
52            // Not started, not a digit — keep scanning.
53            _ => {}
54        }
55    }
56
57    let s = start?;
58    let candidate = &output[s..end];
59
60    // Must contain at least one dot (e.g., "6.0", not just "6").
61    if !candidate.contains('.') {
62        return None;
63    }
64
65    // Trim trailing dots (e.g., "6.1." → "6.1").
66    Some(candidate.trim_end_matches('.'))
67}
68
69/// Parse a constraint string like `">=6.0"` into a `VersionConstraint`.
70pub fn parse_constraint(constraint: &str) -> Result<VersionConstraint, BntoError> {
71    let trimmed = constraint.trim();
72
73    if trimmed.is_empty() {
74        return Err(BntoError::InvalidInput(
75            "Empty version constraint".to_string(),
76        ));
77    }
78
79    // Extract operator and version string.
80    let (op, version_str) = if let Some(rest) = trimmed.strip_prefix(">=") {
81        (ConstraintOp::Gte, rest.trim())
82    } else {
83        return Err(BntoError::InvalidInput(format!(
84            "Unsupported version constraint operator in '{trimmed}'. Only >= is supported."
85        )));
86    };
87
88    let segments = parse_segments(version_str)?;
89
90    Ok(VersionConstraint { op, segments })
91}
92
93/// Parse a dotted version string into numeric segments.
94fn parse_segments(version: &str) -> Result<Vec<u64>, BntoError> {
95    if version.is_empty() {
96        return Err(BntoError::InvalidInput("Empty version string".to_string()));
97    }
98
99    version
100        .split('.')
101        .map(|part| {
102            part.parse::<u64>().map_err(|_| {
103                BntoError::InvalidInput(format!("Invalid version segment '{part}' in '{version}'"))
104            })
105        })
106        .collect()
107}
108
109// --- Comparison ---
110
111/// Check if an installed version satisfies a constraint.
112///
113/// Compares segment-by-segment. Missing trailing segments are treated as 0
114/// (e.g., `"6.0"` == `"6.0.0"`). This handles both semver (`6.1.1`) and
115/// calendar versioning (`2024.12.23`) since both are dot-separated numerics.
116pub fn satisfies(installed: &str, constraint: &VersionConstraint) -> bool {
117    let installed_segments = match parse_segments(installed) {
118        Ok(s) => s,
119        Err(_) => return false,
120    };
121
122    match constraint.op {
123        ConstraintOp::Gte => compare_segments(&installed_segments, &constraint.segments) >= 0,
124    }
125}
126
127/// Compare two version segment lists.
128/// Returns negative if a < b, 0 if equal, positive if a > b.
129fn compare_segments(a: &[u64], b: &[u64]) -> i64 {
130    let max_len = a.len().max(b.len());
131    for i in 0..max_len {
132        let av = a.get(i).copied().unwrap_or(0);
133        let bv = b.get(i).copied().unwrap_or(0);
134        if av != bv {
135            return av as i64 - bv as i64;
136        }
137    }
138    0
139}
140
141/// Run `<binary> --version` and check if the installed version satisfies
142/// the constraint. Returns the installed version string on success, or
143/// `None` if version couldn't be determined (binary missing, no parseable
144/// output, etc.).
145pub fn check_version(
146    binary: &str,
147    constraint_str: &str,
148    ctx: &dyn crate::ProcessContext,
149) -> VersionCheckResult {
150    // Parse the constraint first — if it's invalid, skip the check.
151    let constraint = match parse_constraint(constraint_str) {
152        Ok(c) => c,
153        Err(_) => return VersionCheckResult::Skipped,
154    };
155
156    // Run `<binary> --version` and capture stdout.
157    let output = match ctx.run_command(binary, &["--version"]) {
158        Ok(bytes) => String::from_utf8_lossy(&bytes).to_string(),
159        Err(_) => return VersionCheckResult::Skipped,
160    };
161
162    // Extract version from output.
163    let installed = match extract_version(&output) {
164        Some(v) => v.to_string(),
165        None => return VersionCheckResult::Skipped,
166    };
167
168    let satisfied = satisfies(&installed, &constraint);
169
170    VersionCheckResult::Checked {
171        installed,
172        satisfied,
173    }
174}
175
176/// Result of a version check against a constraint.
177#[derive(Debug, Clone, PartialEq)]
178pub enum VersionCheckResult {
179    /// Version was checked — includes the installed version and whether
180    /// it satisfies the constraint.
181    Checked { installed: String, satisfied: bool },
182    /// Version check was skipped (no constraint, unparseable output, etc.).
183    Skipped,
184}
185
186// =============================================================================
187// Tests
188// =============================================================================
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    // --- extract_version ---
195
196    #[test]
197    fn test_extract_version_ffmpeg() {
198        let output = "ffmpeg version 6.1.1 Copyright (c) 2000-2023";
199        assert_eq!(extract_version(output), Some("6.1.1"));
200    }
201
202    #[test]
203    fn test_extract_version_ytdlp() {
204        // yt-dlp prints just the version string on a line.
205        let output = "2024.12.23";
206        assert_eq!(extract_version(output), Some("2024.12.23"));
207    }
208
209    #[test]
210    fn test_extract_version_multiline() {
211        let output = "yt-dlp 2024.12.23 [some extra info]\nMore lines here";
212        assert_eq!(extract_version(output), Some("2024.12.23"));
213    }
214
215    #[test]
216    fn test_extract_version_no_match() {
217        let output = "some random output without version numbers";
218        assert_eq!(extract_version(output), None);
219    }
220
221    #[test]
222    fn test_extract_version_bare_number() {
223        // Single number without dots should not match.
224        let output = "version 6";
225        assert_eq!(extract_version(output), None);
226    }
227
228    #[test]
229    fn test_extract_version_trailing_dot() {
230        let output = "tool 1.2.";
231        assert_eq!(extract_version(output), Some("1.2"));
232    }
233
234    // --- parse_constraint ---
235
236    #[test]
237    fn test_parse_constraint_gte() {
238        let c = parse_constraint(">=6.0").unwrap();
239        assert_eq!(c.op, ConstraintOp::Gte);
240        assert_eq!(c.segments, vec![6, 0]);
241    }
242
243    #[test]
244    fn test_parse_constraint_gte_three_parts() {
245        let c = parse_constraint(">=2024.0.0").unwrap();
246        assert_eq!(c.op, ConstraintOp::Gte);
247        assert_eq!(c.segments, vec![2024, 0, 0]);
248    }
249
250    #[test]
251    fn test_parse_constraint_empty_is_error() {
252        assert!(parse_constraint("").is_err());
253    }
254
255    #[test]
256    fn test_parse_constraint_unsupported_op() {
257        assert!(parse_constraint("<6.0").is_err());
258        assert!(parse_constraint("~6.0").is_err());
259        assert!(parse_constraint("^6.0").is_err());
260    }
261
262    #[test]
263    fn test_parse_constraint_with_spaces() {
264        let c = parse_constraint(">= 6.0").unwrap();
265        assert_eq!(c.segments, vec![6, 0]);
266    }
267
268    // --- satisfies ---
269
270    #[test]
271    fn test_satisfies_gte_exact() {
272        let c = parse_constraint(">=6.0").unwrap();
273        assert!(satisfies("6.0", &c));
274    }
275
276    #[test]
277    fn test_satisfies_gte_higher_major() {
278        let c = parse_constraint(">=6.0").unwrap();
279        assert!(satisfies("7.0.0", &c));
280    }
281
282    #[test]
283    fn test_satisfies_gte_higher_minor() {
284        let c = parse_constraint(">=6.0").unwrap();
285        assert!(satisfies("6.1.1", &c));
286    }
287
288    #[test]
289    fn test_satisfies_gte_fail_lower() {
290        let c = parse_constraint(">=6.0").unwrap();
291        assert!(!satisfies("5.1.2", &c));
292    }
293
294    #[test]
295    fn test_satisfies_calver_pass() {
296        let c = parse_constraint(">=2024.0.0").unwrap();
297        assert!(satisfies("2024.12.23", &c));
298    }
299
300    #[test]
301    fn test_satisfies_calver_fail() {
302        let c = parse_constraint(">=2024.0.0").unwrap();
303        assert!(!satisfies("2023.06.01", &c));
304    }
305
306    #[test]
307    fn test_satisfies_missing_segments_treated_as_zero() {
308        // "6.0" vs constraint ">=6.0.0" — the installed "6.0" becomes "6.0.0".
309        let c = parse_constraint(">=6.0.0").unwrap();
310        assert!(satisfies("6.0", &c));
311    }
312
313    #[test]
314    fn test_satisfies_unparseable_installed_returns_false() {
315        let c = parse_constraint(">=6.0").unwrap();
316        assert!(!satisfies("not-a-version", &c));
317    }
318
319    // --- check_version ---
320
321    #[test]
322    fn test_check_version_satisfied() {
323        let ctx = MockVersionContext("ffmpeg version 6.1.1 Copyright".to_string());
324        let result = check_version("ffmpeg", ">=6.0", &ctx);
325        assert_eq!(
326            result,
327            VersionCheckResult::Checked {
328                installed: "6.1.1".to_string(),
329                satisfied: true,
330            }
331        );
332    }
333
334    #[test]
335    fn test_check_version_unsatisfied() {
336        let ctx = MockVersionContext("ffmpeg version 5.0.2 Copyright".to_string());
337        let result = check_version("ffmpeg", ">=6.0", &ctx);
338        assert_eq!(
339            result,
340            VersionCheckResult::Checked {
341                installed: "5.0.2".to_string(),
342                satisfied: false,
343            }
344        );
345    }
346
347    #[test]
348    fn test_check_version_empty_constraint_skipped() {
349        let ctx = MockVersionContext("ffmpeg version 6.1.1".to_string());
350        let result = check_version("ffmpeg", "", &ctx);
351        assert_eq!(result, VersionCheckResult::Skipped);
352    }
353
354    #[test]
355    fn test_check_version_command_fails_skipped() {
356        let ctx = FailingContext;
357        let result = check_version("nonexistent", ">=1.0", &ctx);
358        assert_eq!(result, VersionCheckResult::Skipped);
359    }
360
361    #[test]
362    fn test_check_version_no_version_in_output_skipped() {
363        let ctx = MockVersionContext("no version here".to_string());
364        let result = check_version("tool", ">=1.0", &ctx);
365        assert_eq!(result, VersionCheckResult::Skipped);
366    }
367
368    // --- Test helpers ---
369
370    /// Mock context that returns a fixed string from `run_command`.
371    struct MockVersionContext(String);
372
373    impl crate::ProcessContext for MockVersionContext {
374        fn run_command(&self, _cmd: &str, _args: &[&str]) -> Result<Vec<u8>, BntoError> {
375            Ok(self.0.as_bytes().to_vec())
376        }
377        fn run_command_streaming(
378            &self,
379            _cmd: &str,
380            _args: &[&str],
381            _on_output: &dyn Fn(&str),
382        ) -> Result<Vec<u8>, BntoError> {
383            Ok(self.0.as_bytes().to_vec())
384        }
385        fn temp_file(&self, _suffix: &str) -> Result<std::path::PathBuf, BntoError> {
386            Err(BntoError::ProcessingFailed("mock".to_string()))
387        }
388        fn env_var(&self, _key: &str) -> Option<String> {
389            None
390        }
391        fn work_dir(&self) -> Result<&std::path::Path, BntoError> {
392            Err(BntoError::ProcessingFailed("mock".to_string()))
393        }
394    }
395
396    /// Mock context where all commands fail.
397    struct FailingContext;
398
399    impl crate::ProcessContext for FailingContext {
400        fn run_command(&self, _cmd: &str, _args: &[&str]) -> Result<Vec<u8>, BntoError> {
401            Err(BntoError::ProcessingFailed("not found".to_string()))
402        }
403        fn run_command_streaming(
404            &self,
405            _cmd: &str,
406            _args: &[&str],
407            _on_output: &dyn Fn(&str),
408        ) -> Result<Vec<u8>, BntoError> {
409            Err(BntoError::ProcessingFailed("not found".to_string()))
410        }
411        fn temp_file(&self, _suffix: &str) -> Result<std::path::PathBuf, BntoError> {
412            Err(BntoError::ProcessingFailed("mock".to_string()))
413        }
414        fn env_var(&self, _key: &str) -> Option<String> {
415            None
416        }
417        fn work_dir(&self) -> Result<&std::path::Path, BntoError> {
418            Err(BntoError::ProcessingFailed("mock".to_string()))
419        }
420    }
421}