1use std::collections::HashSet;
16use std::path::{Path, PathBuf};
17use std::process::Command;
18
19use serde::Deserialize;
20
21#[derive(Debug, Clone, Deserialize)]
23#[serde(deny_unknown_fields)]
24#[allow(dead_code)]
25pub struct CaseMetadata {
26 pub name: String,
28 pub reason: String,
30 pub introduced_by: String,
32 pub topologies: Vec<String>,
34 pub from_range: Vec<String>,
36 pub to_range: Vec<String>,
38 pub features: Vec<String>,
40 pub owner: String,
42 #[serde(default)]
45 pub namespace: Option<String>,
46}
47
48impl CaseMetadata {
49 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#[derive(Debug, Clone)]
60pub struct CompatCase {
61 pub metadata: CaseMetadata,
63 pub dir: PathBuf,
65 pub namespace: String,
67}
68
69fn 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 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
95pub 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 cases.sort_by(|a, b| a.dir.file_name().cmp(&b.dir.file_name()));
167
168 Ok(cases)
169}
170
171pub fn validate_cases_metadata(cases: &[CompatCase]) -> Result<(), String> {
180 for case in cases {
181 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 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
231pub 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
251fn 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#[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 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#[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 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
354fn 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 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 let ver = Version::parse(trimmed)?;
392 Ok(VersionConstraint::Exact(ver))
393}
394
395pub(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 }
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
430pub(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 for token in stdout.split_whitespace() {
445 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 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 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 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 let result = discover_cases(tmp.path());
703 assert!(result.is_err()); }
705
706 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 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 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 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 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}