1use std::path::{Path, PathBuf};
5use std::process::Command;
6
7use codemem_core::{CodememError, ScipConfig};
8
9use super::{parse_scip_bytes, ScipReadResult};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum ScipLanguage {
14 Rust,
15 TypeScript,
16 Python,
17 Java,
18 Go,
19 CSharp,
20 Ruby,
21 Php,
22 Dart,
23}
24
25impl ScipLanguage {
26 fn indexer_binary(&self) -> &'static str {
28 match self {
29 Self::Rust => "rust-analyzer",
30 Self::TypeScript => "scip-typescript",
31 Self::Python => "scip-python",
32 Self::Java => "scip-java",
33 Self::Go => "scip-go",
34 Self::CSharp => "scip-dotnet",
35 Self::Ruby => "scip-ruby",
36 Self::Php => "scip-php",
37 Self::Dart => "scip-dart",
38 }
39 }
40
41 fn default_args(&self) -> Vec<&'static str> {
43 match self {
44 Self::Rust => vec!["scip", "."],
45 Self::TypeScript => vec!["index"],
46 Self::Python => vec!["index", "."],
47 Self::Java => vec!["index"],
48 Self::Go => vec![],
49 Self::CSharp => vec!["index"],
50 Self::Ruby => vec![],
51 Self::Php => vec!["index"],
52 Self::Dart => vec![],
53 }
54 }
55
56 fn default_output_file(&self) -> &'static str {
58 "index.scip"
60 }
61
62 fn name(&self) -> &'static str {
63 match self {
64 Self::Rust => "rust",
65 Self::TypeScript => "typescript",
66 Self::Python => "python",
67 Self::Java => "java",
68 Self::Go => "go",
69 Self::CSharp => "csharp",
70 Self::Ruby => "ruby",
71 Self::Php => "php",
72 Self::Dart => "dart",
73 }
74 }
75}
76
77const MANIFEST_LANGUAGES: &[(&str, ScipLanguage)] = &[
79 ("Cargo.toml", ScipLanguage::Rust),
80 ("package.json", ScipLanguage::TypeScript),
81 ("tsconfig.json", ScipLanguage::TypeScript),
82 ("pyproject.toml", ScipLanguage::Python),
83 ("setup.py", ScipLanguage::Python),
84 ("setup.cfg", ScipLanguage::Python),
85 ("go.mod", ScipLanguage::Go),
86 ("pom.xml", ScipLanguage::Java),
87 ("build.gradle", ScipLanguage::Java),
88 ("build.gradle.kts", ScipLanguage::Java),
89 ("pubspec.yaml", ScipLanguage::Dart),
90 ("Gemfile", ScipLanguage::Ruby),
91 ("composer.json", ScipLanguage::Php),
92];
93
94#[derive(Debug)]
96pub struct OrchestrationResult {
97 pub scip_result: ScipReadResult,
99 pub indexed_languages: Vec<ScipLanguage>,
101 pub failed_languages: Vec<(ScipLanguage, String)>,
103}
104
105impl OrchestrationResult {
106 fn empty(project_root: &Path) -> Self {
108 Self {
109 scip_result: ScipReadResult {
110 project_root: project_root.to_string_lossy().to_string(),
111 definitions: Vec::new(),
112 references: Vec::new(),
113 externals: Vec::new(),
114 covered_files: Vec::new(),
115 },
116 indexed_languages: Vec::new(),
117 failed_languages: Vec::new(),
118 }
119 }
120}
121
122pub struct ScipOrchestrator {
124 config: ScipConfig,
125}
126
127impl ScipOrchestrator {
128 pub fn new(config: ScipConfig) -> Self {
129 Self { config }
130 }
131
132 pub fn run(
134 &self,
135 project_root: &Path,
136 namespace: &str,
137 ) -> Result<OrchestrationResult, CodememError> {
138 let detected_languages = self.detect_languages(project_root);
140 if detected_languages.is_empty() {
141 return Ok(OrchestrationResult::empty(project_root));
142 }
143
144 let available = self.detect_available_indexers(&detected_languages);
146 if available.is_empty() {
147 tracing::info!("No SCIP indexers found on PATH for detected languages");
148 return Ok(OrchestrationResult::empty(project_root));
149 }
150
151 let mut indexed_languages = Vec::new();
153 let mut failed_languages = Vec::new();
154 let mut scip_files: Vec<PathBuf> = Vec::new();
155
156 let temp_dir = tempfile::tempdir().map_err(|e| {
157 CodememError::ScipOrchestration(format!("Failed to create temp dir: {e}"))
158 })?;
159
160 let cache_dir = if self.config.cache_index {
162 scip_cache_dir(namespace)
163 } else {
164 None
165 };
166
167 for lang in &available {
168 if let Some(ref cache) = cache_dir {
170 if let Some(status) = check_cache(cache, *lang, self.config.cache_ttl_hours) {
171 if status.valid {
172 tracing::info!(
173 "Using cached SCIP index for {} ({})",
174 lang.name(),
175 status.path.display()
176 );
177 scip_files.push(status.path);
178 indexed_languages.push(*lang);
179 continue;
180 }
181 }
182 }
183
184 let output_path = temp_dir.path().join(format!("index-{}.scip", lang.name()));
185
186 match self.run_indexer(*lang, project_root, &output_path, namespace) {
187 Ok(()) => {
188 let scip_path = if output_path.exists() {
190 output_path
191 } else {
192 let default_path = project_root.join(lang.default_output_file());
193 if default_path.exists() {
194 default_path
195 } else {
196 failed_languages.push((
197 *lang,
198 "Indexer exited successfully but produced no .scip file"
199 .to_string(),
200 ));
201 continue;
202 }
203 };
204
205 if let Some(ref cache) = cache_dir {
207 save_to_cache(cache, *lang, &scip_path);
208 }
209
210 scip_files.push(scip_path);
211 indexed_languages.push(*lang);
212 }
213 Err(e) => {
214 tracing::warn!("SCIP indexer for {} failed: {}", lang.name(), e);
215 failed_languages.push((*lang, e.to_string()));
216 }
217 }
218 }
219
220 let scip_result = self.merge_scip_files(&scip_files, project_root)?;
222
223 Ok(OrchestrationResult {
224 scip_result,
225 indexed_languages,
226 failed_languages,
227 })
228 }
229
230 pub fn detect_languages(&self, project_root: &Path) -> Vec<ScipLanguage> {
232 let mut found = std::collections::HashSet::new();
233
234 let walker = ignore::WalkBuilder::new(project_root)
235 .hidden(true)
236 .git_ignore(true)
237 .git_global(true)
238 .git_exclude(true)
239 .max_depth(Some(3)) .build();
241
242 for entry in walker.flatten() {
243 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
244 continue;
245 }
246 let file_name = entry
247 .path()
248 .file_name()
249 .and_then(|n| n.to_str())
250 .unwrap_or("");
251
252 for &(manifest, lang) in MANIFEST_LANGUAGES {
253 if file_name == manifest {
254 found.insert(lang);
255 }
256 }
257
258 if file_name.ends_with(".csproj") {
260 found.insert(ScipLanguage::CSharp);
261 }
262 }
263
264 found.into_iter().collect()
265 }
266
267 pub fn detect_available_indexers(&self, languages: &[ScipLanguage]) -> Vec<ScipLanguage> {
269 let mut available = Vec::new();
270
271 for &lang in languages {
272 if self.config_command_for(lang).is_some() {
274 available.push(lang);
275 continue;
276 }
277
278 if !self.config.auto_detect_indexers {
280 continue;
281 }
282 if which_binary(lang.indexer_binary()).is_some() {
283 available.push(lang);
284 }
285 }
286
287 available
288 }
289
290 fn run_indexer(
292 &self,
293 lang: ScipLanguage,
294 project_root: &Path,
295 output_path: &Path,
296 namespace: &str,
297 ) -> Result<(), CodememError> {
298 let (program, args) = if let Some(cmd) = self.config_command_for(lang) {
299 let expanded = cmd.replace("{namespace}", namespace);
301 parse_shell_command(&expanded)?
302 } else {
303 let binary_name = lang.indexer_binary();
307 let resolved = which_binary(binary_name)
308 .map(|p| p.display().to_string())
309 .unwrap_or_else(|| binary_name.to_string());
310 (
311 resolved,
312 lang.default_args().iter().map(|s| s.to_string()).collect(),
313 )
314 };
315
316 tracing::info!(
317 "Running SCIP indexer for {}: {} {:?}",
318 lang.name(),
319 program,
320 args
321 );
322
323 let path_env = augmented_path();
327
328 let output = Command::new(&program)
329 .args(&args)
330 .current_dir(project_root)
331 .env("PATH", &path_env)
332 .output()
333 .map_err(|e| {
334 CodememError::ScipOrchestration(format!("Failed to spawn {program}: {e}"))
335 })?;
336
337 if !output.status.success() {
338 let stderr = String::from_utf8_lossy(&output.stderr);
339 return Err(CodememError::ScipOrchestration(format!(
340 "{} exited with {}: {}",
341 program,
342 output.status,
343 stderr.trim()
344 )));
345 }
346
347 if !output_path.exists() {
350 let default_output = project_root.join(lang.default_output_file());
351 if default_output.exists() {
352 std::fs::rename(&default_output, output_path).map_err(|e| {
353 CodememError::ScipOrchestration(format!(
354 "Failed to move {}: {e}",
355 default_output.display()
356 ))
357 })?;
358 }
359 }
360
361 Ok(())
362 }
363
364 fn config_command_for(&self, lang: ScipLanguage) -> Option<&String> {
366 let cmd = match lang {
367 ScipLanguage::Rust => &self.config.indexers.rust,
368 ScipLanguage::TypeScript => &self.config.indexers.typescript,
369 ScipLanguage::Python => &self.config.indexers.python,
370 ScipLanguage::Java => &self.config.indexers.java,
371 ScipLanguage::Go => &self.config.indexers.go,
372 ScipLanguage::CSharp | ScipLanguage::Ruby | ScipLanguage::Php | ScipLanguage::Dart => {
374 return None;
375 }
376 };
377 if cmd.is_empty() {
378 None
379 } else {
380 Some(cmd)
381 }
382 }
383
384 fn merge_scip_files(
386 &self,
387 paths: &[PathBuf],
388 project_root: &Path,
389 ) -> Result<ScipReadResult, CodememError> {
390 let mut merged = ScipReadResult {
391 project_root: project_root.to_string_lossy().to_string(),
392 definitions: Vec::new(),
393 references: Vec::new(),
394 externals: Vec::new(),
395 covered_files: Vec::new(),
396 };
397
398 for path in paths {
399 let bytes = std::fs::read(path).map_err(|e| {
400 CodememError::ScipOrchestration(format!("Failed to read {}: {e}", path.display()))
401 })?;
402 let result = parse_scip_bytes(&bytes).map_err(CodememError::ScipOrchestration)?;
403 merged.definitions.extend(result.definitions);
404 merged.references.extend(result.references);
405 merged.externals.extend(result.externals);
406 merged.covered_files.extend(result.covered_files);
407 }
408
409 merged.covered_files.sort();
411 merged.covered_files.dedup();
412
413 Ok(merged)
414 }
415}
416
417fn which_binary(name: &str) -> Option<PathBuf> {
419 which::which(name).ok()
420}
421
422fn augmented_path() -> String {
426 let current = std::env::var("PATH").unwrap_or_default();
427 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
428
429 let extra_dirs = [
430 home.join(".cargo/bin"),
431 home.join(".local/bin"),
432 home.join(".nvm/current/bin"),
433 PathBuf::from("/usr/local/bin"),
434 PathBuf::from("/opt/homebrew/bin"),
435 ];
436
437 let mut parts: Vec<String> = vec![current];
438 for dir in &extra_dirs {
439 if dir.is_dir() {
440 parts.push(dir.display().to_string());
441 }
442 }
443 parts.join(":")
444}
445
446fn parse_shell_command(cmd: &str) -> Result<(String, Vec<String>), CodememError> {
450 let parts: Vec<&str> = cmd.split_whitespace().collect();
451 if parts.is_empty() {
452 return Err(CodememError::ScipOrchestration(
453 "Empty command string".to_string(),
454 ));
455 }
456 let program = parts[0].to_string();
457 let args = parts[1..].iter().map(|s| s.to_string()).collect();
458 Ok((program, args))
459}
460
461pub struct CacheStatus {
463 pub path: PathBuf,
465 pub valid: bool,
467}
468
469fn scip_cache_dir(namespace: &str) -> Option<PathBuf> {
472 let home = dirs::home_dir()?;
473 let dir = home.join(".codemem").join("scip-cache").join(namespace);
474 std::fs::create_dir_all(&dir).ok()?;
475 Some(dir)
476}
477
478pub fn check_cache(cache_dir: &Path, lang: ScipLanguage, ttl_hours: u64) -> Option<CacheStatus> {
480 let cache_path = cache_dir.join(format!("index-{}.scip", lang.name()));
481 if !cache_path.exists() {
482 return None;
483 }
484
485 let metadata = std::fs::metadata(&cache_path).ok()?;
486 let modified = metadata.modified().ok()?;
487 let age = modified.elapsed().ok()?;
488 let valid = age.as_secs() < ttl_hours * 3600;
489
490 Some(CacheStatus {
491 path: cache_path,
492 valid,
493 })
494}
495
496fn save_to_cache(cache_dir: &Path, lang: ScipLanguage, source_path: &Path) {
498 let cache_path = cache_dir.join(format!("index-{}.scip", lang.name()));
499 if let Err(e) = std::fs::copy(source_path, &cache_path) {
500 tracing::warn!("Failed to cache SCIP index for {}: {e}", lang.name());
501 }
502}
503
504#[cfg(test)]
505#[path = "../tests/scip_orchestrate_tests.rs"]
506mod tests;