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