use std::collections::HashMap;
use std::path::Path;
use crate::error::StepError;
use crate::prompts::registry::{Registry, StackDef};
#[derive(Debug, Clone)]
pub struct StackInfo {
pub name: String,
pub parent_chain: Vec<String>,
pub tools: HashMap<String, String>,
}
pub struct StackDetector;
impl StackDetector {
pub async fn detect(registry: &Registry, workspace_path: &Path) -> Result<StackInfo, StepError> {
let mut checked_markers: Vec<String> = Vec::new();
for stack_name in ®istry.detection_order {
let stack_def = match registry.stacks.get(stack_name) {
Some(def) => def,
None => continue,
};
if stack_def.file_markers.is_empty() && stack_def.content_match.is_empty() {
continue;
}
if !stack_def.file_markers.is_empty() {
let mut any_marker_found = false;
for marker in &stack_def.file_markers {
checked_markers.push(marker.clone());
if tokio::fs::metadata(workspace_path.join(marker))
.await
.is_ok()
{
any_marker_found = true;
break;
}
}
if !any_marker_found {
continue;
}
}
if !Self::content_matches(stack_def, workspace_path).await {
continue;
}
return Ok(Self::build_stack_info(stack_name, registry));
}
let markers_list = checked_markers.join(", ");
Err(StepError::Fail(format!(
"Could not detect project stack in '{}'. Checked markers: [{}]. \
Create prompts/registry.yaml with your stack definition.",
workspace_path.display(),
markers_list
)))
}
async fn content_matches(stack_def: &StackDef, workspace_path: &Path) -> bool {
for (filename, pattern) in &stack_def.content_match {
match tokio::fs::read_to_string(workspace_path.join(filename)).await {
Ok(content) if content.contains(pattern.as_str()) => {}
_ => return false,
}
}
true
}
fn build_stack_info(name: &str, registry: &Registry) -> StackInfo {
let mut parent_chain: Vec<String> = Vec::new();
let mut current = registry.stacks.get(name).and_then(|s| s.parent.as_deref());
while let Some(parent_name) = current {
parent_chain.push(parent_name.to_string());
current = registry
.stacks
.get(parent_name)
.and_then(|s| s.parent.as_deref());
}
let mut full_chain: Vec<&str> = vec![name];
full_chain.extend(parent_chain.iter().map(String::as_str));
full_chain.reverse();
let mut tools: HashMap<String, String> = HashMap::new();
for stack_name in &full_chain {
if let Some(stack_def) = registry.stacks.get(*stack_name) {
tools.extend(stack_def.tools.clone());
}
}
StackInfo {
name: name.to_string(),
parent_chain,
tools,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::prompts::registry::{Registry, StackDef};
use std::io::Write as _;
use tempfile::tempdir;
fn make_registry() -> Registry {
let mut stacks = HashMap::new();
stacks.insert(
"_default".to_string(),
StackDef {
parent: None,
file_markers: vec![],
content_match: HashMap::new(),
tools: {
let mut t = HashMap::new();
t.insert("lint".to_string(), "echo 'no linter'".to_string());
t.insert("test".to_string(), "echo 'no test'".to_string());
t.insert("build".to_string(), "echo 'no build'".to_string());
t
},
},
);
stacks.insert(
"rust".to_string(),
StackDef {
parent: Some("_default".to_string()),
file_markers: vec!["Cargo.toml".to_string()],
content_match: HashMap::new(),
tools: {
let mut t = HashMap::new();
t.insert("lint".to_string(), "cargo clippy -- -D warnings".to_string());
t.insert("test".to_string(), "cargo test".to_string());
t.insert("build".to_string(), "cargo build --release".to_string());
t
},
},
);
stacks.insert(
"java".to_string(),
StackDef {
parent: Some("_default".to_string()),
file_markers: vec!["pom.xml".to_string(), "build.gradle".to_string()],
content_match: HashMap::new(),
tools: {
let mut t = HashMap::new();
t.insert("test".to_string(), "mvn test".to_string());
t.insert("build".to_string(), "mvn package -DskipTests".to_string());
t
},
},
);
stacks.insert(
"java-spring".to_string(),
StackDef {
parent: Some("java".to_string()),
file_markers: vec!["pom.xml".to_string()],
content_match: {
let mut m = HashMap::new();
m.insert("pom.xml".to_string(), "spring-boot".to_string());
m
},
tools: {
let mut t = HashMap::new();
t.insert(
"test".to_string(),
"mvn test -Dspring.profiles.active=test".to_string(),
);
t
},
},
);
stacks.insert(
"javascript".to_string(),
StackDef {
parent: Some("_default".to_string()),
file_markers: vec!["package.json".to_string()],
content_match: HashMap::new(),
tools: {
let mut t = HashMap::new();
t.insert("test".to_string(), "npm test".to_string());
t
},
},
);
Registry {
version: 1,
detection_order: vec![
"java-spring".to_string(),
"java".to_string(),
"javascript".to_string(),
"rust".to_string(),
],
stacks,
}
}
#[tokio::test]
async fn test_detect_rust_project() {
let dir = tempdir().unwrap();
std::fs::File::create(dir.path().join("Cargo.toml")).unwrap();
let registry = make_registry();
let result = StackDetector::detect(®istry, dir.path()).await.unwrap();
assert_eq!(result.name, "rust");
assert_eq!(result.parent_chain, vec!["_default"]);
}
#[tokio::test]
async fn test_detect_java_spring_project() {
let dir = tempdir().unwrap();
let mut f = std::fs::File::create(dir.path().join("pom.xml")).unwrap();
f.write_all(b"<project><parent><artifactId>spring-boot-starter-parent</artifactId></parent></project>")
.unwrap();
let registry = make_registry();
let result = StackDetector::detect(®istry, dir.path()).await.unwrap();
assert_eq!(result.name, "java-spring");
}
#[tokio::test]
async fn test_detection_order_java_spring_before_java() {
let dir = tempdir().unwrap();
let mut f = std::fs::File::create(dir.path().join("pom.xml")).unwrap();
f.write_all(b"<project>spring-boot</project>").unwrap();
let registry = make_registry();
let result = StackDetector::detect(®istry, dir.path()).await.unwrap();
assert_eq!(result.name, "java-spring");
}
#[tokio::test]
async fn test_content_match_failure_falls_through_to_less_specific_stack() {
let dir = tempdir().unwrap();
let mut f = std::fs::File::create(dir.path().join("pom.xml")).unwrap();
f.write_all(b"<project><groupId>com.example</groupId></project>")
.unwrap();
let registry = make_registry();
let result = StackDetector::detect(®istry, dir.path()).await.unwrap();
assert_eq!(result.name, "java");
}
#[tokio::test]
async fn test_no_stack_detected_returns_step_error_fail() {
let dir = tempdir().unwrap();
let registry = make_registry();
let result = StackDetector::detect(®istry, dir.path()).await;
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("Could not detect project stack"),
"Expected error message, got: {msg}"
);
}
#[tokio::test]
async fn test_parent_chain_and_tool_merging_for_rust() {
let dir = tempdir().unwrap();
std::fs::File::create(dir.path().join("Cargo.toml")).unwrap();
let registry = make_registry();
let result = StackDetector::detect(®istry, dir.path()).await.unwrap();
assert_eq!(result.parent_chain, vec!["_default"]);
assert_eq!(result.tools.get("test").unwrap(), "cargo test");
assert_eq!(
result.tools.get("lint").unwrap(),
"cargo clippy -- -D warnings"
);
assert_eq!(result.tools.get("build").unwrap(), "cargo build --release");
}
#[tokio::test]
async fn test_java_spring_parent_chain() {
let dir = tempdir().unwrap();
let mut f = std::fs::File::create(dir.path().join("pom.xml")).unwrap();
f.write_all(b"spring-boot").unwrap();
let registry = make_registry();
let result = StackDetector::detect(®istry, dir.path()).await.unwrap();
assert_eq!(result.parent_chain, vec!["java", "_default"]);
assert_eq!(
result.tools.get("test").unwrap(),
"mvn test -Dspring.profiles.active=test"
);
}
}