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("package.json"), r#"{"name":"fixture"}"#).unwrap();
362
363 let repo = RepoContext::discover(dir.path()).unwrap();
364
365 let mut expected = 3;
366 if cfg!(feature = "python") {
367 expected += 1;
368 }
369 if cfg!(feature = "rust") {
370 expected += 1;
371 }
372 if cfg!(feature = "vue") {
373 expected += 1;
374 }
375 if cfg!(feature = "svelte") {
376 expected += 1;
377 }
378 assert_eq!(repo.source_files.len(), expected);
379 assert_eq!(repo.tsconfigs.len(), 1);
380 assert_eq!(repo.package_jsons.len(), 1);
381 }
382
383 #[test]
384 fn loads_ignore_unresolved_from_project_config() {
385 let dir = tempdir().unwrap();
386 fs::create_dir_all(dir.path().join("src")).unwrap();
387 fs::write(
388 dir.path().join("src").join("App.tsx"),
389 "export const App = () => null;",
390 )
391 .unwrap();
392 fs::write(
393 dir.path().join(".blast-radius.json"),
394 r#"{ "unresolved": { "ignore": ["styled-system/css", ".velite"] } }"#,
395 )
396 .unwrap();
397
398 let repo = RepoContext::discover(dir.path()).unwrap();
399
400 assert_eq!(
401 repo.ignore_unresolved,
402 vec!["styled-system/css".to_string(), ".velite".to_string()]
403 );
404 }
405
406 #[test]
407 fn defaults_ignore_unresolved_when_config_absent() {
408 let dir = tempdir().unwrap();
409 fs::create_dir_all(dir.path().join("src")).unwrap();
410 fs::write(
411 dir.path().join("src").join("App.tsx"),
412 "export const App = () => null;",
413 )
414 .unwrap();
415
416 let repo = RepoContext::discover(dir.path()).unwrap();
417
418 assert!(repo.ignore_unresolved.is_empty());
419 }
420
421 #[test]
422 fn reports_invalid_project_config_as_warning() {
423 let dir = tempdir().unwrap();
424 fs::write(dir.path().join(".blast-radius.json"), "{ not valid json").unwrap();
425
426 let repo = RepoContext::discover(dir.path()).unwrap();
427
428 assert!(repo.ignore_unresolved.is_empty());
429 assert!(
430 repo.warnings
431 .iter()
432 .any(|warning| warning.contains("failed to parse config")),
433 "invalid config should be reported as a discovery warning"
434 );
435 }
436
437 #[test]
438 fn reports_invalid_tsconfig_as_warning() {
439 let dir = tempdir().unwrap();
440 fs::create_dir_all(dir.path().join("src")).unwrap();
441 fs::write(dir.path().join("tsconfig.json"), "{ invalid json").unwrap();
442 fs::write(
443 dir.path().join("src").join("Button.tsx"),
444 "export const Button = () => null;",
445 )
446 .unwrap();
447
448 let repo = RepoContext::discover(dir.path()).unwrap();
449
450 assert_eq!(repo.source_files.len(), 1);
451 assert!(repo.tsconfigs.is_empty());
452 assert!(
453 repo.warnings
454 .iter()
455 .any(|warning| warning.contains("failed to parse tsconfig")),
456 "invalid tsconfig should be reported as a discovery warning"
457 );
458 }
459
460 #[cfg(feature = "python")]
461 #[test]
462 fn discovers_python_sources_when_enabled() {
463 let dir = tempdir().unwrap();
464 fs::create_dir_all(dir.path().join("src")).unwrap();
465 fs::write(
466 dir.path().join("src").join("helper.py"),
467 "def helper(): pass",
468 )
469 .unwrap();
470
471 let repo = RepoContext::discover(dir.path()).unwrap();
472
473 assert_eq!(repo.source_files.len(), 1);
474 }
475
476 #[cfg(feature = "rust")]
477 #[test]
478 fn discovers_rust_sources_when_enabled() {
479 let dir = tempdir().unwrap();
480 fs::create_dir_all(dir.path().join("src")).unwrap();
481 fs::write(dir.path().join("src").join("lib.rs"), "pub fn helper() {}").unwrap();
482
483 let repo = RepoContext::discover(dir.path()).unwrap();
484
485 assert_eq!(repo.source_files.len(), 1);
486 }
487
488 #[cfg(feature = "vue")]
489 #[test]
490 fn discovers_vue_sources_when_enabled() {
491 let dir = tempdir().unwrap();
492 fs::create_dir_all(dir.path().join("src")).unwrap();
493 fs::write(dir.path().join("src").join("Button.vue"), "<template />").unwrap();
494
495 let repo = RepoContext::discover(dir.path()).unwrap();
496
497 assert_eq!(repo.source_files.len(), 1);
498 }
499
500 #[cfg(feature = "svelte")]
501 #[test]
502 fn discovers_svelte_sources_when_enabled() {
503 let dir = tempdir().unwrap();
504 fs::create_dir_all(dir.path().join("src")).unwrap();
505 fs::write(
506 dir.path().join("src").join("Card.svelte"),
507 "<script></script>",
508 )
509 .unwrap();
510
511 let repo = RepoContext::discover(dir.path()).unwrap();
512
513 assert_eq!(repo.source_files.len(), 1);
514 }
515}