huskies: merge 1110 story Chat bootstrap Phase 2b: additional stack overlays (Go, Python, Ruby, JVM)

This commit is contained in:
dave
2026-05-16 23:15:02 +00:00
parent 6a2f81e873
commit efafe44db1
9 changed files with 341 additions and 14 deletions
+188 -14
View File
@@ -97,7 +97,8 @@ pub fn detect_stack(project_path: &Path, stacks_dir: &Path) -> (Option<String>,
Err(_) => return (None, vec![]),
};
let mut matched: Vec<String> = Vec::new();
// (stack_name, number_of_matched_marker_files)
let mut matched: Vec<(String, usize)> = Vec::new();
let mut stack_dirs: Vec<_> = entries
.filter_map(|e| e.ok())
@@ -112,26 +113,32 @@ pub fn detect_stack(project_path: &Path, stacks_dir: &Path) -> (Option<String>,
let Ok(content) = std::fs::read_to_string(&markers_path) else {
continue;
};
let hit = content.lines().any(|line| {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return false;
}
project_path.join(trimmed).exists()
});
if hit {
matched.push(stack_name);
let count = content
.lines()
.filter(|line| {
let trimmed = line.trim();
!trimmed.is_empty()
&& !trimmed.starts_with('#')
&& project_path.join(trimmed).exists()
})
.count();
if count > 0 {
matched.push((stack_name, count));
}
}
match matched.len() {
0 => (None, vec![]),
1 => (Some(matched.remove(0)), vec![]),
1 => (Some(matched.remove(0).0), vec![]),
_ => {
let names = matched.join(", ");
let chosen = matched.remove(0);
// Dominant stack: most marker files matched; alphabetical tiebreak for stability.
matched.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let names: Vec<String> = matched.iter().map(|(n, _)| n.clone()).collect();
let names_str = names.join(", ");
let chosen = matched.swap_remove(0).0;
let warning = format!(
"Multiple stacks detected ({names}); using **{chosen}**. \
"Multiple stacks detected ({names_str}); using **{chosen}** \
(most marker files matched). \
Pass `--stack <name>` to override."
);
(Some(chosen), vec![warning])
@@ -527,4 +534,171 @@ mod tests {
assert_eq!(stack, None);
assert!(warnings.is_empty());
}
#[test]
fn detect_stack_go_mod() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("go.mod"), "module example").unwrap();
let stacks = tempfile::tempdir().unwrap();
std::fs::create_dir_all(stacks.path().join("go")).unwrap();
std::fs::write(stacks.path().join("go/markers"), "go.mod\n").unwrap();
let (stack, warnings) = detect_stack(dir.path(), stacks.path());
assert_eq!(stack, Some("go".to_string()));
assert!(warnings.is_empty());
}
#[test]
fn detect_stack_python_pyproject() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("pyproject.toml"), "[tool.poetry]").unwrap();
let stacks = tempfile::tempdir().unwrap();
std::fs::create_dir_all(stacks.path().join("python")).unwrap();
std::fs::write(
stacks.path().join("python/markers"),
"pyproject.toml\nrequirements.txt\nsetup.py\n",
)
.unwrap();
let (stack, warnings) = detect_stack(dir.path(), stacks.path());
assert_eq!(stack, Some("python".to_string()));
assert!(warnings.is_empty());
}
#[test]
fn detect_stack_python_requirements_txt() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("requirements.txt"), "flask\n").unwrap();
let stacks = tempfile::tempdir().unwrap();
std::fs::create_dir_all(stacks.path().join("python")).unwrap();
std::fs::write(
stacks.path().join("python/markers"),
"pyproject.toml\nrequirements.txt\nsetup.py\n",
)
.unwrap();
let (stack, warnings) = detect_stack(dir.path(), stacks.path());
assert_eq!(stack, Some("python".to_string()));
assert!(warnings.is_empty());
}
#[test]
fn detect_stack_python_setup_py() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("setup.py"), "from setuptools import setup").unwrap();
let stacks = tempfile::tempdir().unwrap();
std::fs::create_dir_all(stacks.path().join("python")).unwrap();
std::fs::write(
stacks.path().join("python/markers"),
"pyproject.toml\nrequirements.txt\nsetup.py\n",
)
.unwrap();
let (stack, warnings) = detect_stack(dir.path(), stacks.path());
assert_eq!(stack, Some("python".to_string()));
assert!(warnings.is_empty());
}
#[test]
fn detect_stack_ruby_gemfile() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Gemfile"), "source 'https://rubygems.org'").unwrap();
let stacks = tempfile::tempdir().unwrap();
std::fs::create_dir_all(stacks.path().join("ruby")).unwrap();
std::fs::write(stacks.path().join("ruby/markers"), "Gemfile\n").unwrap();
let (stack, warnings) = detect_stack(dir.path(), stacks.path());
assert_eq!(stack, Some("ruby".to_string()));
assert!(warnings.is_empty());
}
#[test]
fn detect_stack_jvm_pom_xml() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("pom.xml"), "<project/>").unwrap();
let stacks = tempfile::tempdir().unwrap();
std::fs::create_dir_all(stacks.path().join("jvm")).unwrap();
std::fs::write(
stacks.path().join("jvm/markers"),
"pom.xml\nbuild.gradle\nbuild.gradle.kts\n",
)
.unwrap();
let (stack, warnings) = detect_stack(dir.path(), stacks.path());
assert_eq!(stack, Some("jvm".to_string()));
assert!(warnings.is_empty());
}
#[test]
fn detect_stack_jvm_build_gradle() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("build.gradle"), "plugins { }").unwrap();
let stacks = tempfile::tempdir().unwrap();
std::fs::create_dir_all(stacks.path().join("jvm")).unwrap();
std::fs::write(
stacks.path().join("jvm/markers"),
"pom.xml\nbuild.gradle\nbuild.gradle.kts\n",
)
.unwrap();
let (stack, warnings) = detect_stack(dir.path(), stacks.path());
assert_eq!(stack, Some("jvm".to_string()));
assert!(warnings.is_empty());
}
#[test]
fn detect_stack_jvm_build_gradle_kts() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("build.gradle.kts"), "plugins { }").unwrap();
let stacks = tempfile::tempdir().unwrap();
std::fs::create_dir_all(stacks.path().join("jvm")).unwrap();
std::fs::write(
stacks.path().join("jvm/markers"),
"pom.xml\nbuild.gradle\nbuild.gradle.kts\n",
)
.unwrap();
let (stack, warnings) = detect_stack(dir.path(), stacks.path());
assert_eq!(stack, Some("jvm".to_string()));
assert!(warnings.is_empty());
}
/// A polyglot repo with more Python markers than Node markers should prefer python.
#[test]
fn detect_stack_multiple_dominant_wins() {
let dir = tempfile::tempdir().unwrap();
// Two Python markers: pyproject.toml + requirements.txt
std::fs::write(dir.path().join("pyproject.toml"), "").unwrap();
std::fs::write(dir.path().join("requirements.txt"), "").unwrap();
// One Node marker: package.json (e.g. for a build tool)
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
let stacks = tempfile::tempdir().unwrap();
std::fs::create_dir_all(stacks.path().join("node")).unwrap();
std::fs::write(
stacks.path().join("node/markers"),
"package.json\ntsconfig.json\n",
)
.unwrap();
std::fs::create_dir_all(stacks.path().join("python")).unwrap();
std::fs::write(
stacks.path().join("python/markers"),
"pyproject.toml\nrequirements.txt\nsetup.py\n",
)
.unwrap();
let (stack, warnings) = detect_stack(dir.path(), stacks.path());
// python matches 2 markers, node matches 1 — python should win.
assert_eq!(stack, Some("python".to_string()));
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("Multiple stacks"));
}
}