Skip to main content

flowscope_cli/
input.rs

1//! Input handling for file reading and stdin support.
2
3use anyhow::{Context, Result};
4use flowscope_core::FileSource;
5use ignore::WalkBuilder;
6use rayon::prelude::*;
7use std::io::{self, Read};
8use std::path::{Path, PathBuf};
9
10/// Lint input source containing SQL content and optional file path.
11pub struct LintInputSource {
12    pub source: FileSource,
13    pub path: Option<PathBuf>,
14}
15
16/// Read SQL input from files or stdin.
17///
18/// If no files are provided, reads from stdin.
19/// Returns a vector of FileSource for multi-file analysis.
20pub 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
28/// Read lint input from files/directories or stdin.
29///
30/// Directory paths are expanded recursively and only `.sql` files are included.
31/// Direct file paths are always included (regardless of extension) for backwards compatibility.
32pub 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
64/// Read SQL from stdin
65fn 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        // Use .sql extension so frontend filters include stdin content
73        name: "<stdin>.sql".to_string(),
74        content,
75    }])
76}
77
78/// Read SQL from multiple files
79fn 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}