use anyhow::{Context, Result};
use flowscope_core::FileSource;
use ignore::WalkBuilder;
use rayon::prelude::*;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
pub struct LintInputSource {
pub source: FileSource,
pub path: Option<PathBuf>,
}
pub fn read_input(files: &[PathBuf]) -> Result<Vec<FileSource>> {
if files.is_empty() {
read_from_stdin()
} else {
read_from_files(files)
}
}
pub fn read_lint_input(paths: &[PathBuf], respect_gitignore: bool) -> Result<Vec<LintInputSource>> {
if paths.is_empty() {
return read_from_stdin().map(|sources| {
sources
.into_iter()
.map(|source| LintInputSource { source, path: None })
.collect()
});
}
let expanded_paths = expand_lint_paths(paths, respect_gitignore)?;
if expanded_paths.is_empty() {
anyhow::bail!("No .sql files found in provided directories");
}
expanded_paths
.into_par_iter()
.map(|path| {
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read file: {}", path.display()))?;
Ok(LintInputSource {
source: FileSource {
name: path.display().to_string(),
content,
},
path: Some(path),
})
})
.collect()
}
fn read_from_stdin() -> Result<Vec<FileSource>> {
let mut content = String::new();
io::stdin()
.read_to_string(&mut content)
.context("Failed to read from stdin")?;
Ok(vec![FileSource {
name: "<stdin>.sql".to_string(),
content,
}])
}
fn read_from_files(files: &[PathBuf]) -> Result<Vec<FileSource>> {
files
.iter()
.map(|path| {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read file: {}", path.display()))?;
Ok(FileSource {
name: path.display().to_string(),
content,
})
})
.collect()
}
fn expand_lint_paths(paths: &[PathBuf], respect_gitignore: bool) -> Result<Vec<PathBuf>> {
let mut expanded_paths = Vec::new();
for path in paths {
let metadata = std::fs::metadata(path)
.with_context(|| format!("Failed to read file metadata: {}", path.display()))?;
if metadata.is_dir() {
if respect_gitignore {
collect_sql_files_with_ignore(path, &mut expanded_paths)?;
} else {
collect_sql_files_recursive(path, &mut expanded_paths)?;
}
} else {
expanded_paths.push(path.clone());
}
}
expanded_paths.sort();
expanded_paths.dedup();
Ok(expanded_paths)
}
fn collect_sql_files_with_ignore(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
let mut builder = WalkBuilder::new(dir);
builder.standard_filters(true);
builder.require_git(false);
builder.hidden(false);
for entry in builder.build() {
let entry =
entry.with_context(|| format!("Failed to read directory: {}", dir.display()))?;
let Some(file_type) = entry.file_type() else {
continue;
};
if file_type.is_file() && is_sql_file(entry.path()) {
out.push(entry.path().to_path_buf());
}
}
Ok(())
}
fn collect_sql_files_recursive(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
for entry in std::fs::read_dir(dir)
.with_context(|| format!("Failed to read directory: {}", dir.display()))?
{
let entry =
entry.with_context(|| format!("Failed to read directory: {}", dir.display()))?;
let path = entry.path();
let file_type = entry
.file_type()
.with_context(|| format!("Failed to read file type: {}", path.display()))?;
if file_type.is_dir() {
collect_sql_files_recursive(path.as_path(), out)?;
} else if file_type.is_file() && is_sql_file(&path) {
out.push(path);
}
}
Ok(())
}
fn is_sql_file(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("sql"))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::tempdir;
use tempfile::NamedTempFile;
#[test]
fn test_read_single_file() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "SELECT * FROM users").unwrap();
let sources = read_from_files(&[file.path().to_path_buf()]).unwrap();
assert_eq!(sources.len(), 1);
assert!(sources[0].content.contains("SELECT * FROM users"));
}
#[test]
fn test_read_multiple_files() {
let mut file1 = NamedTempFile::new().unwrap();
let mut file2 = NamedTempFile::new().unwrap();
writeln!(file1, "SELECT * FROM users").unwrap();
writeln!(file2, "SELECT * FROM orders").unwrap();
let sources =
read_from_files(&[file1.path().to_path_buf(), file2.path().to_path_buf()]).unwrap();
assert_eq!(sources.len(), 2);
}
#[test]
fn test_read_missing_file() {
let result = read_from_files(&[PathBuf::from("/nonexistent/file.sql")]);
assert!(result.is_err());
}
#[test]
fn test_read_lint_input_from_directory_recursively() {
let dir = tempdir().unwrap();
let nested = dir.path().join("nested");
std::fs::create_dir_all(&nested).unwrap();
let sql_one = dir.path().join("one.sql");
let sql_two = nested.join("two.SQL");
let txt_file = nested.join("ignore.txt");
std::fs::write(&sql_one, "SELECT 1").unwrap();
std::fs::write(&sql_two, "SELECT 2").unwrap();
std::fs::write(&txt_file, "SELECT 3").unwrap();
let inputs = read_lint_input(&[dir.path().to_path_buf()], true).unwrap();
assert_eq!(inputs.len(), 2);
let names: Vec<String> = inputs.into_iter().map(|i| i.source.name).collect();
assert!(names.iter().any(|n| n.ends_with("one.sql")));
assert!(names.iter().any(|n| n.ends_with("two.SQL")));
assert!(!names.iter().any(|n| n.ends_with("ignore.txt")));
}
#[test]
fn test_read_lint_input_respects_gitignore() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join(".gitignore"), "ignored.sql\n").unwrap();
let kept = dir.path().join("kept.sql");
let ignored = dir.path().join("ignored.sql");
std::fs::write(&kept, "SELECT 1").unwrap();
std::fs::write(&ignored, "SELECT 2").unwrap();
let respected = read_lint_input(&[dir.path().to_path_buf()], true).unwrap();
let respected_names: Vec<String> = respected.into_iter().map(|i| i.source.name).collect();
assert!(respected_names.iter().any(|n| n.ends_with("kept.sql")));
assert!(!respected_names.iter().any(|n| n.ends_with("ignored.sql")));
let not_respected = read_lint_input(&[dir.path().to_path_buf()], false).unwrap();
let not_respected_names: Vec<String> =
not_respected.into_iter().map(|i| i.source.name).collect();
assert!(not_respected_names.iter().any(|n| n.ends_with("kept.sql")));
assert!(not_respected_names
.iter()
.any(|n| n.ends_with("ignored.sql")));
}
}