use std::path::PathBuf;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_file(tag: &str, contents: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos();
let dir = std::env::temp_dir().join(format!("qql-lint-it-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join(format!("{tag}.qql"));
std::fs::write(&path, contents).expect("write fixture");
path
}
fn qql(args: &[&str]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_qql"))
.args(args)
.output()
.expect("run qql")
}
#[test]
fn lint_passes_on_valid_canonical_input() {
let clean = "QUERY [0.1, 0.2] FROM docs LIMIT 5;\n";
let path = temp_file("clean", clean);
let out = qql(&["lint", path.to_str().unwrap()]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn lint_fails_on_syntax_error() {
let bad = "QUERY FROM docs LIMIT 5;\n";
let path = temp_file("bad_syntax", bad);
let out = qql(&["lint", path.to_str().unwrap()]);
assert!(!out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = format!("{stdout}\n{stderr}");
assert!(combined.contains("QQL-PARSE-"));
}
#[test]
fn lint_reports_redundant_payload_on_stderr_with_precise_span() {
let with_payload = "QUERY [0.1, 0.2] FROM docs WITH PAYLOAD true LIMIT 5;\n";
let path = temp_file("payload", with_payload);
let out = qql(&["lint", path.to_str().unwrap()]);
assert!(!out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stdout.contains("QQL-IDIOM-REDUNDANT-PAYLOAD"),
"diagnostics must not pollute stdout"
);
assert!(stderr.contains("QQL-IDIOM-REDUNDANT-PAYLOAD"));
let json_out = qql(&["lint", path.to_str().unwrap(), "--json"]);
let parsed: serde_json::Value = serde_json::from_slice(&json_out.stdout).expect("valid json");
assert_eq!(parsed["ok"], false);
let diag = &parsed["files"][0]["diagnostics"][0];
assert_eq!(diag["code"], "QQL-IDIOM-REDUNDANT-PAYLOAD");
assert_eq!(diag["span_start"], 27);
assert_eq!(diag["column"], 28);
}
#[test]
fn lint_fix_cleans_redundant_payload_and_duplicate_wait() {
let buggy = "UPSERT INTO docs VALUES {id: 1} WAIT true WAIT true;\n";
let path = temp_file("buggy", buggy);
let out = qql(&["lint", "--fix", path.to_str().unwrap()]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let fixed_content = std::fs::read_to_string(&path).unwrap();
assert!(!fixed_content.contains("WAIT true WAIT true"));
assert!(fixed_content.contains("WAIT true"));
let check = qql(&["lint", path.to_str().unwrap()]);
assert!(check.status.success());
}
#[test]
fn lint_fix_keeps_trailing_wait_on_conflict() {
let buggy = "UPSERT INTO docs VALUES {id: 1} WAIT true WAIT false;\n";
let path = temp_file("wait_conflict", buggy);
let out = qql(&["lint", "--fix", path.to_str().unwrap()]);
assert!(out.status.success());
let fixed_content = std::fs::read_to_string(&path).unwrap();
assert!(fixed_content.contains("WAIT false"));
assert!(!fixed_content.contains("WAIT true WAIT false"));
}
#[test]
fn lint_fix_never_touches_string_literals() {
let tricky = "QUERY 'WITH PAYLOAD TRUE' FROM docs LIMIT 5;\n";
let path = temp_file("str_literal", tricky);
let out = qql(&["lint", "--fix", path.to_str().unwrap()]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), tricky);
let tricky_wait = "UPSERT INTO docs VALUES {id: 1, text: 'WAIT true'} WAIT true;\n";
let path = temp_file("str_wait", tricky_wait);
let out = qql(&["lint", "--fix", path.to_str().unwrap()]);
assert!(out.status.success());
assert_eq!(std::fs::read_to_string(&path).unwrap(), tricky_wait);
}
#[test]
fn lint_missing_qql_path_is_not_misread_as_query() {
let out = qql(&["lint", "definitely-missing-file.qql"]);
assert!(!out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("no such file"), "stderr was: {stderr}");
}
#[test]
fn lint_stdin_fix_still_fails_on_syntax_errors() {
use std::io::Write;
use std::process::Stdio;
let mut child = Command::new(env!("CARGO_BIN_EXE_qql"))
.args(["lint", "--fix"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn qql");
child
.stdin
.as_mut()
.unwrap()
.write_all(b"QUERY FROM docs LIMIT 5;\n")
.unwrap();
let out = child.wait_with_output().unwrap();
assert!(!out.status.success());
}
#[test]
fn lint_binds_params_from_flag_and_header() {
let param = "QUERY :q FROM docs USING dense LIMIT 5;\n";
let path = temp_file("param", param);
let out = qql(&["lint", path.to_str().unwrap()]);
assert!(!out.status.success());
assert!(
String::from_utf8_lossy(&out.stderr).contains("QQL-BIND-MISSING-PARAM"),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let out = qql(&["lint", path.to_str().unwrap(), "-p", "q=hello"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let header = "-- qql-params: {\"q\": \"hello\"}\nQUERY :q FROM docs USING dense LIMIT 5;\n";
let path = temp_file("param_header", header);
let out = qql(&["lint", path.to_str().unwrap()]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn lint_flags_scroll_redundant_payload() {
let scroll = "SCROLL FROM docs WITH PAYLOAD true LIMIT 5;\n";
let path = temp_file("scroll_payload", scroll);
let out = qql(&["lint", path.to_str().unwrap()]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("QQL-IDIOM-REDUNDANT-PAYLOAD"));
let out = qql(&["lint", "--fix", path.to_str().unwrap()]);
assert!(out.status.success());
let fixed = std::fs::read_to_string(&path).unwrap();
assert!(!fixed.contains("PAYLOAD true"));
}
#[test]
fn lint_json_output() {
let code = "QUERY [0.1, 0.2] FROM docs LIMIT 5;\n";
let path = temp_file("json_test", code);
let out = qql(&["lint", path.to_str().unwrap(), "--json"]);
assert!(out.status.success());
let parsed: serde_json::Value = serde_json::from_slice(&out.stdout).expect("valid json");
assert_eq!(parsed["ok"], true);
assert_eq!(parsed["files"][0]["valid"], true);
assert!(parsed.get("content").is_none());
}
#[test]
fn lint_json_envelope_is_stable_across_modes() {
use std::io::Write;
use std::process::Stdio;
let mut child = Command::new(env!("CARGO_BIN_EXE_qql"))
.args(["lint", "--json"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn qql");
child
.stdin
.as_mut()
.unwrap()
.write_all(b"QUERY [0.1] FROM docs LIMIT 1;\n")
.unwrap();
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
let parsed: serde_json::Value = serde_json::from_slice(&out.stdout).expect("valid json");
assert_eq!(parsed["ok"], true);
assert_eq!(parsed["files"][0]["file"], "<stdin>");
let out = qql(&[
"lint",
"QUERY [0.1] FROM docs WITH PAYLOAD true LIMIT 1;",
"--fix",
"--json",
]);
assert!(out.status.success());
let parsed: serde_json::Value = serde_json::from_slice(&out.stdout).expect("valid json");
assert_eq!(parsed["ok"], true);
assert_eq!(parsed["files"][0]["fixed"], true);
assert!(
!parsed["content"]
.as_str()
.expect("content is text")
.contains("PAYLOAD true")
);
}
#[test]
fn lint_non_wait_duplicate_clause_is_not_marked_fixable() {
let facet_dup = "FACET category FROM docs LIMIT 5 LIMIT 10;\n";
let path = temp_file("facet_dup", facet_dup);
let out = qql(&["lint", path.to_str().unwrap(), "--json"]);
assert!(!out.status.success());
let parsed: serde_json::Value = serde_json::from_slice(&out.stdout).expect("valid json");
let diag = &parsed["files"][0]["diagnostics"][0];
assert_eq!(diag["code"], "QQL-PARSE-DUPLICATE-CLAUSE");
assert_eq!(diag["fixable"], false);
}