Skip to main content

blast_radius/
fs.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6use ignore::WalkBuilder;
7use jsonc_parser::{ParseOptions, parse_to_serde_value};
8use serde::Deserialize;
9
10#[derive(Debug, Clone)]
11pub struct RepoContext {
12    pub repo_root: PathBuf,
13    pub source_files: Vec<PathBuf>,
14    pub tsconfigs: Vec<TsConfigPath>,
15    pub package_jsons: Vec<PathBuf>,
16    /// Import-specifier substrings the repo asks not to count as unresolved
17    /// (generated/virtual modules its tooling produces). From `.blast-radius.json`.
18    pub ignore_unresolved: Vec<String>,
19    pub warnings: Vec<String>,
20}
21
22/// Optional per-repo configuration loaded from `.blast-radius.json` at the repo
23/// root. Lets a repo declare tooling-specific quirks the language-neutral core
24/// shouldn't hardcode.
25#[derive(Debug, Default, Deserialize)]
26struct ProjectConfig {
27    #[serde(default)]
28    unresolved: UnresolvedConfig,
29}
30
31#[derive(Debug, Default, Deserialize)]
32struct UnresolvedConfig {
33    /// Import specifiers whose substring matches are not counted as unresolved.
34    #[serde(default)]
35    ignore: Vec<String>,
36}
37
38#[derive(Debug, Clone)]
39pub struct TsConfigPath {
40    pub path: PathBuf,
41    pub compiler_options: TsCompilerOptions,
42}
43
44#[derive(Debug, Clone, Default, Deserialize)]
45pub struct TsCompilerOptions {
46    #[serde(default)]
47    pub base_url: Option<String>,
48    #[serde(default)]
49    pub paths: BTreeMap<String, Vec<String>>,
50}
51
52#[derive(Debug, Deserialize)]
53struct TsConfigFile {
54    #[serde(default, rename = "compilerOptions")]
55    compiler_options: TsCompilerOptions,
56}
57
58impl RepoContext {
59    pub fn discover(repo_root: &Path) -> Result<Self> {
60        let repo_root = repo_root
61            .canonicalize()
62            .with_context(|| format!("failed to resolve repo root {}", repo_root.display()))?;
63
64        let mut source_files = Vec::new();
65        let mut tsconfigs = Vec::new();
66        let mut package_jsons = Vec::new();
67        let mut warnings = Vec::new();
68
69        let ignore_unresolved = match load_project_config(&repo_root) {
70            Ok(config) => config.unresolved.ignore,
71            Err(error) => {
72                warnings.push(format!("{error:#}"));
73                Vec::new()
74            }
75        };
76
77        let walker = WalkBuilder::new(&repo_root)
78            .hidden(false)
79            .git_ignore(true)
80            .git_exclude(true)
81            .git_global(true)
82            .filter_entry(|entry| {
83                let name = entry.file_name().to_string_lossy();
84                !matches!(
85                    name.as_ref(),
86                    ".git" | "node_modules" | "dist" | "build" | "coverage" | ".next" | ".turbo"
87                )
88            })
89            .build();
90
91        for entry in walker {
92            let entry = entry?;
93            if !entry.file_type().is_some_and(|kind| kind.is_file()) {
94                continue;
95            }
96
97            let path = entry.into_path();
98            match path.file_name().and_then(|name| name.to_str()) {
99                Some("tsconfig.json") => match load_tsconfig(&path) {
100                    Ok(config) => tsconfigs.push(config),
101                    Err(error) => warnings.push(format!("{error:#}")),
102                },
103                Some("package.json") => package_jsons.push(path.clone()),
104                _ => {}
105            }
106
107            if is_source_file(&path) {
108                source_files.push(path);
109            }
110        }
111
112        source_files.sort();
113        tsconfigs.sort_by(|a, b| a.path.cmp(&b.path));
114        package_jsons.sort();
115
116        Ok(Self {
117            repo_root,
118            source_files,
119            tsconfigs,
120            package_jsons,
121            ignore_unresolved,
122            warnings,
123        })
124    }
125}
126
127fn load_project_config(repo_root: &Path) -> Result<ProjectConfig> {
128    let path = repo_root.join(".blast-radius.json");
129    let contents = match fs::read_to_string(&path) {
130        Ok(contents) => contents,
131        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
132            return Ok(ProjectConfig::default());
133        }
134        Err(error) => {
135            return Err(anyhow::Error::new(error)
136                .context(format!("failed to read config {}", path.display())));
137        }
138    };
139
140    // Parsed as JSONC (comments + trailing commas), matching tsconfig handling.
141    let value: serde_json::Value = parse_to_serde_value(
142        &contents,
143        &ParseOptions {
144            allow_comments: true,
145            allow_loose_object_property_names: false,
146            allow_trailing_commas: true,
147            allow_missing_commas: false,
148            allow_single_quoted_strings: false,
149            allow_hexadecimal_numbers: false,
150            allow_unary_plus_numbers: false,
151        },
152    )
153    .with_context(|| format!("failed to parse config {}", path.display()))?;
154
155    serde_json::from_value(value)
156        .with_context(|| format!("failed to decode config {}", path.display()))
157}
158
159fn load_tsconfig(path: &Path) -> Result<TsConfigPath> {
160    let contents = fs::read_to_string(path)
161        .with_context(|| format!("failed to read tsconfig {}", path.display()))?;
162    let value: serde_json::Value = parse_to_serde_value(
163        &contents,
164        &ParseOptions {
165            allow_comments: true,
166            allow_loose_object_property_names: false,
167            allow_trailing_commas: true,
168            allow_missing_commas: false,
169            allow_single_quoted_strings: false,
170            allow_hexadecimal_numbers: false,
171            allow_unary_plus_numbers: false,
172        },
173    )
174    .with_context(|| format!("failed to parse tsconfig {}", path.display()))?;
175    let parsed: TsConfigFile = serde_json::from_value(value)
176        .with_context(|| format!("failed to decode tsconfig {}", path.display()))?;
177    Ok(TsConfigPath {
178        path: path.to_path_buf(),
179        compiler_options: parsed.compiler_options,
180    })
181}
182
183fn is_source_file(path: &Path) -> bool {
184    path.extension()
185        .and_then(|ext| ext.to_str())
186        .is_some_and(crate::language::is_source_extension)
187}
188
189#[cfg(test)]
190mod tests {
191    use std::fs;
192
193    use tempfile::tempdir;
194
195    use super::RepoContext;
196
197    #[test]
198    fn discovers_source_files_and_tsconfig() {
199        let dir = tempdir().unwrap();
200        fs::create_dir_all(dir.path().join("src")).unwrap();
201        fs::write(
202            dir.path().join("tsconfig.json"),
203            r#"{"compilerOptions":{"baseUrl":".","paths":{"@ui/*":["packages/ui/*"]}}}"#,
204        )
205        .unwrap();
206        fs::write(
207            dir.path().join("src").join("Button.tsx"),
208            "export const Button = () => null;",
209        )
210        .unwrap();
211        fs::write(
212            dir.path().join("src").join("legacy.mjs"),
213            "export const legacy = true;",
214        )
215        .unwrap();
216        fs::write(dir.path().join("src").join("server.cts"), "export = {};").unwrap();
217        fs::write(
218            dir.path().join("src").join("helper.py"),
219            "def helper(): pass",
220        )
221        .unwrap();
222        fs::write(dir.path().join("src").join("lib.rs"), "pub fn helper() {}").unwrap();
223        fs::write(
224            dir.path().join("src").join("Button.vue"),
225            "<script setup>import x from './x'</script>",
226        )
227        .unwrap();
228        fs::write(
229            dir.path().join("src").join("Card.svelte"),
230            "<script>import x from './x'</script>",
231        )
232        .unwrap();
233        fs::write(dir.path().join("src").join("user.rb"), "class User; end").unwrap();
234        fs::write(dir.path().join("src").join("User.java"), "class User {}").unwrap();
235        fs::write(dir.path().join("package.json"), r#"{"name":"fixture"}"#).unwrap();
236
237        let repo = RepoContext::discover(dir.path()).unwrap();
238
239        let mut expected = 3;
240        if cfg!(feature = "python") {
241            expected += 1;
242        }
243        if cfg!(feature = "rust") {
244            expected += 1;
245        }
246        if cfg!(feature = "vue") {
247            expected += 1;
248        }
249        if cfg!(feature = "svelte") {
250            expected += 1;
251        }
252        if cfg!(feature = "ruby") {
253            expected += 1;
254        }
255        if cfg!(feature = "java") {
256            expected += 1;
257        }
258        assert_eq!(repo.source_files.len(), expected);
259        assert_eq!(repo.tsconfigs.len(), 1);
260        assert_eq!(repo.package_jsons.len(), 1);
261    }
262
263    #[test]
264    fn loads_ignore_unresolved_from_project_config() {
265        let dir = tempdir().unwrap();
266        fs::create_dir_all(dir.path().join("src")).unwrap();
267        fs::write(
268            dir.path().join("src").join("App.tsx"),
269            "export const App = () => null;",
270        )
271        .unwrap();
272        fs::write(
273            dir.path().join(".blast-radius.json"),
274            r#"{ "unresolved": { "ignore": ["styled-system/css", ".velite"] } }"#,
275        )
276        .unwrap();
277
278        let repo = RepoContext::discover(dir.path()).unwrap();
279
280        assert_eq!(
281            repo.ignore_unresolved,
282            vec!["styled-system/css".to_string(), ".velite".to_string()]
283        );
284    }
285
286    #[test]
287    fn defaults_ignore_unresolved_when_config_absent() {
288        let dir = tempdir().unwrap();
289        fs::create_dir_all(dir.path().join("src")).unwrap();
290        fs::write(
291            dir.path().join("src").join("App.tsx"),
292            "export const App = () => null;",
293        )
294        .unwrap();
295
296        let repo = RepoContext::discover(dir.path()).unwrap();
297
298        assert!(repo.ignore_unresolved.is_empty());
299    }
300
301    #[test]
302    fn reports_invalid_project_config_as_warning() {
303        let dir = tempdir().unwrap();
304        fs::write(dir.path().join(".blast-radius.json"), "{ not valid json").unwrap();
305
306        let repo = RepoContext::discover(dir.path()).unwrap();
307
308        assert!(repo.ignore_unresolved.is_empty());
309        assert!(
310            repo.warnings
311                .iter()
312                .any(|warning| warning.contains("failed to parse config")),
313            "invalid config should be reported as a discovery warning"
314        );
315    }
316
317    #[test]
318    fn reports_invalid_tsconfig_as_warning() {
319        let dir = tempdir().unwrap();
320        fs::create_dir_all(dir.path().join("src")).unwrap();
321        fs::write(dir.path().join("tsconfig.json"), "{ invalid json").unwrap();
322        fs::write(
323            dir.path().join("src").join("Button.tsx"),
324            "export const Button = () => null;",
325        )
326        .unwrap();
327
328        let repo = RepoContext::discover(dir.path()).unwrap();
329
330        assert_eq!(repo.source_files.len(), 1);
331        assert!(repo.tsconfigs.is_empty());
332        assert!(
333            repo.warnings
334                .iter()
335                .any(|warning| warning.contains("failed to parse tsconfig")),
336            "invalid tsconfig should be reported as a discovery warning"
337        );
338    }
339
340    #[cfg(feature = "python")]
341    #[test]
342    fn discovers_python_sources_when_enabled() {
343        let dir = tempdir().unwrap();
344        fs::create_dir_all(dir.path().join("src")).unwrap();
345        fs::write(
346            dir.path().join("src").join("helper.py"),
347            "def helper(): pass",
348        )
349        .unwrap();
350
351        let repo = RepoContext::discover(dir.path()).unwrap();
352
353        assert_eq!(repo.source_files.len(), 1);
354    }
355
356    #[cfg(feature = "rust")]
357    #[test]
358    fn discovers_rust_sources_when_enabled() {
359        let dir = tempdir().unwrap();
360        fs::create_dir_all(dir.path().join("src")).unwrap();
361        fs::write(dir.path().join("src").join("lib.rs"), "pub fn helper() {}").unwrap();
362
363        let repo = RepoContext::discover(dir.path()).unwrap();
364
365        assert_eq!(repo.source_files.len(), 1);
366    }
367
368    #[cfg(feature = "vue")]
369    #[test]
370    fn discovers_vue_sources_when_enabled() {
371        let dir = tempdir().unwrap();
372        fs::create_dir_all(dir.path().join("src")).unwrap();
373        fs::write(dir.path().join("src").join("Button.vue"), "<template />").unwrap();
374
375        let repo = RepoContext::discover(dir.path()).unwrap();
376
377        assert_eq!(repo.source_files.len(), 1);
378    }
379
380    #[cfg(feature = "svelte")]
381    #[test]
382    fn discovers_svelte_sources_when_enabled() {
383        let dir = tempdir().unwrap();
384        fs::create_dir_all(dir.path().join("src")).unwrap();
385        fs::write(
386            dir.path().join("src").join("Card.svelte"),
387            "<script></script>",
388        )
389        .unwrap();
390
391        let repo = RepoContext::discover(dir.path()).unwrap();
392
393        assert_eq!(repo.source_files.len(), 1);
394    }
395
396    #[cfg(feature = "ruby")]
397    #[test]
398    fn discovers_ruby_sources_when_enabled() {
399        let dir = tempdir().unwrap();
400        fs::create_dir_all(dir.path().join("lib")).unwrap();
401        fs::write(dir.path().join("lib").join("user.rb"), "class User; end").unwrap();
402
403        let repo = RepoContext::discover(dir.path()).unwrap();
404
405        assert_eq!(repo.source_files.len(), 1);
406    }
407
408    #[cfg(feature = "java")]
409    #[test]
410    fn discovers_java_sources_when_enabled() {
411        let dir = tempdir().unwrap();
412        fs::create_dir_all(dir.path().join("src")).unwrap();
413        fs::write(dir.path().join("src").join("User.java"), "class User {}").unwrap();
414
415        let repo = RepoContext::discover(dir.path()).unwrap();
416
417        assert_eq!(repo.source_files.len(), 1);
418    }
419}