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::path::PathBuf;
16use std::sync::Arc;
17
18use clap::Parser;
19use sqlness::QueryContext;
20use sqlness::interceptor::template::DELIMITER as TEMPLATE_DELIMITER;
21use sqlness::interceptor::{InterceptorRef, Registry};
22
23use crate::cmd::bare::ServerAddr;
24use crate::cmd::compat_case::{self, CompatCase, try_infer_version, version_matches_range};
25use crate::env::bare::{Env, StoreConfig, WalConfig};
26use crate::protocol_interceptor::{self, POSTGRES, PROTOCOL_KEY};
27use crate::util;
28
29const COMPAT_TOPOLOGY: &str = "distributed";
30const COMMENT_PREFIX: &str = "--";
31const INTERCEPTOR_PREFIX: &str = "-- SQLNESS";
32const QUERY_DELIMITER: char = ';';
33
34/// Run compatibility tests in bare distributed mode.
35///
36/// Starts an old-version distributed cluster, runs setup SQLs,
37/// then restarts the cluster with a new version on preserved state,
38/// and runs verify SQLs comparing results against `verify.result` files.
39///
40/// PR1 notes:
41/// - Sqlness interceptor comments are supported for each statement.
42/// - The runner starts the full distributed topology, including flownode.
43#[derive(Debug, Parser)]
44pub struct CompatCommand {
45    /// Version of the "from" GreptimeDB binary (e.g. "v0.9.5") or "current".
46    /// If neither --from-version nor --from-bins-dir is specified, the
47    /// current debug build is used for both from and to.
48    #[clap(long)]
49    from_version: Option<String>,
50
51    /// Path to the directory containing the "from" GreptimeDB binary.
52    #[clap(long)]
53    from_bins_dir: Option<PathBuf>,
54
55    /// Path to the directory containing the "to" GreptimeDB binary.
56    /// Defaults to the current debug build.
57    #[clap(long)]
58    to_bins_dir: Option<PathBuf>,
59
60    /// Directory of compatibility test cases.
61    /// Defaults to `tests/compatibility/cases` relative to workspace root.
62    #[clap(long)]
63    case_dir: Option<PathBuf>,
64
65    /// Name of test cases to run. Accepts a regexp.
66    #[clap(long, default_value = ".*")]
67    test_filter: String,
68
69    /// Fail this run as soon as one case fails.
70    #[clap(long, default_value = "false")]
71    fail_fast: bool,
72
73    /// Preserve persistent state in the temporary directory after run.
74    /// Etcd is always cleaned up regardless of this flag.
75    #[clap(long, default_value = "false")]
76    preserve_state: bool,
77
78    /// Pull different versions of GreptimeDB on need.
79    #[clap(long, default_value = "true")]
80    pull_version_on_need: bool,
81
82    /// Whether to set up etcd via Docker. Required for PR1 distributed compat.
83    /// External metadata stores are not supported by the compat MVP yet.
84    #[clap(long, default_value = "true")]
85    setup_etcd: bool,
86
87    /// Perform discovery and filtering only; print what would run without
88    /// starting any services, mutating files, or running setup/verify.
89    #[clap(long, default_value = "false")]
90    dry_run: bool,
91}
92
93impl CompatCommand {
94    pub async fn run(self) {
95        let dry_run = self.dry_run;
96
97        // ---- 1. Validate MVP runtime constraints ----
98        if !dry_run && !self.setup_etcd {
99            panic!(
100                "compat MVP requires Docker etcd (--setup-etcd=true); external metadata stores are not supported yet"
101            );
102        }
103
104        // ---- 2. Resolve case directory ----
105        let case_dir = self.case_dir.unwrap_or_else(default_compat_case_dir);
106
107        if !case_dir.is_dir() {
108            panic!("Case directory not found: {}", case_dir.display());
109        }
110
111        // ---- 3. Discover cases ----
112        let mut cases = compat_case::discover_cases(&case_dir).unwrap_or_else(|e| panic!("{e}"));
113
114        // Filter by test_filter
115        let filter_re = regex::Regex::new(&self.test_filter)
116            .unwrap_or_else(|e| panic!("Invalid test filter regex '{}': {e}", self.test_filter));
117        cases.retain(|c| filter_re.is_match(&c.metadata.name));
118
119        // Filter by topology
120        cases.retain(|c| c.metadata.topologies.iter().any(|t| t == COMPAT_TOPOLOGY));
121
122        if cases.is_empty() {
123            if dry_run {
124                println!(
125                    "DRY-RUN: no compat cases found matching filter '{}' and topology '{}'",
126                    self.test_filter, COMPAT_TOPOLOGY
127                );
128            } else {
129                println!(
130                    "No compat cases found matching filter '{}' and topology '{}'",
131                    self.test_filter, COMPAT_TOPOLOGY
132                );
133            }
134            return;
135        }
136
137        // ---- 3b. Validate metadata (incl. version constraints) before filtering ----
138        // Must run before version-range filtering so invalid constraints like
139        // `>=not-a-version` cause a hard error instead of silent skip.
140        compat_case::validate_cases_metadata(&cases).unwrap_or_else(|e| panic!("{e}"));
141
142        // ---- 3c. Validate namespace dedup before version filtering ----
143        // Validate globally for all selected topology/name cases so duplicated
144        // namespaces cannot hide behind version filters.
145        compat_case::validate_case_namespaces(&cases).unwrap_or_else(|e| panic!("{e}"));
146
147        // ---- 4. Resolve "from" and "to" versions ----
148        let (from_bins_dir, from_version, from_ver_parsed, to_bins_dir, to_version, to_ver_parsed) =
149            if dry_run {
150                // Dry-run: resolve versions without panicking on missing binaries.
151                // try_infer_version returns None gracefully when the binary is absent.
152                let dry_run_from_bins_dir = self
153                    .from_bins_dir
154                    .clone()
155                    .unwrap_or_else(|| util::get_binary_dir("debug"));
156                let from_ver_str = self
157                    .from_version
158                    .as_deref()
159                    .and_then(|v| {
160                        if v == "current" {
161                            None
162                        } else {
163                            Some(v.to_string())
164                        }
165                    })
166                    .or_else(|| try_infer_version(&dry_run_from_bins_dir).map(|v| v.to_string()));
167                let from_ver_parsed = from_ver_str
168                    .as_deref()
169                    .and_then(|s| compat_case::Version::parse(s).ok());
170
171                let dry_run_to_bins_dir = self
172                    .to_bins_dir
173                    .clone()
174                    .unwrap_or_else(|| util::get_binary_dir("debug"));
175                let to_ver_str = try_infer_version(&dry_run_to_bins_dir).map(|v| v.to_string());
176                let to_ver_parsed = to_ver_str
177                    .as_deref()
178                    .and_then(|s| compat_case::Version::parse(s).ok());
179
180                (
181                    Some(dry_run_from_bins_dir),
182                    from_ver_str,
183                    from_ver_parsed,
184                    Some(dry_run_to_bins_dir),
185                    to_ver_str,
186                    to_ver_parsed,
187                )
188            } else {
189                // Normal path: resolve bins (may panic if binary not found).
190                let from_bins_dir = resolve_bins(
191                    self.from_bins_dir.as_ref(),
192                    self.from_version.as_deref(),
193                    self.pull_version_on_need,
194                )
195                .await;
196
197                let from_version = if let Some(ref ver) = self.from_version {
198                    if ver != "current" {
199                        Some(ver.clone())
200                    } else {
201                        try_infer_version(&from_bins_dir).map(|v| v.to_string())
202                    }
203                } else {
204                    try_infer_version(&from_bins_dir).map(|v| v.to_string())
205                };
206
207                let from_ver_parsed = from_version
208                    .as_deref()
209                    .and_then(|s| compat_case::Version::parse(s).ok());
210
211                let to_bins_dir =
212                    resolve_bins(self.to_bins_dir.as_ref(), None, self.pull_version_on_need).await;
213                let to_version = try_infer_version(&to_bins_dir).map(|v| v.to_string());
214                let to_ver_parsed = to_version
215                    .as_deref()
216                    .and_then(|s| compat_case::Version::parse(s).ok());
217
218                (
219                    Some(from_bins_dir),
220                    from_version,
221                    from_ver_parsed,
222                    Some(to_bins_dir),
223                    to_version,
224                    to_ver_parsed,
225                )
226            };
227
228        // ---- 5b. Filter by version range ----
229        let pre_filter_count = cases.len();
230        cases.retain(|c| {
231            let from_ok = version_matches_range(from_ver_parsed.as_ref(), &c.metadata.from_range);
232            if !from_ok {
233                let from_label = from_ver_parsed
234                    .as_ref()
235                    .map(|v| v.to_string())
236                    .unwrap_or_else(|| "unknown".to_string());
237                println!(
238                    "Skipping case '{}': from_range {:?} does not match version '{}'",
239                    c.metadata.name, c.metadata.from_range, from_label
240                );
241            }
242            from_ok
243        });
244        cases.retain(|c| {
245            let to_ok = version_matches_range(to_ver_parsed.as_ref(), &c.metadata.to_range);
246            if !to_ok {
247                let to_label = to_ver_parsed
248                    .as_ref()
249                    .map(|v| v.to_string())
250                    .unwrap_or_else(|| "unknown".to_string());
251                println!(
252                    "Skipping case '{}': to_range {:?} does not match version '{}'",
253                    c.metadata.name, c.metadata.to_range, to_label
254                );
255            }
256            to_ok
257        });
258
259        if pre_filter_count != cases.len() {
260            println!(
261                "Version-range filtering: {} → {} cases",
262                pre_filter_count,
263                cases.len()
264            );
265        }
266
267        if cases.is_empty() {
268            if dry_run {
269                println!("DRY-RUN: no compat cases would run after version-range filtering");
270            } else {
271                println!("No compat cases remaining after version-range filtering");
272            }
273            return;
274        }
275
276        if dry_run {
277            println!("DRY-RUN: would run {} compat case(s)", cases.len());
278            println!("  topology:     {}", COMPAT_TOPOLOGY);
279            println!(
280                "  from version: {}",
281                from_version.as_deref().unwrap_or(
282                    "unknown (use --from-version, --from-bins-dir, or build debug binary)"
283                )
284            );
285            println!(
286                "  to version:   {}",
287                to_version
288                    .as_deref()
289                    .unwrap_or("unknown (use --to-bins-dir or build debug binary)")
290            );
291            if pre_filter_count != cases.len() {
292                println!();
293                println!(
294                    "Version-range filtering reduced {} → {} cases (see 'Skipping case' messages above)",
295                    pre_filter_count,
296                    cases.len()
297                );
298            }
299            println!();
300            for c in &cases {
301                println!("  case:        {}", c.metadata.name);
302                println!("    namespace:   {}", c.namespace);
303                println!("    from_range:  {:?}", c.metadata.from_range);
304                println!("    to_range:    {:?}", c.metadata.to_range);
305                println!("    features:    {:?}", c.metadata.features);
306            }
307            println!();
308            println!("Dry run complete. Remove --dry-run to execute.");
309            return;
310        }
311
312        println!(
313            "Running {} compat case(s) with topology {}:",
314            cases.len(),
315            COMPAT_TOPOLOGY
316        );
317        for c in &cases {
318            println!(
319                "  - {} (namespace: {}, topologies: {:?})",
320                c.metadata.name, c.namespace, c.metadata.topologies
321            );
322        }
323
324        // ---- 6. Create temp directory (after filtering so early exits don't leave empty dirs) ----
325        let temp_dir = tempfile::Builder::new()
326            .prefix("sqlness-compat")
327            .tempdir()
328            .unwrap();
329        let sqlness_home = temp_dir.keep();
330        unsafe {
331            std::env::set_var("SQLNESS_HOME", sqlness_home.display().to_string());
332        }
333
334        // ---- 7. Build interceptor registry ----
335        let interceptor_registry = create_interceptor_registry();
336
337        // ---- 7b. Create Env for bare distributed mode ----
338        let store_config = StoreConfig {
339            store_addrs: if self.setup_etcd {
340                vec!["127.0.0.1:2379".to_string()]
341            } else {
342                vec![]
343            },
344            setup_etcd: self.setup_etcd,
345            setup_pg: None,
346            setup_mysql: None,
347            enable_flat_format: false,
348            enable_gc: false,
349        };
350
351        let env = Env::new(
352            sqlness_home.clone(),
353            ServerAddr::default(),
354            WalConfig::RaftEngine,
355            self.pull_version_on_need,
356            from_bins_dir,
357            store_config,
358            vec![],
359        );
360
361        // ---- 7c. Etcd cleanup guard ----
362        // Arm this only immediately before starting the cluster. Earlier validation
363        // failures should not stop an unrelated local container named `etcd`.
364        let mut etcd_guard = if self.setup_etcd {
365            Some(EtcdGuard::new())
366        } else {
367            None
368        };
369
370        // ---- 8. Run setup phase on old cluster ----
371        println!("Starting old-version distributed cluster with flownode...");
372        let mut db = env.compat_start_distributed(0).await;
373
374        println!("Running setup phase...");
375        for case in &cases {
376            run_compat_phase(&db, case, &interceptor_registry, CompatPhase::Setup)
377                .await
378                .unwrap_or_else(|e| panic!("Setup failed for case '{}': {e}", case.metadata.name));
379            println!("  Setup: {} - OK", case.metadata.name);
380        }
381
382        // ---- 9. Switch to "to" binary and restart cluster ----
383        // to_bins_dir was already resolved during version-range filtering
384        println!("Restarting cluster with new-version binary on preserved state...");
385        env.compat_restart_all(
386            &db,
387            to_bins_dir.expect("to_bins_dir must be resolved in non-dry-run mode"),
388        )
389        .await;
390
391        // ---- 10. Run verify phase on new cluster ----
392        println!("Running verify phase...");
393        let mut failed = Vec::new();
394        for case in &cases {
395            match run_compat_phase(&db, case, &interceptor_registry, CompatPhase::Verify).await {
396                Ok(()) => println!("  Verify: {} - PASSED", case.metadata.name),
397                Err(e) => {
398                    println!("  Verify: {} - FAILED: {e}", case.metadata.name);
399                    failed.push(case.metadata.name.clone());
400                    if self.fail_fast {
401                        break;
402                    }
403                }
404            }
405        }
406
407        // ---- 11. Stop cluster ----
408        db.compat_stop();
409
410        // ---- 12. Cleanup ----
411        // Etcd is always cleaned up; --preserve-state only preserves sqlness_home.
412        if self.setup_etcd {
413            println!("Stopping etcd");
414            util::stop_rm_etcd();
415        }
416
417        if !self.preserve_state {
418            println!("Removing state in {:?}", sqlness_home);
419            tokio::fs::remove_dir_all(sqlness_home)
420                .await
421                .unwrap_or_else(|e| println!("Warning: failed to clean up temp dir: {e}"));
422        }
423
424        // Disarm the etcd guard now that we've done normal cleanup.
425        if let Some(mut guard) = etcd_guard.take() {
426            guard.disarm();
427        }
428
429        if failed.is_empty() {
430            println!("\n\x1b[32mAll compat tests passed!\x1b[0m");
431        } else {
432            println!("\n\x1b[31mFailed cases: {}\x1b[0m", failed.join(", "));
433            // Explicitly drop the guard before exit so it doesn't double-cleanup.
434            std::process::exit(1);
435        }
436    }
437}
438
439/// Guard that stops/removes Docker etcd on drop (panic or early exit).
440/// Disarm before normal cleanup to avoid double-cleanup.
441///
442/// The guard refuses to arm if a container named `etcd` already exists, so a
443/// failed compat run never deletes a developer-owned container with that name.
444struct EtcdGuard {
445    active: bool,
446}
447
448impl EtcdGuard {
449    fn new() -> Self {
450        let inspect_status = std::process::Command::new("docker")
451            .args(["container", "inspect", "etcd"])
452            .stdout(std::process::Stdio::null())
453            .stderr(std::process::Stdio::null())
454            .status();
455        if inspect_status.is_ok_and(|status| status.success()) {
456            panic!(
457                "A Docker container named `etcd` already exists. \
458                 Remove it before running compat tests so the cleanup guard \
459                 cannot delete a container it did not create."
460            );
461        }
462        Self { active: true }
463    }
464
465    fn disarm(&mut self) {
466        self.active = false;
467    }
468}
469
470impl Drop for EtcdGuard {
471    fn drop(&mut self) {
472        if self.active {
473            println!("EtcdGuard: emergency etcd cleanup (panic or early exit)");
474            // Best-effort: don't panic in Drop
475            let _ = std::process::Command::new("docker")
476                .args(["container", "stop", "etcd"])
477                .status();
478            let _ = std::process::Command::new("docker")
479                .args(["container", "rm", "etcd"])
480                .status();
481        }
482    }
483}
484
485/// Phase of compat execution.
486#[derive(Clone, Copy, PartialEq, Eq)]
487enum CompatPhase {
488    Setup,
489    Verify,
490}
491
492/// Create an interceptor registry matching the ordinary sqlness runner.
493fn create_interceptor_registry() -> Registry {
494    let mut interceptor_registry: Registry = Default::default();
495    interceptor_registry.register(
496        protocol_interceptor::PREFIX,
497        Arc::new(protocol_interceptor::ProtocolInterceptorFactory),
498    );
499    interceptor_registry
500}
501
502/// Resolve binary directory: explicit path takes priority, then version (pulls if needed),
503/// otherwise default to current debug build.
504///
505/// Validates that `<dir>/greptime` exists after resolution and canonicalizes the path.
506async fn resolve_bins(
507    bins_dir: Option<&PathBuf>,
508    version: Option<&str>,
509    pull_version_on_need: bool,
510) -> PathBuf {
511    let dir = if let Some(dir) = bins_dir {
512        dir.clone()
513    } else if let Some(ver) = version {
514        if ver == "current" {
515            util::get_binary_dir("debug")
516        } else {
517            util::maybe_pull_binary(ver, pull_version_on_need).await;
518            let root = std::path::PathBuf::from(util::get_workspace_root());
519            std::path::PathBuf::from_iter([root, std::path::PathBuf::from(ver)])
520        }
521    } else {
522        // Default: current debug build
523        util::get_binary_dir("debug")
524    };
525
526    // Canonicalize when possible (may fail if dir doesn't exist)
527    let dir = match dir.canonicalize() {
528        Ok(canon) => canon,
529        Err(e) => panic!(
530            "Cannot resolve binary directory '{}': {e}. \
531             Use --from-bins-dir / --to-bins-dir to specify the correct path, \
532             or --from-version to pull a release.",
533            dir.display()
534        ),
535    };
536
537    if !dir.join(util::PROGRAM).is_file() {
538        panic!(
539            "greptime binary not found in '{}'. \
540             Use --from-bins-dir / --to-bins-dir to specify the correct directory, \
541             or build greptime first (e.g. `cargo build -p greptime`). \
542             Note: if you use a custom target-dir, the binary may be elsewhere; \
543             pass the actual directory with --from-bins-dir or --to-bins-dir.",
544            dir.display()
545        );
546    }
547
548    dir
549}
550
551/// Default case directory: `tests/compatibility/cases` relative to workspace root.
552fn default_compat_case_dir() -> PathBuf {
553    let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
554    // CARGO_MANIFEST_DIR is tests/runner
555    // Pop to tests/
556    path.pop();
557    path.push("compatibility");
558    path.push("cases");
559    path
560}
561
562/// Run a single compat phase (setup or verify) for one case.
563async fn run_compat_phase(
564    db: &crate::env::bare::GreptimeDB,
565    case: &CompatCase,
566    registry: &Registry,
567    phase: CompatPhase,
568) -> Result<(), String> {
569    let sql_file = match phase {
570        CompatPhase::Setup => case.dir.join("setup.sql"),
571        CompatPhase::Verify => case.dir.join("verify.sql"),
572    };
573
574    let sql_content = std::fs::read_to_string(&sql_file)
575        .map_err(|e| format!("Failed to read {}: {e}", sql_file.display()))?;
576
577    let mut statements = parse_sql_file(&sql_content, registry)?;
578
579    // Execute statements
580    let mut verify_output = String::new();
581
582    for statement in &mut statements {
583        let (display, results) = statement.execute(db, &case.namespace).await?;
584
585        match phase {
586            CompatPhase::Setup => {
587                // Setup: just check for success (already returned Ok)
588            }
589            CompatPhase::Verify => {
590                verify_output.push_str(&display);
591                for result in results {
592                    verify_output.push_str(&result);
593                    verify_output.push('\n');
594                    verify_output.push('\n');
595                }
596            }
597        }
598    }
599
600    if phase == CompatPhase::Verify {
601        trim_trailing_blank_lines(&mut verify_output);
602
603        let result_path = case.dir.join("verify.result");
604
605        // If verify.result doesn't exist, generate it from actual output but
606        // return an error so the author must review, commit, and rerun.
607        if !result_path.is_file() {
608            std::fs::write(&result_path, &verify_output)
609                .map_err(|e| format!("Failed to create {}: {e}", result_path.display()))?;
610            return Err(format!(
611                "Created missing verify.result for case '{}'; review the generated file, commit it, and rerun",
612                case.metadata.name
613            ));
614        }
615
616        let expected = std::fs::read_to_string(&result_path)
617            .map_err(|e| format!("Failed to read {}: {e}", result_path.display()))?;
618
619        if verify_output != expected {
620            // Update the result file with actual output to aid local update.
621            std::fs::write(&result_path, &verify_output)
622                .map_err(|e| format!("Failed to update {}: {e}", result_path.display()))?;
623
624            // Generate a simple diff
625            let diff = simple_diff(&expected, &verify_output);
626            return Err(format!(
627                "Result mismatch for case '{}'.\nDiff:\n{diff}",
628                case.metadata.name
629            ));
630        }
631    }
632
633    Ok(())
634}
635
636/// Keep generated snapshots compatible with `git diff --check` by avoiding a
637/// trailing blank line at EOF while preserving the final newline.
638fn trim_trailing_blank_lines(output: &mut String) {
639    while output.ends_with("\n\n") {
640        output.pop();
641    }
642}
643
644/// Execute the namespace prelude (CREATE DATABASE IF NOT EXISTS + USE) for a case.
645/// This is NOT written into verify.result.
646///
647/// The prelude is protocol-aware:
648/// - `CREATE DATABASE` is always sent via gRPC so it works regardless of
649///   statement-level protocol directives.
650/// - For Postgres-protocol statements, `SET search_path` selects the case
651///   namespace instead of running `USE` (which is not valid PG SQL).
652/// - For MySQL and default/gRPC statements, `USE <ns>` runs through the
653///   statement's effective context.
654async fn run_namespace_prelude(
655    db: &crate::env::bare::GreptimeDB,
656    namespace: &str,
657    query_ctx: &QueryContext,
658) -> Result<(), String> {
659    // CREATE DATABASE always via gRPC — no protocol override
660    let create_db = format!("CREATE DATABASE IF NOT EXISTS {namespace}");
661    let default_ctx = QueryContext::default();
662    db.compat_query(&create_db, &default_ctx).await?;
663
664    // Postgres: select the namespace via search_path instead of USE.
665    if query_ctx
666        .context
667        .get(PROTOCOL_KEY)
668        .is_some_and(|p| p == POSTGRES)
669    {
670        let set_search_path = format!("SET search_path TO '{namespace}'");
671        db.compat_query(&set_search_path, query_ctx).await?;
672        return Ok(());
673    }
674
675    // MySQL / default (gRPC): execute USE
676    let use_db = format!("USE {namespace}");
677    db.compat_query(&use_db, query_ctx).await?;
678
679    Ok(())
680}
681
682/// A parsed SQL statement with sqlness comments and interceptors.
683struct ParsedStatement {
684    comment_lines: Vec<String>,
685    display_query: Vec<String>,
686    execute_query: Vec<String>,
687    interceptors: Vec<InterceptorRef>,
688}
689
690impl ParsedStatement {
691    fn new() -> Self {
692        Self {
693            comment_lines: Vec::new(),
694            display_query: Vec::new(),
695            execute_query: Vec::new(),
696            interceptors: Vec::new(),
697        }
698    }
699
700    fn push_comment(&mut self, line: String) {
701        self.comment_lines.push(line);
702    }
703
704    fn push_interceptor(&mut self, line: &str, registry: &Registry) -> Result<(), String> {
705        let Some((_, remaining)) = line.split_once(INTERCEPTOR_PREFIX) else {
706            return Err(format!(
707                "Missing sqlness interceptor prefix in line: {line}"
708            ));
709        };
710        let interceptor = registry.create(remaining).map_err(|e| e.to_string())?;
711        self.interceptors.push(interceptor);
712        Ok(())
713    }
714
715    fn append_query_line(&mut self, line: &str) {
716        self.display_query.push(line.to_string());
717        self.execute_query.push(line.to_string());
718    }
719
720    fn is_empty(&self) -> bool {
721        self.comment_lines.is_empty()
722            && self.display_query.is_empty()
723            && self.execute_query.is_empty()
724            && self.interceptors.is_empty()
725    }
726
727    fn has_query(&self) -> bool {
728        !self.execute_query.is_empty()
729    }
730
731    fn display_text(&self) -> String {
732        let mut output = String::new();
733        for comment in &self.comment_lines {
734            output.push_str(comment);
735            output.push('\n');
736        }
737        for line in &self.display_query {
738            output.push_str(line);
739        }
740        output.push('\n');
741        output.push('\n');
742        output
743    }
744
745    fn concat_query_lines(&self) -> String {
746        self.execute_query
747            .iter()
748            .fold(String::new(), |query, line| query + line)
749            .trim_start()
750            .to_string()
751    }
752
753    async fn before_execute_intercept(&mut self) -> QueryContext {
754        let mut context = QueryContext::default();
755        for interceptor in &self.interceptors {
756            interceptor
757                .before_execute_async(&mut self.execute_query, &mut context)
758                .await;
759        }
760        context
761    }
762
763    async fn after_execute_intercept(&self, result: &mut String) {
764        for interceptor in &self.interceptors {
765            interceptor.after_execute_async(result).await;
766        }
767    }
768
769    async fn execute(
770        &mut self,
771        db: &crate::env::bare::GreptimeDB,
772        namespace: &str,
773    ) -> Result<(String, Vec<String>), String> {
774        let display = self.display_text();
775        let context = self.before_execute_intercept().await;
776        db.compat_prepare_query_context(&context).await;
777        run_namespace_prelude(db, namespace, &context).await?;
778        let sql = self.concat_query_lines();
779        let mut results = Vec::new();
780
781        for sql in sql.split(TEMPLATE_DELIMITER) {
782            if sql.trim().is_empty() {
783                continue;
784            }
785            let sql = if sql.ends_with(QUERY_DELIMITER) {
786                sql.to_string()
787            } else {
788                format!("{sql};")
789            };
790            let mut result = db.compat_query(&sql, &context).await?;
791            self.after_execute_intercept(&mut result).await;
792            results.push(result);
793        }
794
795        Ok((display, results))
796    }
797}
798
799/// Parse a SQL file into statements using the same sqlness comment/interceptor
800/// conventions as the ordinary runner.
801fn parse_sql_file(content: &str, registry: &Registry) -> Result<Vec<ParsedStatement>, String> {
802    let mut statements = Vec::new();
803    let mut current_stmt = ParsedStatement::new();
804
805    for line in content.lines() {
806        if line.starts_with(COMMENT_PREFIX) {
807            current_stmt.push_comment(line.to_string());
808
809            if line.starts_with(INTERCEPTOR_PREFIX) {
810                current_stmt.push_interceptor(line, registry)?;
811            }
812            continue;
813        }
814
815        if line.is_empty() {
816            continue;
817        }
818
819        current_stmt.append_query_line(line);
820
821        // Check for statement terminator
822        if line.ends_with(QUERY_DELIMITER) {
823            if current_stmt.has_query() {
824                statements.push(current_stmt);
825            }
826            current_stmt = ParsedStatement::new();
827        } else {
828            current_stmt.append_query_line("\n");
829        }
830    }
831
832    // Flush any remaining statement
833    if !current_stmt.is_empty() && current_stmt.has_query() {
834        statements.push(current_stmt);
835    }
836
837    if statements.is_empty() {
838        return Err("No SQL statements found in file".to_string());
839    }
840
841    Ok(statements)
842}
843
844/// Generate a simple line-based diff between expected and actual.
845fn simple_diff(expected: &str, actual: &str) -> String {
846    let mut diff = String::new();
847    let expected_lines: Vec<&str> = expected.lines().collect();
848    let actual_lines: Vec<&str> = actual.lines().collect();
849    let max_len = expected_lines.len().max(actual_lines.len());
850
851    for i in 0..max_len {
852        let exp = expected_lines.get(i).unwrap_or(&"(missing)");
853        let act = actual_lines.get(i).unwrap_or(&"(missing)");
854        if exp != act {
855            diff.push_str(&format!("  Line {}:\n", i + 1));
856            diff.push_str(&format!("    expected: {exp}\n"));
857            diff.push_str(&format!("    actual:   {act}\n"));
858        }
859    }
860
861    if diff.is_empty() {
862        diff.push_str("  (files differ but no line-level diff found — may be whitespace)\n");
863    }
864
865    diff
866}
867
868#[cfg(test)]
869mod tests {
870    use super::trim_trailing_blank_lines;
871
872    #[test]
873    fn test_trim_trailing_blank_lines_preserves_single_final_newline() {
874        let mut output = "SELECT 1;\n\n+---+\n\n".to_string();
875        trim_trailing_blank_lines(&mut output);
876
877        assert_eq!(output, "SELECT 1;\n\n+---+\n");
878    }
879}