ralph_workflow/language_detector/
mod.rs1#![deny(unsafe_code)]
19
20mod extensions;
21mod scanner;
22mod signatures;
23
24use std::collections::HashMap;
25use std::io;
26use std::path::Path;
27
28use crate::workspace::{Workspace, WorkspaceFs};
29
30pub use extensions::extension_to_language;
31use extensions::is_non_primary_language;
32
33const MAX_SECONDARY_LANGUAGES: usize = 6;
38
39const MIN_FILES_FOR_DETECTION: usize = 1;
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ProjectStack {
45 pub(crate) primary_language: String,
47 pub(crate) secondary_languages: Vec<String>,
49 pub(crate) frameworks: Vec<String>,
51 pub(crate) has_tests: bool,
53 pub(crate) test_framework: Option<String>,
55 pub(crate) package_manager: Option<String>,
57}
58
59impl Default for ProjectStack {
60 fn default() -> Self {
61 Self {
62 primary_language: "Unknown".to_string(),
63 secondary_languages: Vec::new(),
64 frameworks: Vec::new(),
65 has_tests: false,
66 test_framework: None,
67 package_manager: None,
68 }
69 }
70}
71
72impl ProjectStack {
73 pub(crate) fn is_rust(&self) -> bool {
75 self.primary_language == "Rust" || self.secondary_languages.iter().any(|l| l == "Rust")
76 }
77
78 pub(crate) fn is_python(&self) -> bool {
80 self.primary_language == "Python" || self.secondary_languages.iter().any(|l| l == "Python")
81 }
82
83 pub(crate) fn is_javascript_or_typescript(&self) -> bool {
85 matches!(self.primary_language.as_str(), "JavaScript" | "TypeScript")
86 || self
87 .secondary_languages
88 .iter()
89 .any(|l| l == "JavaScript" || l == "TypeScript")
90 }
91
92 pub(crate) fn is_go(&self) -> bool {
94 self.primary_language == "Go" || self.secondary_languages.iter().any(|l| l == "Go")
95 }
96
97 pub(crate) fn summary(&self) -> String {
99 let mut parts = vec![self.primary_language.clone()];
100
101 if !self.secondary_languages.is_empty() {
102 parts.push(format!("(+{})", self.secondary_languages.join(", ")));
103 }
104
105 if !self.frameworks.is_empty() {
106 parts.push(format!("[{}]", self.frameworks.join(", ")));
107 }
108
109 if self.has_tests {
110 if let Some(ref tf) = self.test_framework {
111 parts.push(format!("tests:{tf}"));
112 } else {
113 parts.push("tests:yes".to_string());
114 }
115 }
116
117 parts.join(" ")
118 }
119}
120
121pub fn detect_stack(root: &Path) -> io::Result<ProjectStack> {
130 let workspace = WorkspaceFs::new(root.to_path_buf());
131 detect_stack_with_workspace(&workspace, Path::new(""))
132}
133
134#[must_use]
136pub fn detect_stack_summary(root: &Path) -> String {
137 detect_stack(root).map_or_else(|_| "Unknown".to_string(), |stack| stack.summary())
138}
139
140#[cfg(test)]
141mod tests;
142
143pub fn detect_stack_with_workspace(
156 workspace: &dyn Workspace,
157 root: &Path,
158) -> io::Result<ProjectStack> {
159 let extension_counts = scanner::count_extensions_with_workspace(workspace, root)?;
161
162 let mut language_counts: HashMap<&str, usize> = HashMap::new();
164 for (ext, count) in &extension_counts {
165 if let Some(lang) = extension_to_language(ext) {
166 *language_counts.entry(lang).or_insert(0) += count;
167 }
168 }
169
170 let mut language_vec: Vec<_> = language_counts
172 .into_iter()
173 .filter(|(_, count)| *count >= MIN_FILES_FOR_DETECTION)
174 .collect();
175 language_vec.sort_by(|a, b| b.1.cmp(&a.1));
176
177 let primary_language = language_vec
179 .iter()
180 .find(|(lang, _)| !is_non_primary_language(lang))
181 .or_else(|| language_vec.first())
182 .map_or_else(|| "Unknown".to_string(), |(lang, _)| (*lang).to_string());
183
184 let secondary_languages: Vec<String> = language_vec
185 .iter()
186 .filter(|(lang, _)| *lang != primary_language.as_str())
187 .take(MAX_SECONDARY_LANGUAGES)
188 .map(|(lang, _)| (*lang).to_string())
189 .collect();
190
191 let (frameworks, test_framework, package_manager) =
193 signatures::detect_signature_files_with_workspace(workspace, root);
194
195 let has_tests = test_framework.is_some()
197 || scanner::detect_tests_with_workspace(workspace, root, &primary_language);
198
199 Ok(ProjectStack {
200 primary_language,
201 secondary_languages,
202 frameworks,
203 has_tests,
204 test_framework,
205 package_manager,
206 })
207}
208
209#[cfg(test)]
210mod workspace_tests {
211 use super::*;
212 use crate::workspace::MemoryWorkspace;
213
214 #[test]
215 fn test_detect_stack_with_workspace_rust_project() {
216 let workspace = MemoryWorkspace::new_test()
217 .with_file(
218 "Cargo.toml",
219 r#"
220[package]
221name = "test"
222[dependencies]
223axum = "0.7"
224[dev-dependencies]
225"#,
226 )
227 .with_file("src/main.rs", "fn main() {}")
228 .with_file("src/lib.rs", "pub mod foo;")
229 .with_file("tests/integration.rs", "#[test] fn test() {}");
230
231 let stack = detect_stack_with_workspace(&workspace, Path::new("")).unwrap();
232
233 assert_eq!(stack.primary_language, "Rust");
234 assert!(stack.frameworks.contains(&"Axum".to_string()));
235 assert!(stack.has_tests);
236 assert_eq!(stack.package_manager, Some("Cargo".to_string()));
237 }
238
239 #[test]
240 fn test_detect_stack_with_workspace_js_project() {
241 let workspace = MemoryWorkspace::new_test()
242 .with_file(
243 "package.json",
244 r#"
245{
246 "dependencies": { "react": "^18.0.0" },
247 "devDependencies": { "jest": "^29.0.0" }
248}
249
250"#,
251 )
252 .with_file("src/index.js", "export default {}")
253 .with_file("src/App.jsx", "export function App() {}")
254 .with_file("src/utils.js", "export const foo = 1");
255
256 let stack = detect_stack_with_workspace(&workspace, Path::new("")).unwrap();
257
258 assert_eq!(stack.primary_language, "JavaScript");
259 assert!(stack.frameworks.contains(&"React".to_string()));
260 assert_eq!(stack.test_framework, Some("Jest".to_string()));
261 }
262}