1use crate::errors::BntoError;
9
10#[derive(Debug, Clone, PartialEq)]
14pub struct VersionConstraint {
15 pub op: ConstraintOp,
16 pub segments: Vec<u64>,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq)]
21pub enum ConstraintOp {
22 Gte,
24}
25
26pub fn extract_version(output: &str) -> Option<&str> {
34 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 (None, b'0'..=b'9') => {
43 start = Some(i);
44 end = i + 1;
45 }
46 (Some(_), b'0'..=b'9' | b'.') => {
48 end = i + 1;
49 }
50 (Some(_), _) => break,
52 _ => {}
54 }
55 }
56
57 let s = start?;
58 let candidate = &output[s..end];
59
60 if !candidate.contains('.') {
62 return None;
63 }
64
65 Some(candidate.trim_end_matches('.'))
67}
68
69pub 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 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
93fn 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
109pub 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
127fn 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
141pub fn check_version(
146 binary: &str,
147 constraint_str: &str,
148 ctx: &dyn crate::ProcessContext,
149) -> VersionCheckResult {
150 let constraint = match parse_constraint(constraint_str) {
152 Ok(c) => c,
153 Err(_) => return VersionCheckResult::Skipped,
154 };
155
156 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 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#[derive(Debug, Clone, PartialEq)]
178pub enum VersionCheckResult {
179 Checked { installed: String, satisfied: bool },
182 Skipped,
184}
185
186#[cfg(test)]
191mod tests {
192 use super::*;
193
194 #[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 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 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 #[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 #[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 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 #[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 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 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}