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 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, 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 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}