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