Files
dbx/src-tauri/src/commands/external_sql.rs
T

697 lines
25 KiB
Rust

use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::UNIX_EPOCH;
use dbx_core::sql::{decode_sql_file_bytes, decode_sql_file_bytes_with_encoding, encode_sql_file_text};
use serde::Serialize;
use sha2::{Digest, Sha256};
use tokio::io::AsyncReadExt;
const MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES: u64 = 64 * 1024 * 1024;
const MIN_EXTERNAL_SQL_EDITOR_FILE_BYTES: u64 = 1024 * 1024;
const MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES_LIMIT: u64 = 4096 * 1024 * 1024;
fn clamp_external_sql_editor_limit(max_size_bytes: Option<u64>) -> u64 {
match max_size_bytes {
Some(value) => value.clamp(MIN_EXTERNAL_SQL_EDITOR_FILE_BYTES, MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES_LIMIT),
None => MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES,
}
}
fn exceeds_external_sql_editor_limit(size_bytes: u64, max_size_bytes: u64) -> bool {
size_bytes > max_size_bytes
}
#[derive(Debug, Serialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum ExternalSqlFileReadResult {
Content { content: String, version: ExternalSqlFileVersion, encoding: String },
TooLarge { size_bytes: u64, max_size_bytes: u64 },
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ExternalSqlFileVersion {
pub size_bytes: u64,
pub modified_ns: String,
pub content_hash: String,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum ExternalSqlFileStatus {
Present { size_bytes: u64, modified_ns: String },
Missing,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum ExternalSqlFileWriteResult {
Written { version: ExternalSqlFileVersion },
Conflict { current_version: ExternalSqlFileVersion },
Missing,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ExternalSqlFileSaveResult {
pub path: String,
pub version: ExternalSqlFileVersion,
}
use tauri_plugin_dialog::DialogExt;
#[tauri::command]
pub fn pending_open_sql_files(state: tauri::State<'_, ExternalSqlOpenState>) -> Vec<String> {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let mut paths = sql_file_paths_from_args(std::env::args().skip(1), &cwd);
paths.extend(state.drain());
dedupe_paths(paths)
}
#[tauri::command]
pub async fn read_external_sql_file(
path: String,
max_size_bytes: Option<u64>,
encoding: Option<String>,
) -> Result<ExternalSqlFileReadResult, String> {
read_external_sql_file_content_async(
PathBuf::from(path),
clamp_external_sql_editor_limit(max_size_bytes),
encoding.as_deref(),
)
.await
}
#[tauri::command]
pub async fn inspect_external_sql_file(path: String) -> Result<ExternalSqlFileStatus, String> {
inspect_external_sql_file_async(PathBuf::from(path)).await
}
#[tauri::command]
pub async fn write_external_sql_file(
path: String,
content: String,
expected_content_hash: Option<String>,
expected_missing: bool,
encoding: Option<String>,
) -> Result<ExternalSqlFileWriteResult, String> {
write_external_sql_file_checked_async(
PathBuf::from(path),
content,
expected_content_hash,
expected_missing,
false,
encoding.as_deref().unwrap_or("utf8"),
)
.await
}
#[tauri::command]
pub async fn save_external_sql_file(
window: tauri::Window,
default_file_name: String,
content: String,
filter_extension: Option<String>,
encoding: Option<String>,
) -> Result<Option<ExternalSqlFileSaveResult>, String> {
let (sender, receiver) = tokio::sync::oneshot::channel();
// Non-SQL external tabs (custom-filtered text files) keep their own
// extension when saving a copy; without a usable extension leave the
// dialog unfiltered instead of forcing the SQL filter.
let dialog = window.dialog().file().set_file_name(default_file_name);
let dialog = match filter_extension.as_deref().map(str::trim).filter(|extension| !extension.is_empty()) {
Some("sql") => dialog.add_filter("SQL", &["sql"]),
Some(extension) => dialog.add_filter(extension.to_uppercase(), &[extension]),
None => dialog,
};
dialog.save_file(move |file_path| {
let _ = sender.send(file_path);
});
let path = receiver
.await
.map_err(|_| "SQL save dialog closed unexpectedly".to_string())?
.map(|file_path| file_path.into_path().map_err(|error| format!("Failed to resolve SQL file path: {error}")))
.transpose()?;
// Keep the native dialog result as a PathBuf until after the write so
// Windows Unicode paths do not cross an extra frontend IPC boundary.
save_external_sql_file_content_async(path, content, encoding.as_deref().unwrap_or("utf8")).await
}
#[derive(Default)]
pub struct ExternalSqlOpenState {
pending: Mutex<Vec<String>>,
}
impl ExternalSqlOpenState {
pub fn push(&self, paths: Vec<String>) {
if paths.is_empty() {
return;
}
if let Ok(mut pending) = self.pending.lock() {
pending.extend(paths);
}
}
fn drain(&self) -> Vec<String> {
self.pending.lock().map(|mut pending| pending.drain(..).collect()).unwrap_or_default()
}
}
pub fn sql_file_paths_from_args<I, S>(args: I, cwd: &Path) -> Vec<String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
args.into_iter().filter_map(|arg| sql_file_path_from_arg(arg.as_ref(), cwd)).collect()
}
fn sql_file_path_from_arg(arg: &str, cwd: &Path) -> Option<String> {
if arg.starts_with('-') {
return None;
}
let path = PathBuf::from(super::launch_args::normalize_launch_path_arg(arg)?);
if !is_sql_file_path(&path) {
return None;
}
let resolved = if path.is_absolute() { path } else { cwd.join(path) };
Some(resolved.to_string_lossy().to_string())
}
pub fn is_sql_file_path(path: &Path) -> bool {
path.extension().and_then(|ext| ext.to_str()).map(|ext| ext.eq_ignore_ascii_case("sql")).unwrap_or(false)
}
fn detected_encoding(bytes: &[u8]) -> &'static str {
if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
"utf8Bom"
} else if bytes.starts_with(&[0xFF, 0xFE]) {
"utf16le"
} else if bytes.starts_with(&[0xFE, 0xFF]) {
"utf16be"
} else if std::str::from_utf8(bytes).is_ok() {
"utf8"
} else {
"gbk"
}
}
fn modified_ns(metadata: &std::fs::Metadata) -> String {
metadata
.modified()
.ok()
.and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_nanos())
.unwrap_or_default()
.to_string()
}
fn content_hash(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
fn external_sql_file_version(metadata: &std::fs::Metadata, bytes: &[u8]) -> ExternalSqlFileVersion {
ExternalSqlFileVersion {
size_bytes: metadata.len(),
modified_ns: modified_ns(metadata),
content_hash: content_hash(bytes),
}
}
#[cfg(test)]
fn read_external_sql_file_content(path: &Path) -> Result<ExternalSqlFileReadResult, String> {
read_external_sql_file_content_with_limit(path, MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES)
}
#[cfg(test)]
fn read_external_sql_file_content_with_limit(
path: &Path,
max_size_bytes: u64,
) -> Result<ExternalSqlFileReadResult, String> {
let metadata = std::fs::metadata(path).map_err(|e| format!("Failed to inspect SQL file: {e}"))?;
if !metadata.is_file() {
return Err("Only files can be opened this way".to_string());
}
if exceeds_external_sql_editor_limit(metadata.len(), max_size_bytes) {
return Ok(ExternalSqlFileReadResult::TooLarge { size_bytes: metadata.len(), max_size_bytes });
}
let bytes = std::fs::read(path).map_err(|e| format!("Failed to read SQL file: {e}"))?;
let version = external_sql_file_version(&metadata, &bytes);
decode_sql_file_bytes(&bytes).map(|content| ExternalSqlFileReadResult::Content {
content,
version,
encoding: detected_encoding(&bytes).to_string(),
})
}
async fn read_external_sql_file_content_async(
path: PathBuf,
max_size_bytes: u64,
encoding: Option<&str>,
) -> Result<ExternalSqlFileReadResult, String> {
let file = tokio::fs::File::open(&path).await.map_err(|e| format!("Failed to read SQL file: {e}"))?;
let metadata = file.metadata().await.map_err(|e| format!("Failed to inspect SQL file: {e}"))?;
if !metadata.is_file() {
return Err("Only files can be opened this way".to_string());
}
if exceeds_external_sql_editor_limit(metadata.len(), max_size_bytes) {
return Ok(ExternalSqlFileReadResult::TooLarge { size_bytes: metadata.len(), max_size_bytes });
}
let mut bytes = Vec::with_capacity(metadata.len() as usize);
file.take(max_size_bytes + 1).read_to_end(&mut bytes).await.map_err(|e| format!("Failed to read SQL file: {e}"))?;
if exceeds_external_sql_editor_limit(bytes.len() as u64, max_size_bytes) {
return Ok(ExternalSqlFileReadResult::TooLarge { size_bytes: bytes.len() as u64, max_size_bytes });
}
let version = external_sql_file_version(&metadata, &bytes);
let content = match encoding.filter(|value| *value != "auto") {
Some(value) => decode_sql_file_bytes_with_encoding(&bytes, value),
None => decode_sql_file_bytes(&bytes),
}?;
Ok(ExternalSqlFileReadResult::Content {
content,
version,
encoding: encoding.filter(|v| *v != "auto").unwrap_or(detected_encoding(&bytes)).to_string(),
})
}
async fn inspect_external_sql_file_async(path: PathBuf) -> Result<ExternalSqlFileStatus, String> {
match tokio::fs::metadata(&path).await {
Ok(metadata) if metadata.is_file() => {
Ok(ExternalSqlFileStatus::Present { size_bytes: metadata.len(), modified_ns: modified_ns(&metadata) })
}
Ok(_) => Err("Only files can be inspected this way".to_string()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(ExternalSqlFileStatus::Missing),
Err(error) => Err(format!("Failed to inspect SQL file: {error}")),
}
}
#[cfg(test)]
fn write_external_sql_file_content(path: &Path, content: &str) -> Result<(), String> {
std::fs::write(path, content).map_err(|e| format!("Failed to save SQL file: {e}"))
}
async fn external_sql_file_version_async(path: &Path) -> Result<Option<ExternalSqlFileVersion>, String> {
let mut file = match tokio::fs::File::open(path).await {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(format!("Failed to read SQL file before saving: {error}")),
};
let metadata =
file.metadata().await.map_err(|error| format!("Failed to inspect SQL file before saving: {error}"))?;
let mut digest = Sha256::new();
let mut buffer = vec![0_u8; 64 * 1024];
loop {
let read =
file.read(&mut buffer).await.map_err(|error| format!("Failed to read SQL file before saving: {error}"))?;
if read == 0 {
break;
}
digest.update(&buffer[..read]);
}
Ok(Some(ExternalSqlFileVersion {
size_bytes: metadata.len(),
modified_ns: modified_ns(&metadata),
content_hash: format!("{:x}", digest.finalize()),
}))
}
async fn write_external_sql_file_checked_async(
path: PathBuf,
content: String,
expected_content_hash: Option<String>,
expected_missing: bool,
force: bool,
encoding: &str,
) -> Result<ExternalSqlFileWriteResult, String> {
if !force {
match external_sql_file_version_async(&path).await? {
Some(current_version) => {
if expected_missing
|| expected_content_hash.as_deref().is_some_and(|expected| expected != current_version.content_hash)
{
return Ok(ExternalSqlFileWriteResult::Conflict { current_version });
}
}
None if expected_content_hash.is_some() => return Ok(ExternalSqlFileWriteResult::Missing),
None => {}
}
}
let bytes = encode_sql_file_text(&content, encoding)?;
let hash = content_hash(&bytes);
tokio::fs::write(&path, &bytes).await.map_err(|error| format!("Failed to save SQL file: {error}"))?;
let metadata =
tokio::fs::metadata(&path).await.map_err(|error| format!("Failed to inspect saved SQL file: {error}"))?;
Ok(ExternalSqlFileWriteResult::Written {
version: ExternalSqlFileVersion {
size_bytes: metadata.len(),
modified_ns: modified_ns(&metadata),
content_hash: hash,
},
})
}
async fn save_external_sql_file_content_async(
path: Option<PathBuf>,
content: String,
encoding: &str,
) -> Result<Option<ExternalSqlFileSaveResult>, String> {
let Some(path) = path else {
return Ok(None);
};
let result = write_external_sql_file_checked_async(path.clone(), content, None, false, true, encoding).await?;
let ExternalSqlFileWriteResult::Written { version } = result else {
return Err("Failed to save SQL file".to_string());
};
Ok(Some(ExternalSqlFileSaveResult { path: path.to_string_lossy().into_owned(), version }))
}
#[cfg(test)]
fn save_external_sql_file_content(
path: Option<&Path>,
content: &str,
) -> Result<Option<ExternalSqlFileSaveResult>, String> {
let Some(path) = path else {
return Ok(None);
};
write_external_sql_file_content(path, content)?;
let bytes = std::fs::read(path).map_err(|error| format!("Failed to read saved SQL file: {error}"))?;
let metadata = std::fs::metadata(path).map_err(|error| format!("Failed to inspect saved SQL file: {error}"))?;
Ok(Some(ExternalSqlFileSaveResult {
path: path.to_string_lossy().into_owned(),
version: external_sql_file_version(&metadata, &bytes),
}))
}
fn dedupe_paths(paths: Vec<String>) -> Vec<String> {
let mut unique = Vec::new();
for path in paths {
if !unique.contains(&path) {
unique.push(path);
}
}
unique
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filters_sql_file_args_case_insensitively() {
let paths = sql_file_paths_from_args(["/tmp/a.sql", "--flag", "/tmp/b.SQL", "/tmp/c.txt"], Path::new("/work"));
assert_eq!(paths, vec!["/tmp/a.sql", "/tmp/b.SQL"]);
}
#[test]
fn resolves_relative_sql_file_args_against_cwd() {
let paths = sql_file_paths_from_args(["queries/report.sql"], Path::new("/work"));
assert_eq!(paths, vec!["/work/queries/report.sql"]);
}
#[test]
fn accepts_sql_file_args_passed_as_file_urls() {
let paths = sql_file_paths_from_args(["file:///tmp/a.sql", "file:///tmp/my%20report.sql"], Path::new("/work"));
assert_eq!(paths, vec!["/tmp/a.sql", "/tmp/my report.sql"]);
}
#[test]
fn drains_pending_sql_file_paths_once() {
let state = ExternalSqlOpenState::default();
state.push(vec!["/tmp/a.sql".to_string()]);
assert_eq!(state.drain(), vec!["/tmp/a.sql"]);
assert!(state.drain().is_empty());
}
#[test]
fn reads_external_sql_file_content() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4()));
std::fs::write(&path, "select 1;").unwrap();
let result = read_external_sql_file_content(&path);
let _ = std::fs::remove_file(&path);
let ExternalSqlFileReadResult::Content { content, version, .. } = result.unwrap() else {
panic!("expected SQL file content");
};
assert_eq!(content, "select 1;");
assert_eq!(version.size_bytes, 9);
assert_eq!(version.content_hash, content_hash(b"select 1;"));
}
#[test]
fn reads_gbk_external_sql_file_content() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4()));
std::fs::write(&path, b"select '\xD6\xD0\xCE\xC4';").unwrap();
let result = read_external_sql_file_content(&path);
let _ = std::fs::remove_file(&path);
let ExternalSqlFileReadResult::Content { content, version, .. } = result.unwrap() else {
panic!("expected SQL file content");
};
assert_eq!(content, "select '中文';");
assert_eq!(version.content_hash, content_hash(b"select '\xD6\xD0\xCE\xC4';"));
}
#[test]
fn reads_external_filtered_text_file_content() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.py", uuid::Uuid::new_v4()));
std::fs::write(&path, "print('hello')").unwrap();
let result = read_external_sql_file_content(&path);
let _ = std::fs::remove_file(&path);
let ExternalSqlFileReadResult::Content { content, .. } = result.unwrap() else {
panic!("expected text file content");
};
assert_eq!(content, "print('hello')");
}
#[test]
fn external_sql_editor_limit_is_inclusive() {
assert!(!exceeds_external_sql_editor_limit(
MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES,
MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES
));
assert!(exceeds_external_sql_editor_limit(
MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES + 1,
MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES
));
}
#[test]
fn clamps_external_sql_editor_limit_from_frontend() {
assert_eq!(clamp_external_sql_editor_limit(None), MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES);
assert_eq!(clamp_external_sql_editor_limit(Some(0)), MIN_EXTERNAL_SQL_EDITOR_FILE_BYTES);
assert_eq!(clamp_external_sql_editor_limit(Some(16 * 1024 * 1024)), 16 * 1024 * 1024);
assert_eq!(clamp_external_sql_editor_limit(Some(u64::MAX)), MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES_LIMIT);
}
#[test]
fn rejects_oversized_external_sql_before_reading_content() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4()));
let file = std::fs::File::create(&path).unwrap();
let file_size = MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES + 1;
file.set_len(file_size).unwrap();
let result = read_external_sql_file_content(&path);
let _ = std::fs::remove_file(&path);
assert_eq!(
result.unwrap(),
ExternalSqlFileReadResult::TooLarge {
size_bytes: file_size,
max_size_bytes: MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES,
}
);
}
#[test]
fn rejects_oversized_external_sql_with_custom_limit() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4()));
std::fs::write(&path, "select 1;").unwrap();
let result = read_external_sql_file_content_with_limit(&path, 4);
let _ = std::fs::remove_file(&path);
assert_eq!(result.unwrap(), ExternalSqlFileReadResult::TooLarge { size_bytes: 9, max_size_bytes: 4 });
}
#[tokio::test]
async fn rejects_oversized_external_sql_in_async_command_path() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4()));
let file = std::fs::File::create(&path).unwrap();
let file_size = MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES + 1;
file.set_len(file_size).unwrap();
let result = read_external_sql_file_content_async(path.clone(), MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES, None).await;
let _ = std::fs::remove_file(&path);
assert_eq!(
result.unwrap(),
ExternalSqlFileReadResult::TooLarge {
size_bytes: file_size,
max_size_bytes: MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES,
}
);
}
#[test]
fn serializes_external_sql_file_limit_for_frontend() {
let result = ExternalSqlFileReadResult::TooLarge { size_bytes: 100, max_size_bytes: 64 };
assert_eq!(
serde_json::to_value(result).unwrap(),
serde_json::json!({ "kind": "tooLarge", "sizeBytes": 100, "maxSizeBytes": 64 })
);
}
#[test]
fn writes_external_sql_file_content() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4()));
let result = write_external_sql_file_content(&path, "select 2;");
let content = std::fs::read_to_string(&path).unwrap();
let _ = std::fs::remove_file(&path);
assert!(result.is_ok());
assert_eq!(content, "select 2;");
}
#[tokio::test]
async fn inspects_present_and_missing_external_text_files() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.sh", uuid::Uuid::new_v4()));
std::fs::write(&path, "echo test").unwrap();
let present = inspect_external_sql_file_async(path.clone()).await.unwrap();
assert!(matches!(present, ExternalSqlFileStatus::Present { size_bytes: 9, .. }));
std::fs::remove_file(&path).unwrap();
assert_eq!(inspect_external_sql_file_async(path).await.unwrap(), ExternalSqlFileStatus::Missing);
}
#[tokio::test]
async fn checked_write_rejects_external_content_conflicts() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4()));
std::fs::write(&path, "select 2;").unwrap();
let result = write_external_sql_file_checked_async(
path.clone(),
"select 3;".to_string(),
Some(content_hash(b"select 1;")),
false,
false,
"utf8",
)
.await
.unwrap();
assert!(matches!(result, ExternalSqlFileWriteResult::Conflict { .. }));
assert_eq!(std::fs::read_to_string(&path).unwrap(), "select 2;");
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn checked_write_reports_missing_and_can_recreate_file() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4()));
let missing = write_external_sql_file_checked_async(
path.clone(),
"select 2;".to_string(),
Some(content_hash(b"select 1;")),
false,
false,
"utf8",
)
.await
.unwrap();
assert_eq!(missing, ExternalSqlFileWriteResult::Missing);
let recreated =
write_external_sql_file_checked_async(path.clone(), "select 2;".to_string(), None, true, false, "utf8")
.await
.unwrap();
assert!(matches!(recreated, ExternalSqlFileWriteResult::Written { .. }));
assert_eq!(std::fs::read_to_string(&path).unwrap(), "select 2;");
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn checked_write_does_not_overwrite_a_file_recreated_after_confirmation() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4()));
std::fs::write(&path, "select external;").unwrap();
let result = write_external_sql_file_checked_async(
path.clone(),
"select editor;".to_string(),
None,
true,
false,
"utf8",
)
.await
.unwrap();
assert!(matches!(result, ExternalSqlFileWriteResult::Conflict { .. }));
assert_eq!(std::fs::read_to_string(&path).unwrap(), "select external;");
let _ = std::fs::remove_file(&path);
}
#[test]
fn saves_external_sql_file_with_unicode_name() {
let path = std::env::temp_dir().join(format!("查询-{}.sql", uuid::Uuid::new_v4()));
let result = save_external_sql_file_content(Some(&path), "select 3;");
let content = std::fs::read_to_string(&path).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!(result.unwrap().unwrap().path, path.to_string_lossy());
assert_eq!(content, "select 3;");
}
#[test]
fn saves_external_sql_file_with_ascii_name() {
let path = std::env::temp_dir().join(format!("query-{}.sql", uuid::Uuid::new_v4()));
let result = save_external_sql_file_content(Some(&path), "select 4;");
let _ = std::fs::remove_file(&path);
assert_eq!(result.unwrap().unwrap().path, path.to_string_lossy());
}
#[test]
fn cancelling_external_sql_file_save_does_not_write() {
let result = save_external_sql_file_content(None, "select 5;");
assert_eq!(result.unwrap(), None);
}
#[test]
fn reports_external_sql_file_write_failure() {
let directory = std::env::temp_dir().join(format!("dbx-missing-parent-{}", uuid::Uuid::new_v4()));
let path = directory.join("query.sql");
let result = save_external_sql_file_content(Some(&path), "select 6;");
assert!(result.unwrap_err().contains("Failed to save SQL file"));
}
#[test]
fn writes_external_filtered_text_file_content() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.txt", uuid::Uuid::new_v4()));
let result = write_external_sql_file_content(&path, "plain text");
assert!(result.is_ok());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "plain text");
let _ = std::fs::remove_file(&path);
}
}