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        };
349
350        let env = Env::new(
351            sqlness_home.clone(),
352            ServerAddr::default(),
353            WalConfig::RaftEngine,
354            self.pull_version_on_need,
355            from_bins_dir,
356            store_config,
357            vec![],
358        );
359
360        // ---- 7c. Etcd cleanup guard ----
361        // Arm this only immediately before starting the cluster. Earlier validation
362        // failures should not stop an unrelated local container named `etcd`.
363        let mut etcd_guard = if self.setup_etcd {
364            Some(EtcdGuard::new())
365        } else {
366            None
367        };
368
369        // ---- 8. Run setup phase on old cluster ----
370        println!("Starting old-version distributed cluster with flownode...");
371        let mut db = env.compat_start_distributed(0).await;
372
373        println!("Running setup phase...");
374        for case in &cases {
375            run_compat_phase(&db, case, &interceptor_registry, CompatPhase::Setup)
376                .await
377                .unwrap_or_else(|e| panic!("Setup failed for case '{}': {e}", case.metadata.name));
378            println!("  Setup: {} - OK", case.metadata.name);
379        }
380
381        // ---- 9. Switch to "to" binary and restart cluster ----
382        // to_bins_dir was already resolved during version-range filtering
383        println!("Restarting cluster with new-version binary on preserved state...");
384        env.compat_restart_all(
385            &db,
386            to_bins_dir.expect("to_bins_dir must be resolved in non-dry-run mode"),
387        )
388        .await;
389
390        // ---- 10. Run verify phase on new cluster ----
391        println!("Running verify phase...");
392        let mut failed = Vec::new();
393        for case in &cases {
394            match run_compat_phase(&db, case, &interceptor_registry, CompatPhase::Verify).await {
395                Ok(()) => println!("  Verify: {} - PASSED", case.metadata.name),
396                Err(e) => {
397                    println!("  Verify: {} - FAILED: {e}", case.metadata.name);
398                    failed.push(case.metadata.name.clone());
399                    if self.fail_fast {
400                        break;
401                    }
402                }
403            }
404        }
405
406        // ---- 11. Stop cluster ----
407        db.compat_stop();
408
409        // ---- 12. Cleanup ----
410        // Etcd is always cleaned up; --preserve-state only preserves sqlness_home.
411        if self.setup_etcd {
412            println!("Stopping etcd");
413            util::stop_rm_etcd();
414        }
415
416        if !self.preserve_state {
417            println!("Removing state in {:?}", sqlness_home);
418            tokio::fs::remove_dir_all(sqlness_home)
419                .await
420                .unwrap_or_else(|e| println!("Warning: failed to clean up temp dir: {e}"));
421        }
422
423        // Disarm the etcd guard now that we've done normal cleanup.
424        if let Some(mut guard) = etcd_guard.take() {
425            guard.disarm();
426        }
427
428        if failed.is_empty() {
429            println!("\n\x1b[32mAll compat tests passed!\x1b[0m");
430        } else {
431            println!("\n\x1b[31mFailed cases: {}\x1b[0m", failed.join(", "));
432            // Explicitly drop the guard before exit so it doesn't double-cleanup.
433            std::process::exit(1);
434        }
435    }
436}
437
438/// Guard that stops/removes Docker etcd on drop (panic or early exit).
439/// Disarm before normal cleanup to avoid double-cleanup.
440///
441/// The guard refuses to arm if a container named `etcd` already exists, so a
442/// failed compat run never deletes a developer-owned container with that name.
443struct EtcdGuard {
444    active: bool,
445}
446
447impl EtcdGuard {
448    fn new() -> Self {
449        let inspect_status = std::process::Command::new("docker")
450            .args(["container", "inspect", "etcd"])
451            .stdout(std::process::Stdio::null())
452            .stderr(std::process::Stdio::null())
453            .status();
454        if inspect_status.is_ok_and(|status| status.success()) {
455            panic!(
456                "A Docker container named `etcd` already exists. \
457                 Remove it before running compat tests so the cleanup guard \
458                 cannot delete a container it did not create."
459            );
460        }
461        Self { active: true }
462    }
463
464    fn disarm(&mut self) {
465        self.active = false;
466    }
467}
468
469impl Drop for EtcdGuard {
470    fn drop(&mut self) {
471        if self.active {
472            println!("EtcdGuard: emergency etcd cleanup (panic or early exit)");
473            // Best-effort: don't panic in Drop
474            let _ = std::process::Command::new("docker")
475                .args(["container", "stop", "etcd"])
476                .status();
477            let _ = std::process::Command::new("docker")
478                .args(["container", "rm", "etcd"])
479                .status();
480        }
481    }
482}
483
484/// Phase of compat execution.
485#[derive(Clone, Copy, PartialEq, Eq)]
486enum CompatPhase {
487    Setup,
488    Verify,
489}
490
491/// Create an interceptor registry matching the ordinary sqlness runner.
492fn create_interceptor_registry() -> Registry {
493    let mut interceptor_registry: Registry = Default::default();
494    interceptor_registry.register(
495        protocol_interceptor::PREFIX,
496        Arc::new(protocol_interceptor::ProtocolInterceptorFactory),
497    );
498    interceptor_registry
499}
500
501/// Resolve binary directory: explicit path takes priority, then version (pulls if needed),
502/// otherwise default to current debug build.
503///
504/// Validates that `<dir>/greptime` exists after resolution and canonicalizes the path.
505async fn resolve_bins(
506    bins_dir: Option<&PathBuf>,
507    version: Option<&str>,
508    pull_version_on_need: bool,
509) -> PathBuf {
510    let dir = if let Some(dir) = bins_dir {
511        dir.clone()
512    } else if let Some(ver) = version {
513        if ver == "current" {
514            util::get_binary_dir("debug")
515        } else {
516            util::maybe_pull_binary(ver, pull_version_on_need).await;
517            let root = std::path::PathBuf::from(util::get_workspace_root());
518            std::path::PathBuf::from_iter([root, std::path::PathBuf::from(ver)])
519        }
520    } else {
521        // Default: current debug build
522        util::get_binary_dir("debug")
523    };
524
525    // Canonicalize when possible (may fail if dir doesn't exist)
526    let dir = match dir.canonicalize() {
527        Ok(canon) => canon,
528        Err(e) => panic!(
529            "Cannot resolve binary directory '{}': {e}. \
530             Use --from-bins-dir / --to-bins-dir to specify the correct path, \
531             or --from-version to pull a release.",
532            dir.display()
533        ),
534    };
535
536    if !dir.join(util::PROGRAM).is_file() {
537        panic!(
538            "greptime binary not found in '{}'. \
539             Use --from-bins-dir / --to-bins-dir to specify the correct directory, \
540             or build greptime first (e.g. `cargo build -p greptime`). \
541             Note: if you use a custom target-dir, the binary may be elsewhere; \
542             pass the actual directory with --from-bins-dir or --to-bins-dir.",
543            dir.display()
544        );
545    }
546
547    dir
548}
549
550/// Default case directory: `tests/compatibility/cases` relative to workspace root.
551fn default_compat_case_dir() -> PathBuf {
552    let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
553    // CARGO_MANIFEST_DIR is tests/runner
554    // Pop to tests/
555    path.pop();
556    path.push("compatibility");
557    path.push("cases");
558    path
559}
560
561/// Run a single compat phase (setup or verify) for one case.
562async fn run_compat_phase(
563    db: &crate::env::bare::GreptimeDB,
564    case: &CompatCase,
565    registry: &Registry,
566    phase: CompatPhase,
567) -> Result<(), String> {
568    let sql_file = match phase {
569        CompatPhase::Setup => case.dir.join("setup.sql"),
570        CompatPhase::Verify => case.dir.join("verify.sql"),
571    };
572
573    let sql_content = std::fs::read_to_string(&sql_file)
574        .map_err(|e| format!("Failed to read {}: {e}", sql_file.display()))?;
575
576    let mut statements = parse_sql_file(&sql_content, registry)?;
577
578    // Execute statements
579    let mut verify_output = String::new();
580
581    for statement in &mut statements {
582        let (display, results) = statement.execute(db, &case.namespace).await?;
583
584        match phase {
585            CompatPhase::Setup => {
586                // Setup: just check for success (already returned Ok)
587            }
588            CompatPhase::Verify => {
589                verify_output.push_str(&display);
590                for result in results {
591                    verify_output.push_str(&result);
592                    verify_output.push('\n');
593                    verify_output.push('\n');
594                }
595            }
596        }
597    }
598
599    if phase == CompatPhase::Verify {
600        trim_trailing_blank_lines(&mut verify_output);
601
602        let result_path = case.dir.join("verify.result");
603
604        // If verify.result doesn't exist, generate it from actual output but
605        // return an error so the author must review, commit, and rerun.
606        if !result_path.is_file() {
607            std::fs::write(&result_path, &verify_output)
608                .map_err(|e| format!("Failed to create {}: {e}", result_path.display()))?;
609            return Err(format!(
610                "Created missing verify.result for case '{}'; review the generated file, commit it, and rerun",
611                case.metadata.name
612            ));
613        }
614
615        let expected = std::fs::read_to_string(&result_path)
616            .map_err(|e| format!("Failed to read {}: {e}", result_path.display()))?;
617
618        if verify_output != expected {
619            // Update the result file with actual output to aid local update.
620            std::fs::write(&result_path, &verify_output)
621                .map_err(|e| format!("Failed to update {}: {e}", result_path.display()))?;
622
623            // Generate a simple diff
624            let diff = simple_diff(&expected, &verify_output);
625            return Err(format!(
626                "Result mismatch for case '{}'.\nDiff:\n{diff}",
627                case.metadata.name
628            ));
629        }
630    }
631
632    Ok(())
633}
634
635/// Keep generated snapshots compatible with `git diff --check` by avoiding a
636/// trailing blank line at EOF while preserving the final newline.
637fn trim_trailing_blank_lines(output: &mut String) {
638    while output.ends_with("\n\n") {
639        output.pop();
640    }
641}
642
643/// Execute the namespace prelude (CREATE DATABASE IF NOT EXISTS + USE) for a case.
644/// This is NOT written into verify.result.
645///
646/// The prelude is protocol-aware:
647/// - `CREATE DATABASE` is always sent via gRPC so it works regardless of
648///   statement-level protocol directives.
649/// - For Postgres-protocol statements, `SET search_path` selects the case
650///   namespace instead of running `USE` (which is not valid PG SQL).
651/// - For MySQL and default/gRPC statements, `USE <ns>` runs through the
652///   statement's effective context.
653async fn run_namespace_prelude(
654    db: &crate::env::bare::GreptimeDB,
655    namespace: &str,
656    query_ctx: &QueryContext,
657) -> Result<(), String> {
658    // CREATE DATABASE always via gRPC — no protocol override
659    let create_db = format!("CREATE DATABASE IF NOT EXISTS {namespace}");
660    let default_ctx = QueryContext::default();
661    db.compat_query(&create_db, &default_ctx).await?;
662
663    // Postgres: select the namespace via search_path instead of USE.
664    if query_ctx
665        .context
666        .get(PROTOCOL_KEY)
667        .is_some_and(|p| p == POSTGRES)
668    {
669        let set_search_path = format!("SET search_path TO '{namespace}'");
670        db.compat_query(&set_search_path, query_ctx).await?;
671        return Ok(());
672    }
673
674    // MySQL / default (gRPC): execute USE
675    let use_db = format!("USE {namespace}");
676    db.compat_query(&use_db, query_ctx).await?;
677
678    Ok(())
679}
680
681/// A parsed SQL statement with sqlness comments and interceptors.
682struct ParsedStatement {
683    comment_lines: Vec<String>,
684    display_query: Vec<String>,
685    execute_query: Vec<String>,
686    interceptors: Vec<InterceptorRef>,
687}
688
689impl ParsedStatement {
690    fn new() -> Self {
691        Self {
692            comment_lines: Vec::new(),
693            display_query: Vec::new(),
694            execute_query: Vec::new(),
695            interceptors: Vec::new(),
696        }
697    }
698
699    fn push_comment(&mut self, line: String) {
700        self.comment_lines.push(line);
701    }
702
703    fn push_interceptor(&mut self, line: &str, registry: &Registry) -> Result<(), String> {
704        let Some((_, remaining)) = line.split_once(INTERCEPTOR_PREFIX) else {
705            return Err(format!(
706                "Missing sqlness interceptor prefix in line: {line}"
707            ));
708        };
709        let interceptor = registry.create(remaining).map_err(|e| e.to_string())?;
710        self.interceptors.push(interceptor);
711        Ok(())
712    }
713
714    fn append_query_line(&mut self, line: &str) {
715        self.display_query.push(line.to_string());
716        self.execute_query.push(line.to_string());
717    }
718
719    fn is_empty(&self) -> bool {
720        self.comment_lines.is_empty()
721            && self.display_query.is_empty()
722            && self.execute_query.is_empty()
723            && self.interceptors.is_empty()
724    }
725
726    fn has_query(&self) -> bool {
727        !self.execute_query.is_empty()
728    }
729
730    fn display_text(&self) -> String {
731        let mut output = String::new();
732        for comment in &self.comment_lines {
733            output.push_str(comment);
734            output.push('\n');
735        }
736        for line in &self.display_query {
737            output.push_str(line);
738        }
739        output.push('\n');
740        output.push('\n');
741        output
742    }
743
744    fn concat_query_lines(&self) -> String {
745        self.execute_query
746            .iter()
747            .fold(String::new(), |query, line| query + line)
748            .trim_start()
749            .to_string()
750    }
751
752    async fn before_execute_intercept(&mut self) -> QueryContext {
753        let mut context = QueryContext::default();
754        for interceptor in &self.interceptors {
755            interceptor
756                .before_execute_async(&mut self.execute_query, &mut context)
757                .await;
758        }
759        context
760    }
761
762    async fn after_execute_intercept(&self, result: &mut String) {
763        for interceptor in &self.interceptors {
764            interceptor.after_execute_async(result).await;
765        }
766    }
767
768    async fn execute(
769        &mut self,
770        db: &crate::env::bare::GreptimeDB,
771        namespace: &str,
772    ) -> Result<(String, Vec<String>), String> {
773        let display = self.display_text();
774        let context = self.before_execute_intercept().await;
775        db.compat_prepare_query_context(&context).await;
776        run_namespace_prelude(db, namespace, &context).await?;
777        let sql = self.concat_query_lines();
778        let mut results = Vec::new();
779
780        for sql in sql.split(TEMPLATE_DELIMITER) {
781            if sql.trim().is_empty() {
782                continue;
783            }
784            let sql = if sql.ends_with(QUERY_DELIMITER) {
785                sql.to_string()
786            } else {
787                format!("{sql};")
788            };
789            let mut result = db.compat_query(&sql, &context).await?;
790            self.after_execute_intercept(&mut result).await;
791            results.push(result);
792        }
793
794        Ok((display, results))
795    }
796}
797
798/// Parse a SQL file into statements using the same sqlness comment/interceptor
799/// conventions as the ordinary runner.
800fn parse_sql_file(content: &str, registry: &Registry) -> Result<Vec<ParsedStatement>, String> {
801    let mut statements = Vec::new();
802    let mut current_stmt = ParsedStatement::new();
803
804    for line in content.lines() {
805        if line.starts_with(COMMENT_PREFIX) {
806            current_stmt.push_comment(line.to_string());
807
808            if line.starts_with(INTERCEPTOR_PREFIX) {
809                current_stmt.push_interceptor(line, registry)?;
810            }
811            continue;
812        }
813
814        if line.is_empty() {
815            continue;
816        }
817
818        current_stmt.append_query_line(line);
819
820        // Check for statement terminator
821        if line.ends_with(QUERY_DELIMITER) {
822            if current_stmt.has_query() {
823                statements.push(current_stmt);
824            }
825            current_stmt = ParsedStatement::new();
826        } else {
827            current_stmt.append_query_line("\n");
828        }
829    }
830
831    // Flush any remaining statement
832    if !current_stmt.is_empty() && current_stmt.has_query() {
833        statements.push(current_stmt);
834    }
835
836    if statements.is_empty() {
837        return Err("No SQL statements found in file".to_string());
838    }
839
840    Ok(statements)
841}
842
843/// Generate a simple line-based diff between expected and actual.
844fn simple_diff(expected: &str, actual: &str) -> String {
845    let mut diff = String::new();
846    let expected_lines: Vec<&str> = expected.lines().collect();
847    let actual_lines: Vec<&str> = actual.lines().collect();
848    let max_len = expected_lines.len().max(actual_lines.len());
849
850    for i in 0..max_len {
851        let exp = expected_lines.get(i).unwrap_or(&"(missing)");
852        let act = actual_lines.get(i).unwrap_or(&"(missing)");
853        if exp != act {
854            diff.push_str(&format!("  Line {}:\n", i + 1));
855            diff.push_str(&format!("    expected: {exp}\n"));
856            diff.push_str(&format!("    actual:   {act}\n"));
857        }
858    }
859
860    if diff.is_empty() {
861        diff.push_str("  (files differ but no line-level diff found — may be whitespace)\n");
862    }
863
864    diff
865}
866
867#[cfg(test)]
868mod tests {
869    use super::trim_trailing_blank_lines;
870
871    #[test]
872    fn test_trim_trailing_blank_lines_preserves_single_final_newline() {
873        let mut output = "SELECT 1;\n\n+---+\n\n".to_string();
874        trim_trailing_blank_lines(&mut output);
875
876        assert_eq!(output, "SELECT 1;\n\n+---+\n");
877    }
878}