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