Skip to main content

sqlness_runner/cmd/
compat.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::BTreeMap;
16use std::path::PathBuf;
17use std::sync::Arc;
18
19use clap::{Parser, ValueEnum};
20use sqlness::QueryContext;
21use sqlness::interceptor::template::DELIMITER as TEMPLATE_DELIMITER;
22use sqlness::interceptor::{InterceptorRef, Registry};
23
24use crate::cmd::bare::ServerAddr;
25use crate::cmd::compat_case::{self, CompatCase, try_infer_version, version_matches_range};
26use crate::cmd::datanode_overlay::{
27    DatanodeOverlay, DatanodeProtectionPolicy, PreparedDatanodeOverlay,
28};
29use crate::env::bare::{Env, GreptimeDB, StoreConfig, WalConfig};
30use crate::protocol_interceptor::{self, POSTGRES, PROTOCOL_KEY};
31use crate::util;
32
33const COMMENT_PREFIX: &str = "--";
34const INTERCEPTOR_PREFIX: &str = "-- SQLNESS";
35const QUERY_DELIMITER: char = ';';
36
37#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
38enum CompatTopology {
39    Distributed,
40    Standalone,
41}
42
43impl CompatTopology {
44    fn as_str(self) -> &'static str {
45        match self {
46            Self::Distributed => "distributed",
47            Self::Standalone => "standalone",
48        }
49    }
50}
51
52/// Run compatibility tests in bare mode.
53///
54/// Starts a "from" distributed cluster, runs setup SQLs,
55/// then restarts the cluster with a "to" version on preserved state,
56/// and runs verify SQLs comparing results against `verify.result` files.
57///
58/// PR1 notes:
59/// - Sqlness interceptor comments are supported for each statement.
60/// - The runner starts the full distributed topology, including flownode.
61#[derive(Debug, Parser)]
62pub struct CompatCommand {
63    /// Version of the "from" GreptimeDB binary (e.g. "v0.9.5") or "current".
64    /// If neither --from-version nor --from-bins-dir is specified, the
65    /// current debug build is used for both from and to.
66    #[clap(long)]
67    from_version: Option<String>,
68
69    /// Path to the directory containing the "from" GreptimeDB binary.
70    #[clap(long)]
71    from_bins_dir: Option<PathBuf>,
72
73    /// Path to the directory containing the "to" GreptimeDB binary.
74    /// Defaults to the current debug build.
75    #[clap(long)]
76    to_bins_dir: Option<PathBuf>,
77
78    /// Version of the "to" GreptimeDB binary (e.g. "v1.1.4") or "current".
79    /// Downloads the release binary when needed. Cannot be used with
80    /// `--to-bins-dir`.
81    #[clap(long)]
82    to_version: Option<String>,
83
84    /// Directory of compatibility test cases.
85    /// Defaults to `tests/compatibility/cases` relative to workspace root.
86    #[clap(long)]
87    case_dir: Option<PathBuf>,
88
89    /// Name of test cases to run. Accepts a regexp.
90    #[clap(long, default_value = ".*")]
91    test_filter: String,
92
93    /// Require exactly this many cases after all filters are applied.
94    #[clap(long)]
95    expect_cases: Option<usize>,
96
97    /// Topology to start for the compatibility test.
98    #[clap(long, value_enum, default_value_t = CompatTopology::Distributed)]
99    topology: CompatTopology,
100
101    /// Fail this run as soon as one case fails.
102    #[clap(long, default_value = "false")]
103    fail_fast: bool,
104
105    /// Preserve persistent state in the temporary directory after run.
106    /// Etcd is always cleaned up regardless of this flag.
107    #[clap(long, default_value = "false")]
108    preserve_state: bool,
109
110    /// Pull different versions of GreptimeDB on need.
111    #[clap(long, default_value = "true")]
112    pull_version_on_need: bool,
113
114    /// Whether to set up etcd via Docker. Required for PR1 distributed compat.
115    /// External metadata stores are not supported by the compat MVP yet.
116    #[clap(long, default_value = "true")]
117    setup_etcd: bool,
118
119    /// Perform discovery and filtering only; print what would run without
120    /// starting any services, mutating files, or running setup/verify.
121    #[clap(long, default_value = "false")]
122    dry_run: bool,
123}
124
125impl CompatCommand {
126    pub async fn run(self) {
127        let dry_run = self.dry_run;
128        let topology = self.topology;
129
130        if self.to_bins_dir.is_some() && self.to_version.is_some() {
131            panic!("--to-version cannot be used with --to-bins-dir");
132        }
133
134        // ---- 1. Validate MVP runtime constraints ----
135        if !dry_run && topology == CompatTopology::Distributed && !self.setup_etcd {
136            panic!(
137                "compat MVP requires Docker etcd (--setup-etcd=true); external metadata stores are not supported yet"
138            );
139        }
140
141        // ---- 2. Resolve case directory ----
142        let case_dir = self.case_dir.unwrap_or_else(default_compat_case_dir);
143
144        if !case_dir.is_dir() {
145            panic!("Case directory not found: {}", case_dir.display());
146        }
147
148        // ---- 3. Discover cases ----
149        let mut cases = compat_case::discover_cases(&case_dir).unwrap_or_else(|e| panic!("{e}"));
150
151        // Filter by test_filter
152        let filter_re = regex::Regex::new(&self.test_filter)
153            .unwrap_or_else(|e| panic!("Invalid test filter regex '{}': {e}", self.test_filter));
154        cases.retain(|c| filter_re.is_match(&c.metadata.name));
155
156        // Filter by topology
157        cases.retain(|c| {
158            c.metadata
159                .topologies
160                .iter()
161                .any(|candidate| candidate == topology.as_str())
162        });
163
164        if cases.is_empty() {
165            if let Some(expected_cases) = self.expect_cases {
166                panic!(
167                    "Expected {expected_cases} compatibility cases after name and topology filtering, found 0"
168                );
169            }
170            if dry_run {
171                println!(
172                    "DRY-RUN: no compat cases found matching filter '{}' and topology '{}'",
173                    self.test_filter,
174                    topology.as_str()
175                );
176            } else {
177                println!(
178                    "No compat cases found matching filter '{}' and topology '{}'",
179                    self.test_filter,
180                    topology.as_str()
181                );
182            }
183            return;
184        }
185
186        // ---- 3b. Validate metadata (incl. version constraints) before filtering ----
187        // Must run before version-range filtering so invalid constraints like
188        // `>=not-a-version` cause a hard error instead of silent skip.
189        compat_case::validate_cases_metadata(&cases).unwrap_or_else(|e| panic!("{e}"));
190
191        // ---- 3c. Validate namespace dedup before version filtering ----
192        // Validate globally for all selected topology/name cases so duplicated
193        // namespaces cannot hide behind version filters.
194        compat_case::validate_case_namespaces(&cases).unwrap_or_else(|e| panic!("{e}"));
195
196        // ---- 4. Resolve "from" and "to" versions ----
197        let (from_bins_dir, from_version, from_ver_parsed, to_bins_dir, to_version, to_ver_parsed) =
198            if dry_run {
199                // Dry-run: resolve versions without panicking on missing binaries.
200                // try_infer_version returns None gracefully when the binary is absent.
201                let dry_run_from_bins_dir = self
202                    .from_bins_dir
203                    .clone()
204                    .unwrap_or_else(|| util::get_binary_dir("debug"));
205                let from_ver_str = self
206                    .from_version
207                    .as_deref()
208                    .and_then(|v| {
209                        if v == "current" {
210                            None
211                        } else {
212                            Some(v.to_string())
213                        }
214                    })
215                    .or_else(|| try_infer_version(&dry_run_from_bins_dir).map(|v| v.to_string()));
216                let from_ver_parsed = from_ver_str
217                    .as_deref()
218                    .and_then(|s| compat_case::Version::parse(s).ok());
219
220                let dry_run_to_bins_dir = self.to_bins_dir.clone().unwrap_or_else(|| {
221                    self.to_version
222                        .as_deref()
223                        .filter(|version| *version != "current")
224                        .map(|version| PathBuf::from(util::get_workspace_root()).join(version))
225                        .unwrap_or_else(|| util::get_binary_dir("debug"))
226                });
227                let to_ver_str = self
228                    .to_version
229                    .as_deref()
230                    .filter(|version| *version != "current")
231                    .map(str::to_string)
232                    .or_else(|| try_infer_version(&dry_run_to_bins_dir).map(|v| v.to_string()));
233                let to_ver_parsed = to_ver_str
234                    .as_deref()
235                    .and_then(|s| compat_case::Version::parse(s).ok());
236
237                (
238                    Some(dry_run_from_bins_dir),
239                    from_ver_str,
240                    from_ver_parsed,
241                    Some(dry_run_to_bins_dir),
242                    to_ver_str,
243                    to_ver_parsed,
244                )
245            } else {
246                // Normal path: resolve bins (may panic if binary not found).
247                let from_bins_dir = resolve_bins(
248                    self.from_bins_dir.as_ref(),
249                    self.from_version.as_deref(),
250                    self.pull_version_on_need,
251                )
252                .await;
253
254                let from_version = if let Some(ref ver) = self.from_version {
255                    if ver != "current" {
256                        Some(ver.clone())
257                    } else {
258                        try_infer_version(&from_bins_dir).map(|v| v.to_string())
259                    }
260                } else {
261                    try_infer_version(&from_bins_dir).map(|v| v.to_string())
262                };
263
264                let from_ver_parsed = from_version
265                    .as_deref()
266                    .and_then(|s| compat_case::Version::parse(s).ok());
267
268                let to_bins_dir = resolve_bins(
269                    self.to_bins_dir.as_ref(),
270                    self.to_version.as_deref(),
271                    self.pull_version_on_need,
272                )
273                .await;
274                let to_version = self
275                    .to_version
276                    .as_deref()
277                    .filter(|version| *version != "current")
278                    .map(str::to_string)
279                    .or_else(|| try_infer_version(&to_bins_dir).map(|v| v.to_string()));
280                let to_ver_parsed = to_version
281                    .as_deref()
282                    .and_then(|s| compat_case::Version::parse(s).ok());
283
284                (
285                    Some(from_bins_dir),
286                    from_version,
287                    from_ver_parsed,
288                    Some(to_bins_dir),
289                    to_version,
290                    to_ver_parsed,
291                )
292            };
293
294        // ---- 5b. Filter by version range ----
295        let pre_filter_count = cases.len();
296        cases.retain(|c| {
297            let from_ok = version_matches_range(from_ver_parsed.as_ref(), &c.metadata.from_range);
298            if !from_ok {
299                let from_label = from_ver_parsed
300                    .as_ref()
301                    .map(|v| v.to_string())
302                    .unwrap_or_else(|| "unknown".to_string());
303                println!(
304                    "Skipping case '{}': from_range {:?} does not match version '{}'",
305                    c.metadata.name, c.metadata.from_range, from_label
306                );
307            }
308            from_ok
309        });
310        cases.retain(|c| {
311            let to_ok = version_matches_range(to_ver_parsed.as_ref(), &c.metadata.to_range);
312            if !to_ok {
313                let to_label = to_ver_parsed
314                    .as_ref()
315                    .map(|v| v.to_string())
316                    .unwrap_or_else(|| "unknown".to_string());
317                println!(
318                    "Skipping case '{}': to_range {:?} does not match version '{}'",
319                    c.metadata.name, c.metadata.to_range, to_label
320                );
321            }
322            to_ok
323        });
324
325        if pre_filter_count != cases.len() {
326            println!(
327                "Version-range filtering: {} → {} cases",
328                pre_filter_count,
329                cases.len()
330            );
331        }
332
333        if cases.is_empty() {
334            if let Some(expected_cases) = self.expect_cases {
335                panic!(
336                    "Expected {expected_cases} compatibility cases after all filtering, found 0"
337                );
338            }
339            if dry_run {
340                println!("DRY-RUN: no compat cases would run after version-range filtering");
341            } else {
342                println!("No compat cases remaining after version-range filtering");
343            }
344            return;
345        }
346
347        let wal_config = WalConfig::RaftEngine;
348        let profiles =
349            prepare_compat_profiles(cases, &wal_config).unwrap_or_else(|error| panic!("{error}"));
350        emit_protected_path_warnings(&profiles);
351
352        if let Some(expected_cases) = self.expect_cases {
353            assert_eq!(
354                profiles.case_count(),
355                expected_cases,
356                "Expected {expected_cases} compatibility cases after all filtering, found {}",
357                profiles.case_count()
358            );
359        }
360
361        if dry_run {
362            println!(
363                "DRY-RUN: would run {} compat case(s)",
364                profiles.case_count()
365            );
366            println!("  topology:     {}", topology.as_str());
367            println!(
368                "  from version: {}",
369                from_version.as_deref().unwrap_or(
370                    "unknown (use --from-version, --from-bins-dir, or build debug binary)"
371                )
372            );
373            println!(
374                "  to version:   {}",
375                to_version
376                    .as_deref()
377                    .unwrap_or("unknown (use --to-bins-dir or build debug binary)")
378            );
379            if pre_filter_count != profiles.case_count() {
380                println!();
381                println!(
382                    "Version-range filtering reduced {} → {} cases (see 'Skipping case' messages above)",
383                    pre_filter_count,
384                    profiles.case_count()
385                );
386            }
387            println!();
388            for c in profiles.cases() {
389                println!("  case:        {}", c.metadata.name);
390                println!("    namespace:   {}", c.namespace);
391                println!("    from_range:  {:?}", c.metadata.from_range);
392                println!("    to_range:    {:?}", c.metadata.to_range);
393                println!("    features:    {:?}", c.metadata.features);
394            }
395            println!("DRY-RUN: compatibility profiles:");
396            for profile in profiles.iter() {
397                println!("  profile:      {}", profile.profile_id());
398                println!("    cases:       {}", profile.case_names().join(", "));
399                for source in profile.sources() {
400                    println!("    sidecar:     {}", source.display());
401                }
402            }
403            println!();
404            println!("Dry run complete. Remove --dry-run to execute.");
405            return;
406        }
407
408        println!(
409            "Running {} compat case(s) with topology {}:",
410            profiles.case_count(),
411            topology.as_str()
412        );
413        for c in profiles.cases() {
414            println!(
415                "  - {} (namespace: {}, topologies: {:?})",
416                c.metadata.name, c.namespace, c.metadata.topologies
417            );
418        }
419
420        // ---- 6. Build interceptor registry ----
421        let interceptor_registry = create_interceptor_registry();
422        let to_bins_dir = to_bins_dir.expect("to_bins_dir must be resolved in non-dry-run mode");
423        let profile_config = ProfileRunConfig {
424            interceptor_registry: &interceptor_registry,
425            from_bins_dir,
426            to_bins_dir,
427            pull_version_on_need: self.pull_version_on_need,
428            setup_etcd: self.setup_etcd,
429            fail_fast: self.fail_fast,
430            topology,
431            preserve_state: self.preserve_state,
432            wal_config,
433        };
434        let mut failures = Vec::new();
435        let mut preserved_states = Vec::new();
436        for profile in profiles.iter() {
437            let outcome = run_profile(profile, &profile_config).await;
438            failures.extend(outcome.failures);
439            if let Some(state) = outcome.preserved_state {
440                preserved_states.push(state);
441            }
442            if outcome.stop_remaining_profiles {
443                break;
444            }
445        }
446
447        for state in &preserved_states {
448            println!("Preserved compat profile state: {}", state.display());
449        }
450
451        if failures.is_empty() {
452            println!("\n\x1b[32mAll compat tests passed!\x1b[0m");
453        } else {
454            panic!("\n\x1b[31mFailed cases: {}\x1b[0m", failures.join(", "));
455        }
456    }
457}
458
459/// Cases sharing one old-stage datanode configuration lifecycle.
460#[derive(Debug)]
461enum CompatProfile {
462    Baseline {
463        cases: Vec<CompatCase>,
464    },
465    Overlay {
466        overlay: Arc<PreparedDatanodeOverlay>,
467        cases: Vec<CompatCase>,
468        sources: Vec<PathBuf>,
469    },
470}
471
472impl CompatProfile {
473    fn profile_id(&self) -> &str {
474        match self {
475            Self::Baseline { .. } => "baseline",
476            Self::Overlay { overlay, .. } => overlay.profile_id(),
477        }
478    }
479
480    fn cases(&self) -> &[CompatCase] {
481        match self {
482            Self::Baseline { cases } | Self::Overlay { cases, .. } => cases,
483        }
484    }
485
486    fn case_names(&self) -> Vec<&str> {
487        self.cases()
488            .iter()
489            .map(|case| case.metadata.name.as_str())
490            .collect()
491    }
492
493    fn sources(&self) -> &[PathBuf] {
494        match self {
495            Self::Baseline { .. } => &[],
496            Self::Overlay { sources, .. } => sources,
497        }
498    }
499
500    fn overlay(&self) -> Option<Arc<PreparedDatanodeOverlay>> {
501        match self {
502            Self::Baseline { .. } => None,
503            Self::Overlay { overlay, .. } => Some(Arc::clone(overlay)),
504        }
505    }
506
507    fn touched_protected_paths(&self) -> &[crate::cmd::datanode_overlay::DottedPath] {
508        match self {
509            Self::Baseline { .. } => &[],
510            Self::Overlay { overlay, .. } => overlay.touched_protected_paths(),
511        }
512    }
513}
514
515/// Deterministically ordered compatibility profiles.
516#[derive(Debug)]
517struct CompatProfiles(Vec<CompatProfile>);
518
519impl CompatProfiles {
520    fn iter(&self) -> impl Iterator<Item = &CompatProfile> {
521        self.0.iter()
522    }
523
524    fn cases(&self) -> impl Iterator<Item = &CompatCase> {
525        self.0.iter().flat_map(CompatProfile::cases)
526    }
527
528    fn case_count(&self) -> usize {
529        self.0.iter().map(|profile| profile.cases().len()).sum()
530    }
531}
532
533#[derive(Debug)]
534struct OverlayProfileGroup {
535    overlay: Arc<PreparedDatanodeOverlay>,
536    cases: Vec<CompatCase>,
537    sources: Vec<PathBuf>,
538}
539
540fn prepare_compat_profiles(
541    cases: Vec<CompatCase>,
542    wal_config: &WalConfig,
543) -> Result<CompatProfiles, String> {
544    let protection = DatanodeProtectionPolicy::for_wal(wal_config);
545    let mut baseline_cases = Vec::new();
546    let mut overlays: BTreeMap<[u8; 32], OverlayProfileGroup> = BTreeMap::new();
547
548    for case in cases {
549        let Some(reference) = case.metadata.old_datanode_overlay() else {
550            baseline_cases.push(case);
551            continue;
552        };
553        let overlay = DatanodeOverlay::load(&case.dir, reference).map_err(|error| {
554            format!(
555                "Failed to load old datanode overlay for compatibility case '{}': {error}",
556                case.metadata.name
557            )
558        })?;
559        let prepared = Arc::new(overlay.prepare(&protection).map_err(|error| {
560            format!(
561                "Failed to prepare old datanode overlay for compatibility case '{}': {error}",
562                case.metadata.name
563            )
564        })?);
565        let key = *prepared.profile_key();
566        let source = prepared.source().to_path_buf();
567        let entry = overlays.entry(key).or_insert_with(|| OverlayProfileGroup {
568            overlay: Arc::clone(&prepared),
569            cases: Vec::new(),
570            sources: Vec::new(),
571        });
572        entry.cases.push(case);
573        entry.sources.push(source);
574    }
575
576    baseline_cases.sort_by(|left, right| left.metadata.name.cmp(&right.metadata.name));
577    let mut profiles = Vec::new();
578    if !baseline_cases.is_empty() {
579        profiles.push(CompatProfile::Baseline {
580            cases: baseline_cases,
581        });
582    }
583    for (
584        _,
585        OverlayProfileGroup {
586            overlay,
587            mut cases,
588            mut sources,
589        },
590    ) in overlays
591    {
592        cases.sort_by(|left, right| left.metadata.name.cmp(&right.metadata.name));
593        sources.sort();
594        sources.dedup();
595        profiles.push(CompatProfile::Overlay {
596            overlay,
597            cases,
598            sources,
599        });
600    }
601
602    Ok(CompatProfiles(profiles))
603}
604
605fn emit_protected_path_warnings(profiles: &CompatProfiles) {
606    for profile in profiles.iter() {
607        let paths: Vec<_> = profile
608            .touched_protected_paths()
609            .iter()
610            .map(ToString::to_string)
611            .collect();
612        if paths.is_empty() {
613            continue;
614        }
615        println!(
616            "{}",
617            format_protected_path_warning(profile.profile_id(), profile.case_names(), paths)
618        );
619    }
620}
621
622fn format_protected_path_warning(
623    profile_id: &str,
624    mut case_names: Vec<&str>,
625    mut paths: Vec<String>,
626) -> String {
627    case_names.sort_unstable();
628    paths.sort_unstable();
629    format!(
630        "Warning: datanode overlay profile {profile_id} touches runner-owned paths [{}] for cases [{}]",
631        paths.join(", "),
632        case_names.join(", ")
633    )
634}
635
636#[derive(Debug)]
637struct ProfileOutcome {
638    failures: Vec<String>,
639    stop_remaining_profiles: bool,
640    preserved_state: Option<PathBuf>,
641}
642
643/// Pure compatibility profile progress policy, independent of process ownership.
644#[derive(Default)]
645struct ProfileProgress {
646    successful_setup_indexes: Vec<usize>,
647    failures: Vec<String>,
648    fail_fast_setup_failure: bool,
649    fail_fast_verify_failure: bool,
650    cleanup_failed: bool,
651}
652
653impl ProfileProgress {
654    fn record_setup_success(&mut self, case_index: usize) {
655        self.successful_setup_indexes.push(case_index);
656    }
657
658    fn record_setup_failure(&mut self, failure: String, fail_fast: bool) -> bool {
659        self.failures.push(failure);
660        self.fail_fast_setup_failure = fail_fast;
661        fail_fast
662    }
663
664    fn record_verify_failure(&mut self, failure: String, fail_fast: bool) -> bool {
665        self.failures.push(failure);
666        self.fail_fast_verify_failure = fail_fast;
667        fail_fast
668    }
669
670    fn record_cleanup_failure(&mut self, failure: String) {
671        self.failures.push(failure);
672        self.cleanup_failed = true;
673    }
674
675    fn should_transition_to_current(&self) -> bool {
676        !self.successful_setup_indexes.is_empty() && !self.fail_fast_setup_failure
677    }
678
679    fn should_stop_remaining_profiles(&self) -> bool {
680        self.fail_fast_setup_failure || self.fail_fast_verify_failure || self.cleanup_failed
681    }
682}
683
684struct ProfileRunConfig<'a> {
685    interceptor_registry: &'a Registry,
686    from_bins_dir: Option<PathBuf>,
687    to_bins_dir: PathBuf,
688    pull_version_on_need: bool,
689    setup_etcd: bool,
690    fail_fast: bool,
691    topology: CompatTopology,
692    preserve_state: bool,
693    wal_config: WalConfig,
694}
695
696async fn run_profile(profile: &CompatProfile, config: &ProfileRunConfig<'_>) -> ProfileOutcome {
697    println!("Running compatibility profile {}", profile.profile_id());
698    let temp_dir = tempfile::Builder::new()
699        .prefix(&format!("sqlness-compat-{}-", profile.profile_id()))
700        .tempdir()
701        .unwrap();
702    let profile_state = ProfileStateGuard::new(temp_dir, config.preserve_state);
703    let sqlness_home = profile_state.path().to_path_buf();
704    // Standalone runs need no etcd.
705    let setup_etcd = config.topology == CompatTopology::Distributed && config.setup_etcd;
706    unsafe {
707        std::env::set_var(
708            "SQLNESS_HOME",
709            sqlness_home.join("copy").display().to_string(),
710        );
711    }
712
713    let store_config = StoreConfig {
714        store_addrs: setup_etcd
715            .then(|| "127.0.0.1:2379".to_string())
716            .into_iter()
717            .collect(),
718        setup_etcd,
719        setup_pg: None,
720        setup_mysql: None,
721        enable_flat_format: false,
722        enable_gc: false,
723    };
724    let env = Env::new(
725        sqlness_home.clone(),
726        ServerAddr::default(),
727        config.wal_config.clone(),
728        config.pull_version_on_need,
729        config.from_bins_dir.clone(),
730        store_config,
731        vec![],
732    );
733    if let Some(overlay) = profile.overlay() {
734        env.activate_compat_old(overlay);
735    }
736
737    // Arm immediately before starting the profile so earlier preflight failures
738    // cannot remove a developer-owned etcd container.
739    let etcd_guard = setup_etcd.then(EtcdGuard::new);
740    println!(
741        "Starting old-version {} cluster...",
742        config.topology.as_str()
743    );
744    let db = match config.topology {
745        CompatTopology::Distributed => env.compat_start_distributed(0).await,
746        CompatTopology::Standalone => env.compat_start_standalone(0).await,
747    };
748
749    let mut progress = ProfileProgress::default();
750    for (case_index, case) in profile.cases().iter().enumerate() {
751        match run_compat_phase(&db, case, config.interceptor_registry, CompatPhase::Setup).await {
752            Ok(()) => {
753                println!("  Setup: {} - OK", case.metadata.name);
754                progress.record_setup_success(case_index);
755            }
756            Err(error) => {
757                println!("  Setup: {} - FAILED: {error}", case.metadata.name);
758                if progress.record_setup_failure(
759                    format!("{} (setup): {error}", case.metadata.name),
760                    config.fail_fast,
761                ) {
762                    break;
763                }
764            }
765        }
766    }
767
768    if progress.should_transition_to_current() {
769        println!("Restarting cluster with new-version binary on preserved state...");
770        env.activate_compat_current();
771        env.compat_restart(&db, config.to_bins_dir.clone()).await;
772
773        println!("Running verify phase...");
774        for case_index in progress.successful_setup_indexes.clone() {
775            let case = &profile.cases()[case_index];
776            match run_compat_phase(&db, case, config.interceptor_registry, CompatPhase::Verify)
777                .await
778            {
779                Ok(()) => println!("  Verify: {} - PASSED", case.metadata.name),
780                Err(error) => {
781                    println!("  Verify: {} - FAILED: {error}", case.metadata.name);
782                    if progress.record_verify_failure(
783                        format!("{} (verify): {error}", case.metadata.name),
784                        config.fail_fast,
785                    ) {
786                        break;
787                    }
788                }
789            }
790        }
791    } else if progress.successful_setup_indexes.is_empty() {
792        println!("Skipping current-version restart: no setup cases succeeded");
793    }
794
795    let cleanup = cleanup_profile(db, etcd_guard, setup_etcd, profile_state).await;
796    for failure in cleanup.failures {
797        progress.record_cleanup_failure(failure);
798    }
799    let stop_remaining_profiles = progress.should_stop_remaining_profiles();
800    ProfileOutcome {
801        failures: progress.failures,
802        stop_remaining_profiles,
803        preserved_state: cleanup.preserved_state,
804    }
805}
806
807struct CleanupOutcome {
808    failures: Vec<String>,
809    preserved_state: Option<PathBuf>,
810}
811
812/// Owns one profile state directory until normal cleanup or unwind finalization.
813struct ProfileStateGuard {
814    temp_dir: Option<tempfile::TempDir>,
815    preserve_state: bool,
816}
817
818impl ProfileStateGuard {
819    fn new(temp_dir: tempfile::TempDir, preserve_state: bool) -> Self {
820        Self {
821            temp_dir: Some(temp_dir),
822            preserve_state,
823        }
824    }
825
826    fn path(&self) -> &std::path::Path {
827        self.temp_dir.as_ref().unwrap().path()
828    }
829
830    fn finalize(mut self) -> CleanupOutcome {
831        let temp_dir = self.temp_dir.take().unwrap();
832        let mut failures = Vec::new();
833        if self.preserve_state {
834            let path = temp_dir.keep();
835            if let Err(error) = std::fs::metadata(&path) {
836                failures.push(format!(
837                    "failed to confirm preserved profile state {}: {error}",
838                    path.display()
839                ));
840            }
841            return CleanupOutcome {
842                failures,
843                preserved_state: Some(path),
844            };
845        }
846
847        println!("Removing state in {}", temp_dir.path().display());
848        if let Err(error) = temp_dir.close() {
849            failures.push(format!("failed to remove profile state: {error}"));
850        }
851        CleanupOutcome {
852            failures,
853            preserved_state: None,
854        }
855    }
856}
857
858impl Drop for ProfileStateGuard {
859    fn drop(&mut self) {
860        let Some(temp_dir) = self.temp_dir.take() else {
861            return;
862        };
863        if self.preserve_state {
864            let path = temp_dir.keep();
865            println!(
866                "Preserved compat profile state after abnormal exit: {}",
867                path.display()
868            );
869        }
870    }
871}
872
873async fn cleanup_profile(
874    mut db: GreptimeDB,
875    mut etcd_guard: Option<EtcdGuard>,
876    setup_etcd: bool,
877    profile_state: ProfileStateGuard,
878) -> CleanupOutcome {
879    db.compat_stop();
880    drop(db);
881    let mut failures = Vec::new();
882    if setup_etcd {
883        println!("Stopping etcd");
884        match util::stop_rm_etcd_checked() {
885            Ok(()) => {
886                if let Some(guard) = etcd_guard.as_mut() {
887                    guard.disarm();
888                }
889            }
890            Err(error) => failures.push(format!("profile etcd cleanup: {error}")),
891        }
892    }
893    // On failed checked cleanup this Drop performs one best-effort retry before
894    // profile state is finalized.
895    drop(etcd_guard);
896
897    let mut state_outcome = profile_state.finalize();
898    failures.append(&mut state_outcome.failures);
899    CleanupOutcome {
900        failures,
901        preserved_state: state_outcome.preserved_state,
902    }
903}
904
905/// Guard that stops/removes Docker etcd on drop (panic or early exit).
906/// Disarm before normal cleanup to avoid double-cleanup.
907///
908/// The guard refuses to arm if a container named `etcd` already exists, so a
909/// failed compat run never deletes a developer-owned container with that name.
910struct EtcdGuard {
911    active: bool,
912}
913
914impl EtcdGuard {
915    fn new() -> Self {
916        let inspect_status = std::process::Command::new("docker")
917            .args(["container", "inspect", "etcd"])
918            .stdout(std::process::Stdio::null())
919            .stderr(std::process::Stdio::null())
920            .status();
921        if inspect_status.is_ok_and(|status| status.success()) {
922            panic!(
923                "A Docker container named `etcd` already exists. \
924                 Remove it before running compat tests so the cleanup guard \
925                 cannot delete a container it did not create."
926            );
927        }
928        Self { active: true }
929    }
930
931    fn disarm(&mut self) {
932        self.active = false;
933    }
934}
935
936impl Drop for EtcdGuard {
937    fn drop(&mut self) {
938        if self.active {
939            println!("EtcdGuard: emergency etcd cleanup (panic or early exit)");
940            if let Err(error) = util::stop_rm_etcd_checked() {
941                println!("EtcdGuard: emergency etcd cleanup failed: {error}");
942            }
943        }
944    }
945}
946
947/// Phase of compat execution.
948#[derive(Clone, Copy, PartialEq, Eq)]
949enum CompatPhase {
950    Setup,
951    Verify,
952}
953
954/// Create an interceptor registry matching the ordinary sqlness runner.
955fn create_interceptor_registry() -> Registry {
956    let mut interceptor_registry: Registry = Default::default();
957    interceptor_registry.register(
958        protocol_interceptor::PREFIX,
959        Arc::new(protocol_interceptor::ProtocolInterceptorFactory),
960    );
961    interceptor_registry
962}
963
964/// Resolve binary directory: explicit path takes priority, then version (pulls if needed),
965/// otherwise default to current debug build.
966///
967/// Validates that `<dir>/greptime` exists after resolution and canonicalizes the path.
968async fn resolve_bins(
969    bins_dir: Option<&PathBuf>,
970    version: Option<&str>,
971    pull_version_on_need: bool,
972) -> PathBuf {
973    let dir = if let Some(dir) = bins_dir {
974        dir.clone()
975    } else if let Some(ver) = version {
976        if ver == "current" {
977            util::get_binary_dir("debug")
978        } else {
979            util::maybe_pull_binary(ver, pull_version_on_need).await;
980            let root = std::path::PathBuf::from(util::get_workspace_root());
981            std::path::PathBuf::from_iter([root, std::path::PathBuf::from(ver)])
982        }
983    } else {
984        // Default: current debug build
985        util::get_binary_dir("debug")
986    };
987
988    // Canonicalize when possible (may fail if dir doesn't exist)
989    let dir = match dir.canonicalize() {
990        Ok(canon) => canon,
991        Err(e) => panic!(
992            "Cannot resolve binary directory '{}': {e}. \
993             Use --from-bins-dir / --to-bins-dir to specify the correct path, \
994             or --from-version to pull a release.",
995            dir.display()
996        ),
997    };
998
999    if !dir.join(util::PROGRAM).is_file() {
1000        panic!(
1001            "greptime binary not found in '{}'. \
1002             Use --from-bins-dir / --to-bins-dir to specify the correct directory, \
1003             or build greptime first (e.g. `cargo build -p greptime`). \
1004             Note: if you use a custom target-dir, the binary may be elsewhere; \
1005             pass the actual directory with --from-bins-dir or --to-bins-dir.",
1006            dir.display()
1007        );
1008    }
1009
1010    dir
1011}
1012
1013/// Default case directory: `tests/compatibility/cases` relative to workspace root.
1014fn default_compat_case_dir() -> PathBuf {
1015    let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1016    // CARGO_MANIFEST_DIR is tests/runner
1017    // Pop to tests/
1018    path.pop();
1019    path.push("compatibility");
1020    path.push("cases");
1021    path
1022}
1023
1024/// Run a single compat phase (setup or verify) for one case.
1025async fn run_compat_phase(
1026    db: &crate::env::bare::GreptimeDB,
1027    case: &CompatCase,
1028    registry: &Registry,
1029    phase: CompatPhase,
1030) -> Result<(), String> {
1031    let sql_file = match phase {
1032        CompatPhase::Setup => case.dir.join("setup.sql"),
1033        CompatPhase::Verify => case.dir.join("verify.sql"),
1034    };
1035
1036    let sql_content = std::fs::read_to_string(&sql_file)
1037        .map_err(|e| format!("Failed to read {}: {e}", sql_file.display()))?;
1038
1039    let mut statements = parse_sql_file(&sql_content, registry)?;
1040
1041    // Execute statements
1042    let mut verify_output = String::new();
1043
1044    for statement in &mut statements {
1045        let (display, results) = statement.execute(db, &case.namespace).await?;
1046
1047        match phase {
1048            CompatPhase::Setup => {
1049                // Setup: just check for success (already returned Ok)
1050            }
1051            CompatPhase::Verify => {
1052                verify_output.push_str(&display);
1053                for result in results {
1054                    verify_output.push_str(&result);
1055                    verify_output.push('\n');
1056                    verify_output.push('\n');
1057                }
1058            }
1059        }
1060    }
1061
1062    if phase == CompatPhase::Verify {
1063        trim_trailing_blank_lines(&mut verify_output);
1064
1065        let result_path = case.dir.join("verify.result");
1066
1067        // If verify.result doesn't exist, generate it from actual output but
1068        // return an error so the author must review, commit, and rerun.
1069        if !result_path.is_file() {
1070            std::fs::write(&result_path, &verify_output)
1071                .map_err(|e| format!("Failed to create {}: {e}", result_path.display()))?;
1072            return Err(format!(
1073                "Created missing verify.result for case '{}'; review the generated file, commit it, and rerun",
1074                case.metadata.name
1075            ));
1076        }
1077
1078        let expected = std::fs::read_to_string(&result_path)
1079            .map_err(|e| format!("Failed to read {}: {e}", result_path.display()))?;
1080
1081        if verify_output != expected {
1082            // Update the result file with actual output to aid local update.
1083            std::fs::write(&result_path, &verify_output)
1084                .map_err(|e| format!("Failed to update {}: {e}", result_path.display()))?;
1085
1086            // Generate a simple diff
1087            let diff = simple_diff(&expected, &verify_output);
1088            return Err(format!(
1089                "Result mismatch for case '{}'.\nDiff:\n{diff}",
1090                case.metadata.name
1091            ));
1092        }
1093    }
1094
1095    Ok(())
1096}
1097
1098/// Keep generated snapshots compatible with `git diff --check` by avoiding a
1099/// trailing blank line at EOF while preserving the final newline.
1100fn trim_trailing_blank_lines(output: &mut String) {
1101    while output.ends_with("\n\n") {
1102        output.pop();
1103    }
1104}
1105
1106/// Execute the namespace prelude (CREATE DATABASE IF NOT EXISTS + USE) for a case.
1107/// This is NOT written into verify.result.
1108///
1109/// The prelude is protocol-aware:
1110/// - `CREATE DATABASE` is always sent via gRPC so it works regardless of
1111///   statement-level protocol directives.
1112/// - For Postgres-protocol statements, `SET search_path` selects the case
1113///   namespace instead of running `USE` (which is not valid PG SQL).
1114/// - For MySQL and default/gRPC statements, `USE <ns>` runs through the
1115///   statement's effective context.
1116async fn run_namespace_prelude(
1117    db: &crate::env::bare::GreptimeDB,
1118    namespace: &str,
1119    query_ctx: &QueryContext,
1120) -> Result<(), String> {
1121    // CREATE DATABASE always via gRPC — no protocol override
1122    let create_db = format!("CREATE DATABASE IF NOT EXISTS {namespace}");
1123    let default_ctx = QueryContext::default();
1124    db.compat_query(&create_db, &default_ctx).await?;
1125
1126    // Postgres: select the namespace via search_path instead of USE.
1127    if query_ctx
1128        .context
1129        .get(PROTOCOL_KEY)
1130        .is_some_and(|p| p == POSTGRES)
1131    {
1132        let set_search_path = format!("SET search_path TO '{namespace}'");
1133        db.compat_query(&set_search_path, query_ctx).await?;
1134        return Ok(());
1135    }
1136
1137    // MySQL / default (gRPC): execute USE
1138    let use_db = format!("USE {namespace}");
1139    db.compat_query(&use_db, query_ctx).await?;
1140
1141    Ok(())
1142}
1143
1144/// A parsed SQL statement with sqlness comments and interceptors.
1145struct ParsedStatement {
1146    comment_lines: Vec<String>,
1147    display_query: Vec<String>,
1148    execute_query: Vec<String>,
1149    interceptors: Vec<InterceptorRef>,
1150}
1151
1152impl ParsedStatement {
1153    fn new() -> Self {
1154        Self {
1155            comment_lines: Vec::new(),
1156            display_query: Vec::new(),
1157            execute_query: Vec::new(),
1158            interceptors: Vec::new(),
1159        }
1160    }
1161
1162    fn push_comment(&mut self, line: String) {
1163        self.comment_lines.push(line);
1164    }
1165
1166    fn push_interceptor(&mut self, line: &str, registry: &Registry) -> Result<(), String> {
1167        let Some((_, remaining)) = line.split_once(INTERCEPTOR_PREFIX) else {
1168            return Err(format!(
1169                "Missing sqlness interceptor prefix in line: {line}"
1170            ));
1171        };
1172        let interceptor = registry.create(remaining).map_err(|e| e.to_string())?;
1173        self.interceptors.push(interceptor);
1174        Ok(())
1175    }
1176
1177    fn append_query_line(&mut self, line: &str) {
1178        self.display_query.push(line.to_string());
1179        self.execute_query.push(line.to_string());
1180    }
1181
1182    fn is_empty(&self) -> bool {
1183        self.comment_lines.is_empty()
1184            && self.display_query.is_empty()
1185            && self.execute_query.is_empty()
1186            && self.interceptors.is_empty()
1187    }
1188
1189    fn has_query(&self) -> bool {
1190        !self.execute_query.is_empty()
1191    }
1192
1193    fn display_text(&self) -> String {
1194        let mut output = String::new();
1195        for comment in &self.comment_lines {
1196            output.push_str(comment);
1197            output.push('\n');
1198        }
1199        for line in &self.display_query {
1200            output.push_str(line);
1201        }
1202        output.push('\n');
1203        output.push('\n');
1204        output
1205    }
1206
1207    fn concat_query_lines(&self) -> String {
1208        self.execute_query
1209            .iter()
1210            .fold(String::new(), |query, line| query + line)
1211            .trim_start()
1212            .to_string()
1213    }
1214
1215    async fn before_execute_intercept(&mut self) -> QueryContext {
1216        let mut context = QueryContext::default();
1217        for interceptor in &self.interceptors {
1218            interceptor
1219                .before_execute_async(&mut self.execute_query, &mut context)
1220                .await;
1221        }
1222        context
1223    }
1224
1225    async fn after_execute_intercept(&self, result: &mut String) {
1226        for interceptor in &self.interceptors {
1227            interceptor.after_execute_async(result).await;
1228        }
1229    }
1230
1231    async fn execute(
1232        &mut self,
1233        db: &crate::env::bare::GreptimeDB,
1234        namespace: &str,
1235    ) -> Result<(String, Vec<String>), String> {
1236        let display = self.display_text();
1237        let context = self.before_execute_intercept().await;
1238        db.compat_prepare_query_context(&context).await;
1239        run_namespace_prelude(db, namespace, &context).await?;
1240        let sql = self.concat_query_lines();
1241        let mut results = Vec::new();
1242
1243        for sql in sql.split(TEMPLATE_DELIMITER) {
1244            if sql.trim().is_empty() {
1245                continue;
1246            }
1247            let sql = if sql.ends_with(QUERY_DELIMITER) {
1248                sql.to_string()
1249            } else {
1250                format!("{sql};")
1251            };
1252            let mut result = db.compat_query(&sql, &context).await?;
1253            self.after_execute_intercept(&mut result).await;
1254            results.push(result);
1255        }
1256
1257        Ok((display, results))
1258    }
1259}
1260
1261/// Parse a SQL file into statements using the same sqlness comment/interceptor
1262/// conventions as the ordinary runner.
1263fn parse_sql_file(content: &str, registry: &Registry) -> Result<Vec<ParsedStatement>, String> {
1264    let mut statements = Vec::new();
1265    let mut current_stmt = ParsedStatement::new();
1266
1267    for line in content.lines() {
1268        if line.starts_with(COMMENT_PREFIX) {
1269            current_stmt.push_comment(line.to_string());
1270
1271            if line.starts_with(INTERCEPTOR_PREFIX) {
1272                current_stmt.push_interceptor(line, registry)?;
1273            }
1274            continue;
1275        }
1276
1277        if line.is_empty() {
1278            continue;
1279        }
1280
1281        current_stmt.append_query_line(line);
1282
1283        // Check for statement terminator
1284        if line.ends_with(QUERY_DELIMITER) {
1285            if current_stmt.has_query() {
1286                statements.push(current_stmt);
1287            }
1288            current_stmt = ParsedStatement::new();
1289        } else {
1290            current_stmt.append_query_line("\n");
1291        }
1292    }
1293
1294    // Flush any remaining statement
1295    if !current_stmt.is_empty() && current_stmt.has_query() {
1296        statements.push(current_stmt);
1297    }
1298
1299    if statements.is_empty() {
1300        return Err("No SQL statements found in file".to_string());
1301    }
1302
1303    Ok(statements)
1304}
1305
1306/// Generate a simple line-based diff between expected and actual.
1307fn simple_diff(expected: &str, actual: &str) -> String {
1308    let mut diff = String::new();
1309    let expected_lines: Vec<&str> = expected.lines().collect();
1310    let actual_lines: Vec<&str> = actual.lines().collect();
1311    let max_len = expected_lines.len().max(actual_lines.len());
1312
1313    for i in 0..max_len {
1314        let exp = expected_lines.get(i).unwrap_or(&"(missing)");
1315        let act = actual_lines.get(i).unwrap_or(&"(missing)");
1316        if exp != act {
1317            diff.push_str(&format!("  Line {}:\n", i + 1));
1318            diff.push_str(&format!("    expected: {exp}\n"));
1319            diff.push_str(&format!("    actual:   {act}\n"));
1320        }
1321    }
1322
1323    if diff.is_empty() {
1324        diff.push_str("  (files differ but no line-level diff found — may be whitespace)\n");
1325    }
1326
1327    diff
1328}
1329
1330#[cfg(test)]
1331mod tests {
1332    use std::path::Path;
1333
1334    use super::*;
1335    use crate::cmd::compat_case::{CaseMetadata, OldConfigMetadata};
1336
1337    fn test_case(temp_dir: &Path, name: &str, overlay: Option<&str>) -> CompatCase {
1338        let case_dir = temp_dir.join(name);
1339        std::fs::create_dir_all(&case_dir).unwrap();
1340        if let Some(overlay) = overlay {
1341            std::fs::write(case_dir.join("overlay.toml"), overlay).unwrap();
1342        }
1343        CompatCase {
1344            metadata: CaseMetadata {
1345                name: name.to_string(),
1346                reason: "test".to_string(),
1347                introduced_by: "test".to_string(),
1348                topologies: vec![CompatTopology::Distributed.as_str().to_string()],
1349                from_range: vec!["*".to_string()],
1350                to_range: vec!["*".to_string()],
1351                features: vec!["table".to_string()],
1352                owner: "test".to_string(),
1353                namespace: None,
1354                old_config: overlay.map(|_| OldConfigMetadata {
1355                    datanode: PathBuf::from("overlay.toml"),
1356                }),
1357            },
1358            dir: case_dir,
1359            namespace: name.to_string(),
1360        }
1361    }
1362
1363    #[test]
1364    fn test_trim_trailing_blank_lines_preserves_single_final_newline() {
1365        let mut output = "SELECT 1;\n\n+---+\n\n".to_string();
1366        trim_trailing_blank_lines(&mut output);
1367
1368        assert_eq!(output, "SELECT 1;\n\n+---+\n");
1369    }
1370
1371    #[test]
1372    fn profiles_are_baseline_first_and_group_by_full_semantic_digest() {
1373        let temp_dir = tempfile::tempdir().unwrap();
1374        let profiles = prepare_compat_profiles(
1375            vec![
1376                test_case(temp_dir.path(), "overlay_b", Some("[x]\nb = 2\na = 1\n")),
1377                test_case(temp_dir.path(), "baseline", None),
1378                test_case(temp_dir.path(), "overlay_a", Some("[x]\na = 1\nb = 2\n")),
1379                test_case(temp_dir.path(), "overlay_c", Some("value = 3\n")),
1380            ],
1381            &WalConfig::RaftEngine,
1382        )
1383        .unwrap();
1384        let profiles: Vec<_> = profiles.iter().collect();
1385
1386        assert!(matches!(profiles[0], CompatProfile::Baseline { .. }));
1387        assert_eq!(profiles[0].case_names(), ["baseline"]);
1388        assert_eq!(profiles[1].case_names(), ["overlay_a", "overlay_b"]);
1389        assert_eq!(profiles[1].sources().len(), 2);
1390        let overlay_keys: Vec<_> = profiles[1..]
1391            .iter()
1392            .map(|profile| match profile {
1393                CompatProfile::Overlay { overlay, .. } => *overlay.profile_key(),
1394                CompatProfile::Baseline { .. } => unreachable!(),
1395            })
1396            .collect();
1397        assert!(overlay_keys.windows(2).all(|keys| keys[0] < keys[1]));
1398    }
1399
1400    #[test]
1401    fn non_fail_fast_setup_verifies_only_successful_cases() {
1402        let mut progress = ProfileProgress::default();
1403        progress.record_setup_success(0);
1404        assert!(!progress.record_setup_failure("case_b (setup): failed".to_string(), false));
1405        progress.record_setup_success(2);
1406
1407        assert!(progress.should_transition_to_current());
1408        assert_eq!(progress.successful_setup_indexes, [0, 2]);
1409        assert!(!progress.should_stop_remaining_profiles());
1410    }
1411
1412    #[test]
1413    fn fail_fast_setup_blocks_current_and_later_profiles() {
1414        let mut progress = ProfileProgress::default();
1415        progress.record_setup_success(0);
1416
1417        assert!(progress.record_setup_failure("case_b (setup): failed".to_string(), true));
1418        assert!(!progress.should_transition_to_current());
1419        assert!(progress.should_stop_remaining_profiles());
1420    }
1421
1422    #[test]
1423    fn zero_successful_setups_skips_current() {
1424        let mut progress = ProfileProgress::default();
1425        progress.record_setup_failure("case_a (setup): failed".to_string(), false);
1426
1427        assert!(!progress.should_transition_to_current());
1428        assert!(!progress.should_stop_remaining_profiles());
1429    }
1430
1431    #[test]
1432    fn verify_fail_fast_stops_later_profiles_after_cleanup() {
1433        let mut progress = ProfileProgress::default();
1434        progress.record_setup_success(0);
1435
1436        assert!(progress.record_verify_failure("case_a (verify): failed".to_string(), true));
1437        assert!(progress.should_stop_remaining_profiles());
1438    }
1439
1440    #[test]
1441    fn non_fail_fast_aggregates_failures_but_cleanup_failure_stops_profiles() {
1442        let mut progress = ProfileProgress::default();
1443        progress.record_setup_failure("case_a (setup): failed".to_string(), false);
1444        progress.record_setup_success(1);
1445        progress.record_verify_failure("case_b (verify): failed".to_string(), false);
1446
1447        assert!(!progress.should_stop_remaining_profiles());
1448        progress.record_cleanup_failure("failed to remove profile state".to_string());
1449        assert_eq!(progress.failures.len(), 3);
1450        assert!(progress.should_stop_remaining_profiles());
1451    }
1452
1453    #[test]
1454    fn protected_path_warning_is_sorted_and_value_free() {
1455        let warning = format_protected_path_warning(
1456            "abcdef123456",
1457            vec!["case_z", "case_a"],
1458            vec!["wal.provider".to_string(), "mode".to_string()],
1459        );
1460
1461        assert_eq!(
1462            warning,
1463            "Warning: datanode overlay profile abcdef123456 touches runner-owned paths [mode, wal.provider] for cases [case_a, case_z]"
1464        );
1465        assert!(!warning.contains("secret-overlay-value"));
1466    }
1467
1468    #[test]
1469    fn profile_state_guard_preserves_state_on_forced_unwind() {
1470        let temp_dir = tempfile::tempdir().unwrap();
1471        let path = temp_dir.path().to_path_buf();
1472
1473        let unwind = std::panic::catch_unwind(|| {
1474            let _state = ProfileStateGuard::new(temp_dir, true);
1475            panic!("forced profile unwind");
1476        });
1477
1478        assert!(unwind.is_err());
1479        assert!(path.is_dir());
1480        std::fs::remove_dir_all(path).unwrap();
1481    }
1482
1483    #[test]
1484    fn profile_state_guard_removes_state_on_drop_without_preservation() {
1485        let temp_dir = tempfile::tempdir().unwrap();
1486        let path = temp_dir.path().to_path_buf();
1487
1488        drop(ProfileStateGuard::new(temp_dir, false));
1489
1490        assert!(!path.exists());
1491    }
1492
1493    #[test]
1494    fn profile_state_guard_normal_finalization_transfers_ownership_once() {
1495        let temp_dir = tempfile::tempdir().unwrap();
1496        let path = temp_dir.path().to_path_buf();
1497
1498        let cleanup = ProfileStateGuard::new(temp_dir, true).finalize();
1499
1500        assert!(cleanup.failures.is_empty());
1501        assert_eq!(cleanup.preserved_state.as_deref(), Some(path.as_path()));
1502        assert!(path.is_dir());
1503        std::fs::remove_dir_all(path).unwrap();
1504    }
1505}