Skip to main content

sqlness_runner/cmd/
compat_case.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::HashSet;
16use std::path::{Path, PathBuf};
17use std::process::Command;
18
19use serde::Deserialize;
20
21/// Metadata for a compatibility test case, parsed from `case.toml`.
22#[derive(Debug, Clone, Deserialize)]
23#[serde(deny_unknown_fields)]
24#[allow(dead_code)]
25pub struct CaseMetadata {
26    /// Human-readable name of the case.
27    pub name: String,
28    /// Why this compatibility case exists.
29    pub reason: String,
30    /// What PR, issue, or feature introduced this case.
31    pub introduced_by: String,
32    /// Which topologies this case applies to (e.g. ["distributed"]).
33    pub topologies: Vec<String>,
34    /// Version range for the "from" binary. `*` means all versions.
35    pub from_range: Vec<String>,
36    /// Version range for the "to" binary. `*` means all versions.
37    pub to_range: Vec<String>,
38    /// Features required (e.g. ["table", "flow"]).
39    pub features: Vec<String>,
40    /// Owner team or individual.
41    pub owner: String,
42    /// Optional explicit namespace. If not set, derived from case directory name.
43    /// Must match `[a-z0-9_]+`.
44    #[serde(default)]
45    pub namespace: Option<String>,
46}
47
48impl CaseMetadata {
49    /// Compute the effective namespace for this case.
50    /// Uses explicit `namespace` field if set, otherwise derives from case directory name.
51    pub fn effective_namespace(&self, case_dir_name: &str) -> String {
52        self.namespace
53            .clone()
54            .unwrap_or_else(|| sanitize_namespace(case_dir_name))
55    }
56}
57
58/// A loaded compatibility case (metadata + file paths).
59#[derive(Debug, Clone)]
60pub struct CompatCase {
61    /// Parsed metadata from case.toml.
62    pub metadata: CaseMetadata,
63    /// Path to the case directory.
64    pub dir: PathBuf,
65    /// Effective namespace for this case.
66    pub namespace: String,
67}
68
69/// Sanitize a name into a valid GreptimeDB namespace: lowercase alphanumeric + underscores.
70fn sanitize_namespace(name: &str) -> String {
71    let sanitized: String = name
72        .to_lowercase()
73        .chars()
74        .map(|c| {
75            if c.is_ascii_alphanumeric() || c == '_' {
76                c
77            } else {
78                '_'
79            }
80        })
81        .collect();
82
83    // Must start with a letter
84    if sanitized
85        .chars()
86        .next()
87        .is_none_or(|c| !c.is_ascii_alphabetic())
88    {
89        format!("c_{sanitized}")
90    } else {
91        sanitized
92    }
93}
94
95/// Discover all compat cases under `case_root`.
96/// Each case is a directory containing `case.toml`, `setup.sql`, and `verify.sql`.
97/// `verify.result` is optional at discovery — if missing, the verify phase
98/// generates it from actual output and fails so the author must review/commit.
99pub fn discover_cases(case_root: &Path) -> Result<Vec<CompatCase>, String> {
100    let mut cases = Vec::new();
101
102    if !case_root.is_dir() {
103        return Err(format!(
104            "Case root directory not found: {}",
105            case_root.display()
106        ));
107    }
108
109    let entries = std::fs::read_dir(case_root)
110        .map_err(|e| format!("Failed to read case root {}: {e}", case_root.display()))?;
111
112    for entry in entries {
113        let entry = entry.map_err(|e| format!("Failed to read case dir entry: {e}"))?;
114        let path = entry.path();
115        if !path.is_dir() {
116            continue;
117        }
118
119        let case_toml_path = path.join("case.toml");
120        if !case_toml_path.is_file() {
121            println!("Skipping directory {}: no case.toml found", path.display());
122            continue;
123        }
124
125        let setup_sql = path.join("setup.sql");
126        let verify_sql = path.join("verify.sql");
127
128        for required in [&setup_sql, &verify_sql] {
129            if !required.is_file() {
130                return Err(format!(
131                    "Missing required file {} in case directory {}",
132                    required.display(),
133                    path.display()
134                ));
135            }
136        }
137
138        let content = std::fs::read_to_string(&case_toml_path)
139            .map_err(|e| format!("Failed to read {}: {e}", case_toml_path.display()))?;
140
141        let metadata: CaseMetadata = toml::from_str(&content)
142            .map_err(|e| format!("Failed to parse {}: {e}", case_toml_path.display()))?;
143
144        let case_dir_name = path
145            .file_name()
146            .and_then(|n| n.to_str())
147            .unwrap_or("unknown");
148
149        let namespace = metadata.effective_namespace(case_dir_name);
150
151        cases.push(CompatCase {
152            metadata,
153            dir: path,
154            namespace,
155        });
156    }
157
158    if cases.is_empty() {
159        return Err(format!(
160            "No compat cases found under {}",
161            case_root.display()
162        ));
163    }
164
165    // Sort by directory name for deterministic ordering across runs.
166    cases.sort_by(|a, b| a.dir.file_name().cmp(&b.dir.file_name()));
167
168    Ok(cases)
169}
170
171/// Validate per-case metadata for all discovered cases.
172///
173/// Checks that required fields are non-empty, version constraints are parseable,
174/// and namespace format is valid.
175///
176/// Call this **before** version-range filtering so that invalid constraints
177/// (e.g. `>=not-a-version`) cause a hard error instead of being silently
178/// filtered out.
179pub fn validate_cases_metadata(cases: &[CompatCase]) -> Result<(), String> {
180    for case in cases {
181        // Validate namespace format: must start with a lowercase letter, followed by
182        // lowercase alphanumeric or underscores only.
183        if !is_valid_namespace(&case.namespace) {
184            return Err(format!(
185                "Case '{}' has invalid namespace '{}': must match [a-z][a-z0-9_]*",
186                case.metadata.name, case.namespace
187            ));
188        }
189
190        // Validate required metadata fields are non-empty
191        if case.metadata.name.is_empty() {
192            return Err(format!("Case in {} has empty name", case.dir.display()));
193        }
194        if case.metadata.reason.is_empty() {
195            return Err(format!("Case '{}' has empty reason", case.metadata.name));
196        }
197        if case.metadata.introduced_by.is_empty() {
198            return Err(format!(
199                "Case '{}' has empty introduced_by",
200                case.metadata.name
201            ));
202        }
203        if case.metadata.owner.is_empty() {
204            return Err(format!("Case '{}' has empty owner", case.metadata.name));
205        }
206        if case.metadata.topologies.is_empty() {
207            return Err(format!(
208                "Case '{}' has empty topologies",
209                case.metadata.name
210            ));
211        }
212        if case.metadata.from_range.is_empty() {
213            return Err(format!(
214                "Case '{}' has empty from_range",
215                case.metadata.name
216            ));
217        }
218        if case.metadata.to_range.is_empty() {
219            return Err(format!("Case '{}' has empty to_range", case.metadata.name));
220        }
221        validate_version_constraints(&case.metadata.name, "from_range", &case.metadata.from_range)?;
222        validate_version_constraints(&case.metadata.name, "to_range", &case.metadata.to_range)?;
223        if case.metadata.features.is_empty() {
224            return Err(format!("Case '{}' has empty features", case.metadata.name));
225        }
226    }
227
228    Ok(())
229}
230
231/// Check for duplicate namespaces.
232///
233/// Call this **before** version-range filtering so duplicated namespaces cannot
234/// hide behind version filters.
235pub fn validate_case_namespaces(cases: &[CompatCase]) -> Result<(), String> {
236    let mut namespaces: HashSet<&str> = HashSet::new();
237
238    for case in cases {
239        if !namespaces.insert(&case.namespace) {
240            return Err(format!(
241                "Duplicate namespace '{}' for case '{}'. \
242                 Each case must have a unique effective namespace.",
243                case.namespace, case.metadata.name
244            ));
245        }
246    }
247
248    Ok(())
249}
250
251/// Check whether a string is a valid namespace: starts with lowercase letter,
252/// contains only lowercase alphanumeric + underscores.
253fn is_valid_namespace(s: &str) -> bool {
254    if s.is_empty() {
255        return false;
256    }
257    let mut chars = s.chars();
258    match chars.next() {
259        Some(c) if c.is_ascii_lowercase() => {}
260        _ => return false,
261    }
262    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
263}
264
265fn validate_version_constraints(
266    case_name: &str,
267    field_name: &str,
268    constraints: &[String],
269) -> Result<(), String> {
270    for constraint in constraints {
271        parse_version_constraint(constraint).map_err(|e| {
272            format!("Case '{case_name}' has invalid {field_name} entry '{constraint}': {e}")
273        })?;
274    }
275    Ok(())
276}
277
278// ---------------------------------------------------------------------------
279// Version-range filtering
280// ---------------------------------------------------------------------------
281
282/// A simple 3-component version: `major.minor.patch`.
283#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
284pub(crate) struct Version {
285    pub major: u64,
286    pub minor: u64,
287    pub patch: u64,
288}
289
290impl Version {
291    /// Parse a version string like `v1.1.0` or `1.1.0`.
292    pub(crate) fn parse(raw: &str) -> Result<Self, String> {
293        let stripped = raw.strip_prefix('v').unwrap_or(raw);
294        let core = stripped
295            .split_once('-')
296            .map(|(core, _)| core)
297            .unwrap_or(stripped);
298        let core = core.split_once('+').map(|(core, _)| core).unwrap_or(core);
299        let parts: Vec<&str> = core.split('.').collect();
300        if parts.len() != 3 {
301            return Err(format!(
302                "Invalid version '{}': expected major.minor.patch",
303                raw
304            ));
305        }
306        let major = parts[0]
307            .parse::<u64>()
308            .map_err(|_| format!("Invalid major version in '{}'", raw))?;
309        let minor = parts[1]
310            .parse::<u64>()
311            .map_err(|_| format!("Invalid minor version in '{}'", raw))?;
312        let patch = parts[2]
313            .parse::<u64>()
314            .map_err(|_| format!("Invalid patch version in '{}'", raw))?;
315        Ok(Self {
316            major,
317            minor,
318            patch,
319        })
320    }
321}
322
323impl std::fmt::Display for Version {
324    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
325        write!(f, "v{}.{}.{}", self.major, self.minor, self.patch)
326    }
327}
328
329/// A version constraint used in `from_range` / `to_range`.
330#[derive(Debug, Clone)]
331enum VersionConstraint {
332    Wildcard,
333    Exact(Version),
334    Gt(Version),
335    Gte(Version),
336    Lt(Version),
337    Lte(Version),
338}
339
340impl VersionConstraint {
341    /// Does this constraint match the given version?
342    fn matches(&self, version: &Version) -> bool {
343        match self {
344            Self::Wildcard => true,
345            Self::Exact(target) => version == target,
346            Self::Gt(target) => version > target,
347            Self::Gte(target) => version >= target,
348            Self::Lt(target) => version < target,
349            Self::Lte(target) => version <= target,
350        }
351    }
352}
353
354/// Parse a single range entry (e.g. `*`, `<=v1.1.0`, `>=v1.1.1`, `v1.0.0`).
355fn parse_version_constraint(raw: &str) -> Result<VersionConstraint, String> {
356    let trimmed = raw.trim();
357    if trimmed.is_empty() {
358        return Err("Empty version constraint".to_string());
359    }
360    if trimmed == "*" {
361        return Ok(VersionConstraint::Wildcard);
362    }
363
364    // Ordered from most-specific prefix to least
365    if let Some(ver_str) = trimmed.strip_prefix(">=") {
366        let ver = Version::parse(ver_str.trim())?;
367        return Ok(VersionConstraint::Gte(ver));
368    }
369    if let Some(ver_str) = trimmed.strip_prefix("<=") {
370        let ver = Version::parse(ver_str.trim())?;
371        return Ok(VersionConstraint::Lte(ver));
372    }
373    if let Some(ver_str) = trimmed.strip_prefix("==") {
374        let ver = Version::parse(ver_str.trim())?;
375        return Ok(VersionConstraint::Exact(ver));
376    }
377    if let Some(ver_str) = trimmed.strip_prefix('=') {
378        let ver = Version::parse(ver_str.trim())?;
379        return Ok(VersionConstraint::Exact(ver));
380    }
381    if let Some(ver_str) = trimmed.strip_prefix('>') {
382        let ver = Version::parse(ver_str.trim())?;
383        return Ok(VersionConstraint::Gt(ver));
384    }
385    if let Some(ver_str) = trimmed.strip_prefix('<') {
386        let ver = Version::parse(ver_str.trim())?;
387        return Ok(VersionConstraint::Lt(ver));
388    }
389
390    // No operator → treat as exact
391    let ver = Version::parse(trimmed)?;
392    Ok(VersionConstraint::Exact(ver))
393}
394
395/// Check whether a version matches a list of OR-ed constraints.
396///
397/// * `version`: the effective version to test, or `None` if unknown.
398/// * `constraints`: the list from `from_range` or `to_range`.
399///
400/// Returns `true` if `version` matches at least one entry.
401///
402/// Wildcard entries match any version including unknown.
403/// Non-wildcard entries against an unknown version return `false`.
404pub(crate) fn version_matches_range(version: Option<&Version>, constraints: &[String]) -> bool {
405    if constraints.is_empty() {
406        return false;
407    }
408
409    for raw in constraints {
410        match parse_version_constraint(raw) {
411            Ok(VersionConstraint::Wildcard) => return true,
412            Ok(constraint) => {
413                if version.is_some_and(|ver| constraint.matches(ver)) {
414                    return true;
415                }
416                // Unknown version + non-wildcard → doesn't match this entry
417            }
418            Err(e) => {
419                println!(
420                    "Warning: invalid version constraint '{}' — skipping this entry: {e}",
421                    raw
422                );
423            }
424        }
425    }
426
427    false
428}
429
430/// Try to infer a version string by running `<bins_dir>/greptime --version`.
431/// Returns `None` if the binary cannot be executed or the output isn't parseable.
432pub(crate) fn try_infer_version(bins_dir: &Path) -> Option<Version> {
433    let binary = bins_dir.join("greptime");
434    if !binary.is_file() {
435        return None;
436    }
437    let output = Command::new(&binary).arg("--version").output().ok()?;
438    if !output.status.success() {
439        return None;
440    }
441    let stdout = String::from_utf8_lossy(&output.stdout);
442    // Typical output: "greptime 0.9.5-xxxxx" or just "greptime 0.9.5"
443    // Grab the first token that looks like a version.
444    for token in stdout.split_whitespace() {
445        // Strip leading 'v' if present and try to parse
446        let candidate = token.trim();
447        let looks_like_version = candidate.starts_with('v')
448            || candidate.chars().next().is_some_and(|c| c.is_ascii_digit());
449        let parsed_version = looks_like_version
450            .then(|| Version::parse(candidate).ok())
451            .flatten();
452        if let Some(ver) = parsed_version {
453            return Some(ver);
454        }
455    }
456    None
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    #[test]
464    fn test_sanitize_namespace() {
465        assert_eq!(sanitize_namespace("basic_table"), "basic_table");
466        assert_eq!(sanitize_namespace("my-case"), "my_case");
467        assert_eq!(sanitize_namespace("123abc"), "c_123abc");
468        assert_eq!(sanitize_namespace("UPPER"), "upper");
469        assert_eq!(sanitize_namespace("a.b-c"), "a_b_c");
470    }
471
472    #[test]
473    fn test_validate_cases_metadata_rejects_empty_required_vectors() {
474        let case = CompatCase {
475            metadata: CaseMetadata {
476                name: "case".to_string(),
477                reason: "reason".to_string(),
478                introduced_by: "pr".to_string(),
479                topologies: vec![],
480                from_range: vec!["*".to_string()],
481                to_range: vec!["*".to_string()],
482                features: vec!["table".to_string()],
483                owner: "team".to_string(),
484                namespace: None,
485            },
486            dir: PathBuf::from("case"),
487            namespace: "case".to_string(),
488        };
489
490        assert!(validate_cases_metadata(&[case]).is_err());
491    }
492
493    #[test]
494    fn test_validate_cases_metadata_catches_invalid_version_constraint() {
495        let case = CompatCase {
496            metadata: CaseMetadata {
497                name: "bad_constraint".to_string(),
498                reason: "test".to_string(),
499                introduced_by: "test".to_string(),
500                topologies: vec!["distributed".to_string()],
501                from_range: vec![">=not-a-version".to_string()],
502                to_range: vec!["*".to_string()],
503                features: vec!["table".to_string()],
504                owner: "test".to_string(),
505                namespace: None,
506            },
507            dir: PathBuf::from("bad_constraint"),
508            namespace: "bad_constraint".to_string(),
509        };
510
511        assert!(validate_cases_metadata(&[case]).is_err());
512    }
513
514    #[test]
515    fn test_validate_case_namespaces_rejects_duplicate() {
516        let case_a = CompatCase {
517            metadata: CaseMetadata {
518                name: "case_a".to_string(),
519                reason: "test".to_string(),
520                introduced_by: "test".to_string(),
521                topologies: vec!["distributed".to_string()],
522                from_range: vec!["*".to_string()],
523                to_range: vec!["*".to_string()],
524                features: vec!["table".to_string()],
525                owner: "test".to_string(),
526                namespace: None,
527            },
528            dir: PathBuf::from("case_a"),
529            namespace: "shared_name".to_string(),
530        };
531        let case_b = CompatCase {
532            metadata: CaseMetadata {
533                name: "case_b".to_string(),
534                reason: "test".to_string(),
535                introduced_by: "test".to_string(),
536                topologies: vec!["distributed".to_string()],
537                from_range: vec!["*".to_string()],
538                to_range: vec!["*".to_string()],
539                features: vec!["table".to_string()],
540                owner: "test".to_string(),
541                namespace: None,
542            },
543            dir: PathBuf::from("case_b"),
544            namespace: "shared_name".to_string(),
545        };
546
547        assert!(validate_cases_metadata(&[case_a.clone(), case_b.clone()]).is_ok());
548        assert!(validate_case_namespaces(&[case_a, case_b]).is_err());
549    }
550
551    #[test]
552    fn test_validate_case_namespaces_rejects_any_duplicate() {
553        // All duplicate namespaces are rejected — the removed `isolation = "shared"`
554        // exemption no longer applies.
555        let case_a = CompatCase {
556            metadata: CaseMetadata {
557                name: "case_a".to_string(),
558                reason: "test".to_string(),
559                introduced_by: "test".to_string(),
560                topologies: vec!["distributed".to_string()],
561                from_range: vec!["*".to_string()],
562                to_range: vec!["*".to_string()],
563                features: vec!["table".to_string()],
564                owner: "test".to_string(),
565                namespace: Some("shared_name".to_string()),
566            },
567            dir: PathBuf::from("case_a"),
568            namespace: "shared_name".to_string(),
569        };
570        let case_b = CompatCase {
571            metadata: CaseMetadata {
572                name: "case_b".to_string(),
573                reason: "test".to_string(),
574                introduced_by: "test".to_string(),
575                topologies: vec!["distributed".to_string()],
576                from_range: vec!["*".to_string()],
577                to_range: vec!["*".to_string()],
578                features: vec!["table".to_string()],
579                owner: "test".to_string(),
580                namespace: Some("shared_name".to_string()),
581            },
582            dir: PathBuf::from("case_b"),
583            namespace: "shared_name".to_string(),
584        };
585
586        assert!(validate_case_namespaces(&[case_a, case_b]).is_err());
587    }
588
589    #[test]
590    fn test_validate_cases_metadata_rejects_invalid_version_range() {
591        let case = CompatCase {
592            metadata: CaseMetadata {
593                name: "case".to_string(),
594                reason: "reason".to_string(),
595                introduced_by: "pr".to_string(),
596                topologies: vec!["distributed".to_string()],
597                from_range: vec![">=not-a-version".to_string()],
598                to_range: vec!["*".to_string()],
599                features: vec!["table".to_string()],
600                owner: "team".to_string(),
601                namespace: None,
602            },
603            dir: PathBuf::from("case"),
604            namespace: "case".to_string(),
605        };
606
607        assert!(validate_cases_metadata(&[case]).is_err());
608    }
609
610    /// Write minimal required files for a compat case into `dir`.
611    fn write_minimal_case(dir: &Path) {
612        std::fs::create_dir_all(dir).unwrap();
613        let case_toml = dir.join("case.toml");
614        let setup_sql = dir.join("setup.sql");
615        let verify_sql = dir.join("verify.sql");
616
617        std::fs::write(
618            &case_toml,
619            r#"
620name = "test_case"
621reason = "test"
622introduced_by = "test"
623topologies = ["distributed"]
624from_range = ["*"]
625to_range = ["*"]
626features = ["table"]
627owner = "test"
628"#,
629        )
630        .unwrap();
631        std::fs::write(&setup_sql, "CREATE TABLE t (a INT);").unwrap();
632        std::fs::write(&verify_sql, "SELECT * FROM t;").unwrap();
633    }
634
635    #[test]
636    fn test_discover_cases_allows_missing_verify_result() {
637        let tmp = tempfile::tempdir().unwrap();
638        let case_dir = tmp.path().join("my_case");
639        write_minimal_case(&case_dir);
640        // verify.result is intentionally absent — discovery should still succeed
641        assert!(!case_dir.join("verify.result").is_file());
642
643        let cases =
644            discover_cases(tmp.path()).expect("discover should succeed without verify.result");
645        assert_eq!(cases.len(), 1);
646        assert_eq!(cases[0].metadata.name, "test_case");
647    }
648
649    #[test]
650    fn test_discover_cases_rejects_missing_setup_sql() {
651        let tmp = tempfile::tempdir().unwrap();
652        let case_dir = tmp.path().join("my_case");
653        write_minimal_case(&case_dir);
654        std::fs::remove_file(case_dir.join("setup.sql")).unwrap();
655
656        assert!(discover_cases(tmp.path()).is_err());
657    }
658
659    #[test]
660    fn test_discover_cases_rejects_missing_verify_sql() {
661        let tmp = tempfile::tempdir().unwrap();
662        let case_dir = tmp.path().join("my_case");
663        write_minimal_case(&case_dir);
664        std::fs::remove_file(case_dir.join("verify.sql")).unwrap();
665
666        assert!(discover_cases(tmp.path()).is_err());
667    }
668
669    #[test]
670    fn test_discover_cases_rejects_unknown_isolation_field() {
671        let tmp = tempfile::tempdir().unwrap();
672        let case_dir = tmp.path().join("my_case");
673        write_minimal_case(&case_dir);
674        std::fs::write(
675            case_dir.join("case.toml"),
676            r#"
677name = "test_case"
678reason = "test"
679introduced_by = "test"
680topologies = ["distributed"]
681from_range = ["*"]
682to_range = ["*"]
683features = ["table"]
684owner = "test"
685isolation = "shared"
686"#,
687        )
688        .unwrap();
689
690        let err = discover_cases(tmp.path()).unwrap_err();
691        assert!(err.contains("unknown field `isolation`"));
692    }
693
694    #[test]
695    fn test_discover_cases_rejects_missing_case_toml() {
696        let tmp = tempfile::tempdir().unwrap();
697        let case_dir = tmp.path().join("my_case");
698        write_minimal_case(&case_dir);
699        std::fs::remove_file(case_dir.join("case.toml")).unwrap();
700
701        // No case.toml → skipped (not an error)
702        let result = discover_cases(tmp.path());
703        assert!(result.is_err()); // no cases found at all
704    }
705
706    // ------------------------------------------------------------------
707    // Version-range matching tests
708    // ------------------------------------------------------------------
709
710    fn mkver(s: &str) -> Version {
711        Version::parse(s).unwrap()
712    }
713
714    #[test]
715    fn test_parse_version_constraint_wildcard() {
716        let c = parse_version_constraint("*").unwrap();
717        assert!(matches!(c, VersionConstraint::Wildcard));
718    }
719
720    #[test]
721    fn test_parse_version_constraint_exact() {
722        let c = parse_version_constraint("v1.2.3").unwrap();
723        assert!(matches!(c, VersionConstraint::Exact(ref v) if v == &mkver("v1.2.3")));
724    }
725
726    #[test]
727    fn test_parse_version_constraint_gte() {
728        let c = parse_version_constraint(">=v1.1.0").unwrap();
729        assert!(matches!(c, VersionConstraint::Gte(ref v) if v == &mkver("v1.1.0")));
730    }
731
732    #[test]
733    fn test_parse_version_constraint_lte() {
734        let c = parse_version_constraint("<=v1.1.0").unwrap();
735        assert!(matches!(c, VersionConstraint::Lte(ref v) if v == &mkver("v1.1.0")));
736    }
737
738    #[test]
739    fn test_parse_version_constraint_gt() {
740        let c = parse_version_constraint(">v1.0.0").unwrap();
741        assert!(matches!(c, VersionConstraint::Gt(ref v) if v == &mkver("v1.0.0")));
742    }
743
744    #[test]
745    fn test_parse_version_constraint_lt() {
746        let c = parse_version_constraint("<v2.0.0").unwrap();
747        assert!(matches!(c, VersionConstraint::Lt(ref v) if v == &mkver("v2.0.0")));
748    }
749
750    #[test]
751    fn test_parse_version_constraint_eq_double() {
752        let c = parse_version_constraint("==v1.0.0").unwrap();
753        assert!(matches!(c, VersionConstraint::Exact(ref v) if v == &mkver("v1.0.0")));
754    }
755
756    #[test]
757    fn test_version_matches_range_wildcard() {
758        assert!(version_matches_range(None, &["*".to_string()]));
759        assert!(version_matches_range(
760            Some(&mkver("v9.9.9")),
761            &["*".to_string()]
762        ));
763    }
764
765    #[test]
766    fn test_version_matches_range_legacy_jsonb() {
767        let from_range = vec!["<=v1.1.0".to_string()];
768        let to_range = vec![">=v1.1.1".to_string()];
769
770        // from matches <=v1.1.0
771        assert!(version_matches_range(Some(&mkver("v0.9.5")), &from_range));
772        assert!(version_matches_range(Some(&mkver("v1.1.0")), &from_range));
773        assert!(!version_matches_range(Some(&mkver("v1.1.1")), &from_range));
774        assert!(!version_matches_range(Some(&mkver("v1.2.0")), &from_range));
775
776        // to matches >=v1.1.1
777        assert!(version_matches_range(Some(&mkver("v1.1.1")), &to_range));
778        assert!(version_matches_range(Some(&mkver("v2.0.0")), &to_range));
779        assert!(!version_matches_range(Some(&mkver("v1.1.0")), &to_range));
780        assert!(!version_matches_range(Some(&mkver("v0.9.5")), &to_range));
781    }
782
783    #[test]
784    fn test_version_matches_range_unknown_version() {
785        // Non-wildcard ranges should NOT match unknown version
786        assert!(!version_matches_range(None, &["<=v1.1.0".to_string()]));
787        assert!(!version_matches_range(None, &[">=v1.1.1".to_string()]));
788        assert!(!version_matches_range(None, &["v1.0.0".to_string()]));
789        // Wildcard still matches unknown
790        assert!(version_matches_range(None, &["*".to_string()]));
791    }
792
793    #[test]
794    fn test_version_matches_range_exact() {
795        let range = vec!["v1.0.0".to_string(), "v2.0.0".to_string()];
796        assert!(version_matches_range(Some(&mkver("v1.0.0")), &range));
797        assert!(version_matches_range(Some(&mkver("v2.0.0")), &range));
798        assert!(!version_matches_range(Some(&mkver("v1.5.0")), &range));
799    }
800
801    #[test]
802    fn test_version_parse_without_v() {
803        let ver = Version::parse("1.2.3").unwrap();
804        assert_eq!(ver.major, 1);
805        assert_eq!(ver.minor, 2);
806        assert_eq!(ver.patch, 3);
807    }
808
809    #[test]
810    fn test_version_parse_with_suffix() {
811        let ver = Version::parse("1.2.3-alpha+build").unwrap();
812        assert_eq!(ver.major, 1);
813        assert_eq!(ver.minor, 2);
814        assert_eq!(ver.patch, 3);
815    }
816
817    #[test]
818    fn test_try_infer_version_no_binary() {
819        let tmp = tempfile::tempdir().unwrap();
820        assert!(try_infer_version(tmp.path()).is_none());
821    }
822}