use serde::Serialize;
use std::path::Path;
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct ProjectHints {
pub primary_language: Option<String>,
pub manifest: Option<String>,
pub entry_points: Vec<String>,
pub build_commands: Vec<String>,
pub onboarded: bool,
}
pub fn probe_project_hints(project_root: &Path, memories: &[String]) -> ProjectHints {
let onboarded = memories.iter().any(|m| m == "onboarding");
let info = detect_manifest_info(project_root);
let primary_language = crate::workspace::dominant_language(project_root)
.or_else(|| info.as_ref().map(|m| m.language.to_string()));
let (manifest, entry_points, build_commands) = match &info {
Some(m) => (
Some(m.manifest.to_string()),
probe_entry_points(project_root, m.language),
m.build_commands.iter().map(|s| s.to_string()).collect(),
),
None => (None, Vec::new(), Vec::new()),
};
ProjectHints {
primary_language,
manifest,
entry_points,
build_commands,
onboarded,
}
}
struct ManifestInfo {
manifest: &'static str,
language: &'static str,
build_commands: &'static [&'static str],
}
fn detect_manifest_info(project_root: &Path) -> Option<ManifestInfo> {
if project_root.join("package.json").exists() {
let is_ts = project_root.join("tsconfig.json").exists();
return Some(ManifestInfo {
manifest: "package.json",
language: if is_ts { "typescript" } else { "javascript" },
build_commands: &["npm test", "npm run build"],
});
}
const MANIFESTS: &[ManifestInfo] = &[
ManifestInfo {
manifest: "Cargo.toml",
language: "rust",
build_commands: &["cargo build", "cargo test", "cargo run"],
},
ManifestInfo {
manifest: "pyproject.toml",
language: "python",
build_commands: &["pytest", "python -m <package>"],
},
ManifestInfo {
manifest: "setup.py",
language: "python",
build_commands: &["pytest", "python setup.py test"],
},
ManifestInfo {
manifest: "go.mod",
language: "go",
build_commands: &["go build ./...", "go test ./..."],
},
ManifestInfo {
manifest: "pom.xml",
language: "java",
build_commands: &["mvn test", "mvn package"],
},
ManifestInfo {
manifest: "build.gradle.kts",
language: "kotlin",
build_commands: &["./gradlew build", "./gradlew test"],
},
ManifestInfo {
manifest: "build.gradle",
language: "kotlin",
build_commands: &["./gradlew build", "./gradlew test"],
},
];
for m in MANIFESTS {
if project_root.join(m.manifest).exists() {
return Some(ManifestInfo {
manifest: m.manifest,
language: m.language,
build_commands: m.build_commands,
});
}
}
None
}
fn probe_entry_points(project_root: &Path, language: &str) -> Vec<String> {
let candidates: &[&str] = match language {
"rust" => &["src/main.rs", "src/lib.rs"],
"typescript" => &["src/index.ts", "src/main.ts", "index.ts"],
"javascript" => &["src/index.js", "src/main.js", "index.js"],
"python" => &["__main__.py", "main.py", "src/main.py"],
"go" => &["main.go", "cmd/main.go"],
_ => &[],
};
candidates
.iter()
.filter(|p| project_root.join(p).exists())
.take(3)
.map(|s| s.to_string())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_memories() -> Vec<String> {
Vec::new()
}
#[test]
fn rust_project_with_main() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"").unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap();
let hints = probe_project_hints(dir.path(), &empty_memories());
assert_eq!(hints.primary_language.as_deref(), Some("rust"));
assert_eq!(hints.manifest.as_deref(), Some("Cargo.toml"));
assert_eq!(hints.entry_points, vec!["src/main.rs"]);
assert!(hints.build_commands.iter().any(|c| c == "cargo test"));
assert!(!hints.onboarded);
}
#[test]
fn rust_library_only() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"").unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/lib.rs"), "// lib").unwrap();
let hints = probe_project_hints(dir.path(), &empty_memories());
assert_eq!(hints.entry_points, vec!["src/lib.rs"]);
}
#[test]
fn typescript_project_detects_tsconfig() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
std::fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/index.ts"), "").unwrap();
let hints = probe_project_hints(dir.path(), &empty_memories());
assert_eq!(hints.primary_language.as_deref(), Some("typescript"));
assert_eq!(hints.manifest.as_deref(), Some("package.json"));
assert_eq!(hints.entry_points, vec!["src/index.ts"]);
}
#[test]
fn javascript_project_without_tsconfig() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
let hints = probe_project_hints(dir.path(), &empty_memories());
assert_eq!(hints.primary_language.as_deref(), Some("javascript"));
}
#[test]
fn package_json_preferred_over_pyproject() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
std::fs::write(dir.path().join("pyproject.toml"), "").unwrap();
let hints = probe_project_hints(dir.path(), &empty_memories());
assert_eq!(hints.primary_language.as_deref(), Some("javascript"));
assert_eq!(hints.manifest.as_deref(), Some("package.json"));
}
#[test]
fn no_manifest_returns_none() {
let dir = tempfile::tempdir().unwrap();
let hints = probe_project_hints(dir.path(), &empty_memories());
assert_eq!(hints.primary_language, None);
assert_eq!(hints.manifest, None);
assert!(hints.entry_points.is_empty());
assert!(hints.build_commands.is_empty());
}
#[test]
fn onboarded_flag_reflects_memory_presence() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
let with = probe_project_hints(dir.path(), &["onboarding".to_string()]);
assert!(with.onboarded);
let without = probe_project_hints(dir.path(), &["architecture".to_string()]);
assert!(!without.onboarded);
}
#[test]
fn entry_points_capped_at_three() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
std::fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/index.ts"), "").unwrap();
std::fs::write(dir.path().join("src/main.ts"), "").unwrap();
std::fs::write(dir.path().join("index.ts"), "").unwrap();
let hints = probe_project_hints(dir.path(), &empty_memories());
assert!(hints.entry_points.len() <= 3);
assert_eq!(
hints.entry_points,
vec!["src/index.ts", "src/main.ts", "index.ts"]
);
}
#[test]
fn python_project_probes_entry_points() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("pyproject.toml"), "").unwrap();
std::fs::write(dir.path().join("main.py"), "").unwrap();
let hints = probe_project_hints(dir.path(), &empty_memories());
assert_eq!(hints.primary_language.as_deref(), Some("python"));
assert_eq!(hints.entry_points, vec!["main.py"]);
}
#[test]
fn go_project() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("go.mod"), "module x").unwrap();
std::fs::write(dir.path().join("main.go"), "").unwrap();
let hints = probe_project_hints(dir.path(), &empty_memories());
assert_eq!(hints.primary_language.as_deref(), Some("go"));
assert!(hints.build_commands.iter().any(|c| c == "go test ./..."));
}
#[test]
fn kotlin_project_via_gradle_kts() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("build.gradle.kts"), "").unwrap();
let hints = probe_project_hints(dir.path(), &empty_memories());
assert_eq!(hints.primary_language.as_deref(), Some("kotlin"));
assert_eq!(hints.manifest.as_deref(), Some("build.gradle.kts"));
}
}