1use std::collections::{BTreeMap, HashSet};
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 pub ignore_unresolved: Vec<String>,
19 pub warnings: Vec<String>,
20}
21
22#[derive(Debug, Default, Deserialize)]
26struct ProjectConfig {
27 #[serde(default)]
28 unresolved: UnresolvedConfig,
29}
30
31#[derive(Debug, Default, Deserialize)]
32struct UnresolvedConfig {
33 #[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)]
48pub struct TsCompilerOptions {
49 pub base_dir: Option<PathBuf>,
51 pub paths: BTreeMap<String, Vec<String>>,
52 pub paths_dir: Option<PathBuf>,
55}
56
57impl TsCompilerOptions {
58 pub fn has_aliases(&self) -> bool {
59 self.base_dir.is_some() || !self.paths.is_empty()
60 }
61}
62
63#[derive(Debug, Default, Deserialize)]
64struct TsConfigFile {
65 #[serde(default)]
66 extends: Option<serde_json::Value>,
67 #[serde(default, rename = "compilerOptions")]
68 compiler_options: RawCompilerOptions,
69}
70
71#[derive(Debug, Default, Deserialize)]
72struct RawCompilerOptions {
73 #[serde(default, rename = "baseUrl")]
74 base_url: Option<String>,
75 #[serde(default)]
76 paths: Option<BTreeMap<String, Vec<String>>>,
77}
78
79impl RepoContext {
80 pub fn discover(repo_root: &Path) -> Result<Self> {
81 let repo_root = repo_root
82 .canonicalize()
83 .with_context(|| format!("failed to resolve repo root {}", repo_root.display()))?;
84
85 let mut source_files = Vec::new();
86 let mut tsconfigs = Vec::new();
87 let mut package_jsons = Vec::new();
88 let mut warnings = Vec::new();
89
90 let ignore_unresolved = match load_project_config(&repo_root) {
91 Ok(config) => config.unresolved.ignore,
92 Err(error) => {
93 warnings.push(format!("{error:#}"));
94 Vec::new()
95 }
96 };
97
98 let walker = WalkBuilder::new(&repo_root)
99 .hidden(false)
100 .git_ignore(true)
101 .git_exclude(true)
102 .git_global(true)
103 .filter_entry(|entry| {
104 let name = entry.file_name().to_string_lossy();
105 !matches!(
106 name.as_ref(),
107 ".git" | "node_modules" | "dist" | "build" | "coverage" | ".next" | ".turbo"
108 )
109 })
110 .build();
111
112 for entry in walker {
113 let entry = match entry {
114 Ok(entry) => entry,
115 Err(error) => {
116 warnings.push(format!("skipping unreadable path: {error}"));
117 continue;
118 }
119 };
120 if !entry.file_type().is_some_and(|kind| kind.is_file()) {
121 continue;
122 }
123
124 let path = entry.into_path();
125 match path.file_name().and_then(|name| name.to_str()) {
126 Some("tsconfig.json") => match load_tsconfig(&path) {
127 Ok(config) => {
128 if !config.compiler_options.has_aliases() {
132 tsconfigs.extend(load_sibling_tsconfigs(&path, &mut warnings));
133 }
134 tsconfigs.push(config);
135 }
136 Err(error) => warnings.push(format!("{error:#}")),
137 },
138 Some("package.json") => package_jsons.push(path.clone()),
139 _ => {}
140 }
141
142 if is_source_file(&path) {
143 source_files.push(path);
144 }
145 }
146
147 source_files.sort();
148 tsconfigs.sort_by(|a, b| a.path.cmp(&b.path));
149 package_jsons.sort();
150
151 Ok(Self {
152 repo_root,
153 source_files,
154 tsconfigs,
155 package_jsons,
156 ignore_unresolved,
157 warnings,
158 })
159 }
160}
161
162fn load_project_config(repo_root: &Path) -> Result<ProjectConfig> {
163 let path = repo_root.join(".blast-radius.json");
164 let contents = match fs::read_to_string(&path) {
165 Ok(contents) => contents,
166 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
167 return Ok(ProjectConfig::default());
168 }
169 Err(error) => {
170 return Err(anyhow::Error::new(error)
171 .context(format!("failed to read config {}", path.display())));
172 }
173 };
174
175 let value: serde_json::Value = parse_to_serde_value(
177 &contents,
178 &ParseOptions {
179 allow_comments: true,
180 allow_loose_object_property_names: false,
181 allow_trailing_commas: true,
182 allow_missing_commas: false,
183 allow_single_quoted_strings: false,
184 allow_hexadecimal_numbers: false,
185 allow_unary_plus_numbers: false,
186 },
187 )
188 .with_context(|| format!("failed to parse config {}", path.display()))?;
189
190 serde_json::from_value(value)
191 .with_context(|| format!("failed to decode config {}", path.display()))
192}
193
194fn load_tsconfig(path: &Path) -> Result<TsConfigPath> {
195 let mut visited = HashSet::new();
196 Ok(TsConfigPath {
197 path: path.to_path_buf(),
198 compiler_options: load_tsconfig_options(path, &mut visited)?,
199 })
200}
201
202fn load_tsconfig_options(path: &Path, visited: &mut HashSet<PathBuf>) -> Result<TsCompilerOptions> {
205 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
206 if !visited.insert(canonical) {
207 return Ok(TsCompilerOptions::default());
209 }
210
211 let contents = fs::read_to_string(path)
212 .with_context(|| format!("failed to read tsconfig {}", path.display()))?;
213 let value: serde_json::Value = parse_to_serde_value(
214 &contents,
215 &ParseOptions {
216 allow_comments: true,
217 allow_loose_object_property_names: false,
218 allow_trailing_commas: true,
219 allow_missing_commas: false,
220 allow_single_quoted_strings: false,
221 allow_hexadecimal_numbers: false,
222 allow_unary_plus_numbers: false,
223 },
224 )
225 .with_context(|| format!("failed to parse tsconfig {}", path.display()))?;
226 let parsed: TsConfigFile = serde_json::from_value(value)
227 .with_context(|| format!("failed to decode tsconfig {}", path.display()))?;
228
229 let dir = path.parent().unwrap_or(Path::new("."));
230 let mut merged = TsCompilerOptions::default();
231 for specifier in extends_specifiers(&parsed.extends) {
232 let Some(parent_path) = resolve_extends_target(dir, &specifier) else {
235 continue;
236 };
237 let parent = load_tsconfig_options(&parent_path, visited)
238 .with_context(|| format!("failed to load extended tsconfig from {}", path.display()))?;
239 if parent.base_dir.is_some() {
240 merged.base_dir = parent.base_dir;
241 }
242 if parent.paths_dir.is_some() {
243 merged.paths = parent.paths;
244 merged.paths_dir = parent.paths_dir;
245 }
246 }
247
248 if let Some(base_url) = parsed.compiler_options.base_url {
249 merged.base_dir = Some(crate::resolve::clean_path(&dir.join(base_url)));
250 }
251 if let Some(paths) = parsed.compiler_options.paths {
252 merged.paths = paths;
253 merged.paths_dir = Some(dir.to_path_buf());
254 }
255
256 Ok(merged)
257}
258
259fn extends_specifiers(value: &Option<serde_json::Value>) -> Vec<String> {
260 match value {
261 Some(serde_json::Value::String(specifier)) => vec![specifier.clone()],
262 Some(serde_json::Value::Array(items)) => items
263 .iter()
264 .filter_map(|item| item.as_str().map(str::to_string))
265 .collect(),
266 _ => Vec::new(),
267 }
268}
269
270fn resolve_extends_target(dir: &Path, specifier: &str) -> Option<PathBuf> {
274 let candidate = dir.join(specifier);
275 if candidate.is_file() {
276 return Some(candidate);
277 }
278 if !specifier.ends_with(".json") {
279 let mut with_json = candidate.into_os_string();
280 with_json.push(".json");
281 let with_json = PathBuf::from(with_json);
282 if with_json.is_file() {
283 return Some(with_json);
284 }
285 }
286 None
287}
288
289fn load_sibling_tsconfigs(tsconfig: &Path, warnings: &mut Vec<String>) -> Vec<TsConfigPath> {
292 let Some(dir) = tsconfig.parent() else {
293 return Vec::new();
294 };
295
296 let mut configs = Vec::new();
297 for name in ["tsconfig.base.json", "tsconfig.app.json"] {
298 let path = dir.join(name);
299 if !path.is_file() {
300 continue;
301 }
302 match load_tsconfig(&path) {
303 Ok(config) if config.compiler_options.has_aliases() => configs.push(config),
304 Ok(_) => {}
305 Err(error) => warnings.push(format!("{error:#}")),
306 }
307 }
308 configs
309}
310
311fn is_source_file(path: &Path) -> bool {
312 path.extension()
313 .and_then(|ext| ext.to_str())
314 .is_some_and(crate::language::is_source_extension)
315}
316
317#[cfg(test)]
318mod tests {
319 use std::fs;
320
321 use tempfile::tempdir;
322
323 use super::RepoContext;
324
325 #[test]
326 fn discovers_source_files_and_tsconfig() {
327 let dir = tempdir().unwrap();
328 fs::create_dir_all(dir.path().join("src")).unwrap();
329 fs::write(
330 dir.path().join("tsconfig.json"),
331 r#"{"compilerOptions":{"baseUrl":".","paths":{"@ui/*":["packages/ui/*"]}}}"#,
332 )
333 .unwrap();
334 fs::write(
335 dir.path().join("src").join("Button.tsx"),
336 "export const Button = () => null;",
337 )
338 .unwrap();
339 fs::write(
340 dir.path().join("src").join("legacy.mjs"),
341 "export const legacy = true;",
342 )
343 .unwrap();
344 fs::write(dir.path().join("src").join("server.cts"), "export = {};").unwrap();
345 fs::write(
346 dir.path().join("src").join("helper.py"),
347 "def helper(): pass",
348 )
349 .unwrap();
350 fs::write(dir.path().join("src").join("lib.rs"), "pub fn helper() {}").unwrap();
351 fs::write(
352 dir.path().join("src").join("Button.vue"),
353 "<script setup>import x from './x'</script>",
354 )
355 .unwrap();
356 fs::write(
357 dir.path().join("src").join("Card.svelte"),
358 "<script>import x from './x'</script>",
359 )
360 .unwrap();
361 fs::write(dir.path().join("src").join("user.rb"), "class User; end").unwrap();
362 fs::write(dir.path().join("src").join("User.java"), "class User {}").unwrap();
363 fs::write(dir.path().join("package.json"), r#"{"name":"fixture"}"#).unwrap();
364
365 let repo = RepoContext::discover(dir.path()).unwrap();
366
367 let mut expected = 3;
368 if cfg!(feature = "python") {
369 expected += 1;
370 }
371 if cfg!(feature = "rust") {
372 expected += 1;
373 }
374 if cfg!(feature = "vue") {
375 expected += 1;
376 }
377 if cfg!(feature = "svelte") {
378 expected += 1;
379 }
380 if cfg!(feature = "ruby") {
381 expected += 1;
382 }
383 if cfg!(feature = "java") {
384 expected += 1;
385 }
386 assert_eq!(repo.source_files.len(), expected);
387 assert_eq!(repo.tsconfigs.len(), 1);
388 assert_eq!(repo.package_jsons.len(), 1);
389 }
390
391 #[test]
392 fn loads_ignore_unresolved_from_project_config() {
393 let dir = tempdir().unwrap();
394 fs::create_dir_all(dir.path().join("src")).unwrap();
395 fs::write(
396 dir.path().join("src").join("App.tsx"),
397 "export const App = () => null;",
398 )
399 .unwrap();
400 fs::write(
401 dir.path().join(".blast-radius.json"),
402 r#"{ "unresolved": { "ignore": ["styled-system/css", ".velite"] } }"#,
403 )
404 .unwrap();
405
406 let repo = RepoContext::discover(dir.path()).unwrap();
407
408 assert_eq!(
409 repo.ignore_unresolved,
410 vec!["styled-system/css".to_string(), ".velite".to_string()]
411 );
412 }
413
414 #[test]
415 fn defaults_ignore_unresolved_when_config_absent() {
416 let dir = tempdir().unwrap();
417 fs::create_dir_all(dir.path().join("src")).unwrap();
418 fs::write(
419 dir.path().join("src").join("App.tsx"),
420 "export const App = () => null;",
421 )
422 .unwrap();
423
424 let repo = RepoContext::discover(dir.path()).unwrap();
425
426 assert!(repo.ignore_unresolved.is_empty());
427 }
428
429 #[test]
430 fn reports_invalid_project_config_as_warning() {
431 let dir = tempdir().unwrap();
432 fs::write(dir.path().join(".blast-radius.json"), "{ not valid json").unwrap();
433
434 let repo = RepoContext::discover(dir.path()).unwrap();
435
436 assert!(repo.ignore_unresolved.is_empty());
437 assert!(
438 repo.warnings
439 .iter()
440 .any(|warning| warning.contains("failed to parse config")),
441 "invalid config should be reported as a discovery warning"
442 );
443 }
444
445 #[test]
446 fn reports_invalid_tsconfig_as_warning() {
447 let dir = tempdir().unwrap();
448 fs::create_dir_all(dir.path().join("src")).unwrap();
449 fs::write(dir.path().join("tsconfig.json"), "{ invalid json").unwrap();
450 fs::write(
451 dir.path().join("src").join("Button.tsx"),
452 "export const Button = () => null;",
453 )
454 .unwrap();
455
456 let repo = RepoContext::discover(dir.path()).unwrap();
457
458 assert_eq!(repo.source_files.len(), 1);
459 assert!(repo.tsconfigs.is_empty());
460 assert!(
461 repo.warnings
462 .iter()
463 .any(|warning| warning.contains("failed to parse tsconfig")),
464 "invalid tsconfig should be reported as a discovery warning"
465 );
466 }
467
468 #[cfg(feature = "python")]
469 #[test]
470 fn discovers_python_sources_when_enabled() {
471 let dir = tempdir().unwrap();
472 fs::create_dir_all(dir.path().join("src")).unwrap();
473 fs::write(
474 dir.path().join("src").join("helper.py"),
475 "def helper(): pass",
476 )
477 .unwrap();
478
479 let repo = RepoContext::discover(dir.path()).unwrap();
480
481 assert_eq!(repo.source_files.len(), 1);
482 }
483
484 #[cfg(feature = "rust")]
485 #[test]
486 fn discovers_rust_sources_when_enabled() {
487 let dir = tempdir().unwrap();
488 fs::create_dir_all(dir.path().join("src")).unwrap();
489 fs::write(dir.path().join("src").join("lib.rs"), "pub fn helper() {}").unwrap();
490
491 let repo = RepoContext::discover(dir.path()).unwrap();
492
493 assert_eq!(repo.source_files.len(), 1);
494 }
495
496 #[cfg(feature = "vue")]
497 #[test]
498 fn discovers_vue_sources_when_enabled() {
499 let dir = tempdir().unwrap();
500 fs::create_dir_all(dir.path().join("src")).unwrap();
501 fs::write(dir.path().join("src").join("Button.vue"), "<template />").unwrap();
502
503 let repo = RepoContext::discover(dir.path()).unwrap();
504
505 assert_eq!(repo.source_files.len(), 1);
506 }
507
508 #[cfg(feature = "svelte")]
509 #[test]
510 fn discovers_svelte_sources_when_enabled() {
511 let dir = tempdir().unwrap();
512 fs::create_dir_all(dir.path().join("src")).unwrap();
513 fs::write(
514 dir.path().join("src").join("Card.svelte"),
515 "<script></script>",
516 )
517 .unwrap();
518
519 let repo = RepoContext::discover(dir.path()).unwrap();
520
521 assert_eq!(repo.source_files.len(), 1);
522 }
523
524 #[cfg(feature = "ruby")]
525 #[test]
526 fn discovers_ruby_sources_when_enabled() {
527 let dir = tempdir().unwrap();
528 fs::create_dir_all(dir.path().join("lib")).unwrap();
529 fs::write(dir.path().join("lib").join("user.rb"), "class User; end").unwrap();
530
531 let repo = RepoContext::discover(dir.path()).unwrap();
532
533 assert_eq!(repo.source_files.len(), 1);
534 }
535
536 #[cfg(feature = "java")]
537 #[test]
538 fn discovers_java_sources_when_enabled() {
539 let dir = tempdir().unwrap();
540 fs::create_dir_all(dir.path().join("src")).unwrap();
541 fs::write(dir.path().join("src").join("User.java"), "class User {}").unwrap();
542
543 let repo = RepoContext::discover(dir.path()).unwrap();
544
545 assert_eq!(repo.source_files.len(), 1);
546 }
547}