1use anyhow::{Context, Result};
4use flowscope_core::FileSource;
5use ignore::WalkBuilder;
6use rayon::prelude::*;
7use std::io::{self, Read};
8use std::path::{Path, PathBuf};
9
10pub struct LintInputSource {
12 pub source: FileSource,
13 pub path: Option<PathBuf>,
14}
15
16pub fn read_input(files: &[PathBuf]) -> Result<Vec<FileSource>> {
21 if files.is_empty() {
22 read_from_stdin()
23 } else {
24 read_from_files(files)
25 }
26}
27
28pub fn read_lint_input(paths: &[PathBuf], respect_gitignore: bool) -> Result<Vec<LintInputSource>> {
33 if paths.is_empty() {
34 return read_from_stdin().map(|sources| {
35 sources
36 .into_iter()
37 .map(|source| LintInputSource { source, path: None })
38 .collect()
39 });
40 }
41
42 let expanded_paths = expand_lint_paths(paths, respect_gitignore)?;
43 if expanded_paths.is_empty() {
44 anyhow::bail!("No .sql files found in provided directories");
45 }
46
47 expanded_paths
48 .into_par_iter()
49 .map(|path| {
50 let content = std::fs::read_to_string(&path)
51 .with_context(|| format!("Failed to read file: {}", path.display()))?;
52
53 Ok(LintInputSource {
54 source: FileSource {
55 name: path.display().to_string(),
56 content,
57 },
58 path: Some(path),
59 })
60 })
61 .collect()
62}
63
64fn read_from_stdin() -> Result<Vec<FileSource>> {
66 let mut content = String::new();
67 io::stdin()
68 .read_to_string(&mut content)
69 .context("Failed to read from stdin")?;
70
71 Ok(vec![FileSource {
72 name: "<stdin>.sql".to_string(),
74 content,
75 }])
76}
77
78fn read_from_files(files: &[PathBuf]) -> Result<Vec<FileSource>> {
80 files
81 .iter()
82 .map(|path| {
83 let content = std::fs::read_to_string(path)
84 .with_context(|| format!("Failed to read file: {}", path.display()))?;
85
86 Ok(FileSource {
87 name: path.display().to_string(),
88 content,
89 })
90 })
91 .collect()
92}
93
94fn expand_lint_paths(paths: &[PathBuf], respect_gitignore: bool) -> Result<Vec<PathBuf>> {
95 let mut expanded_paths = Vec::new();
96
97 for path in paths {
98 let metadata = std::fs::metadata(path)
99 .with_context(|| format!("Failed to read file metadata: {}", path.display()))?;
100
101 if metadata.is_dir() {
102 if respect_gitignore {
103 collect_sql_files_with_ignore(path, &mut expanded_paths)?;
104 } else {
105 collect_sql_files_recursive(path, &mut expanded_paths)?;
106 }
107 } else {
108 expanded_paths.push(path.clone());
109 }
110 }
111
112 expanded_paths.sort();
113 expanded_paths.dedup();
114 Ok(expanded_paths)
115}
116
117fn collect_sql_files_with_ignore(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
118 let mut builder = WalkBuilder::new(dir);
119 builder.standard_filters(true);
120 builder.require_git(false);
121 builder.hidden(false);
122
123 for entry in builder.build() {
124 let entry =
125 entry.with_context(|| format!("Failed to read directory: {}", dir.display()))?;
126 let Some(file_type) = entry.file_type() else {
127 continue;
128 };
129 if file_type.is_file() && is_sql_file(entry.path()) {
130 out.push(entry.path().to_path_buf());
131 }
132 }
133
134 Ok(())
135}
136
137fn collect_sql_files_recursive(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
138 for entry in std::fs::read_dir(dir)
139 .with_context(|| format!("Failed to read directory: {}", dir.display()))?
140 {
141 let entry =
142 entry.with_context(|| format!("Failed to read directory: {}", dir.display()))?;
143 let path = entry.path();
144 let file_type = entry
145 .file_type()
146 .with_context(|| format!("Failed to read file type: {}", path.display()))?;
147
148 if file_type.is_dir() {
149 collect_sql_files_recursive(path.as_path(), out)?;
150 } else if file_type.is_file() && is_sql_file(&path) {
151 out.push(path);
152 }
153 }
154
155 Ok(())
156}
157
158fn is_sql_file(path: &Path) -> bool {
159 path.extension()
160 .and_then(|ext| ext.to_str())
161 .map(|ext| ext.eq_ignore_ascii_case("sql"))
162 .unwrap_or(false)
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use std::io::Write;
169 use tempfile::tempdir;
170 use tempfile::NamedTempFile;
171
172 #[test]
173 fn test_read_single_file() {
174 let mut file = NamedTempFile::new().unwrap();
175 writeln!(file, "SELECT * FROM users").unwrap();
176
177 let sources = read_from_files(&[file.path().to_path_buf()]).unwrap();
178 assert_eq!(sources.len(), 1);
179 assert!(sources[0].content.contains("SELECT * FROM users"));
180 }
181
182 #[test]
183 fn test_read_multiple_files() {
184 let mut file1 = NamedTempFile::new().unwrap();
185 let mut file2 = NamedTempFile::new().unwrap();
186 writeln!(file1, "SELECT * FROM users").unwrap();
187 writeln!(file2, "SELECT * FROM orders").unwrap();
188
189 let sources =
190 read_from_files(&[file1.path().to_path_buf(), file2.path().to_path_buf()]).unwrap();
191 assert_eq!(sources.len(), 2);
192 }
193
194 #[test]
195 fn test_read_missing_file() {
196 let result = read_from_files(&[PathBuf::from("/nonexistent/file.sql")]);
197 assert!(result.is_err());
198 }
199
200 #[test]
201 fn test_read_lint_input_from_directory_recursively() {
202 let dir = tempdir().unwrap();
203 let nested = dir.path().join("nested");
204 std::fs::create_dir_all(&nested).unwrap();
205
206 let sql_one = dir.path().join("one.sql");
207 let sql_two = nested.join("two.SQL");
208 let txt_file = nested.join("ignore.txt");
209
210 std::fs::write(&sql_one, "SELECT 1").unwrap();
211 std::fs::write(&sql_two, "SELECT 2").unwrap();
212 std::fs::write(&txt_file, "SELECT 3").unwrap();
213
214 let inputs = read_lint_input(&[dir.path().to_path_buf()], true).unwrap();
215 assert_eq!(inputs.len(), 2);
216
217 let names: Vec<String> = inputs.into_iter().map(|i| i.source.name).collect();
218 assert!(names.iter().any(|n| n.ends_with("one.sql")));
219 assert!(names.iter().any(|n| n.ends_with("two.SQL")));
220 assert!(!names.iter().any(|n| n.ends_with("ignore.txt")));
221 }
222
223 #[test]
224 fn test_read_lint_input_respects_gitignore() {
225 let dir = tempdir().unwrap();
226 std::fs::write(dir.path().join(".gitignore"), "ignored.sql\n").unwrap();
227 let kept = dir.path().join("kept.sql");
228 let ignored = dir.path().join("ignored.sql");
229 std::fs::write(&kept, "SELECT 1").unwrap();
230 std::fs::write(&ignored, "SELECT 2").unwrap();
231
232 let respected = read_lint_input(&[dir.path().to_path_buf()], true).unwrap();
233 let respected_names: Vec<String> = respected.into_iter().map(|i| i.source.name).collect();
234 assert!(respected_names.iter().any(|n| n.ends_with("kept.sql")));
235 assert!(!respected_names.iter().any(|n| n.ends_with("ignored.sql")));
236
237 let not_respected = read_lint_input(&[dir.path().to_path_buf()], false).unwrap();
238 let not_respected_names: Vec<String> =
239 not_respected.into_iter().map(|i| i.source.name).collect();
240 assert!(not_respected_names.iter().any(|n| n.ends_with("kept.sql")));
241 assert!(not_respected_names
242 .iter()
243 .any(|n| n.ends_with("ignored.sql")));
244 }
245}