use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
mod cache;
mod detectors;
#[cfg(test)]
mod property_tests;
pub use cache::{CacheEntry, SystemInfoCache};
pub use detectors::*;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemInfo {
pub os: OsInfo,
pub shell: ShellInfo,
pub languages: Vec<LanguageInfo>,
pub package_managers: Vec<PackageManagerInfo>,
pub project: Option<ProjectInfo>,
pub git: Option<GitInfo>,
pub build_tools: Vec<BuildToolInfo>,
pub test_frameworks: Vec<TestFrameworkInfo>,
pub collected_at: std::time::SystemTime,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct OsInfo {
pub name: String,
pub version: String,
pub arch: String,
pub family: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ShellInfo {
pub name: String,
pub version: Option<String>,
pub path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LanguageInfo {
pub name: String,
pub version: String,
pub path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PackageManagerInfo {
pub name: String,
pub version: Option<String>,
pub path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProjectInfo {
pub project_type: ProjectType,
pub root: PathBuf,
pub name: Option<String>,
pub frameworks: Vec<String>,
pub source_dirs: Vec<PathBuf>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum ProjectType {
Rust,
Node,
Python,
Go,
Java,
CSharp,
Ruby,
Php,
Swift,
Kotlin,
Cpp,
C,
Mixed,
Unknown,
}
impl std::fmt::Display for ProjectType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProjectType::Rust => write!(f, "Rust"),
ProjectType::Node => write!(f, "Node.js"),
ProjectType::Python => write!(f, "Python"),
ProjectType::Go => write!(f, "Go"),
ProjectType::Java => write!(f, "Java"),
ProjectType::CSharp => write!(f, "C#"),
ProjectType::Ruby => write!(f, "Ruby"),
ProjectType::Php => write!(f, "PHP"),
ProjectType::Swift => write!(f, "Swift"),
ProjectType::Kotlin => write!(f, "Kotlin"),
ProjectType::Cpp => write!(f, "C++"),
ProjectType::C => write!(f, "C"),
ProjectType::Mixed => write!(f, "Mixed"),
ProjectType::Unknown => write!(f, "Unknown"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GitInfo {
pub branch: String,
pub remote: Option<String>,
pub is_dirty: bool,
pub ahead: u32,
pub behind: u32,
pub last_commit: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BuildToolInfo {
pub name: String,
pub version: Option<String>,
pub path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TestFrameworkInfo {
pub name: String,
pub version: Option<String>,
pub language: String,
}
pub struct SystemInfoProvider {
cache: SystemInfoCache,
ttl: Duration,
project_root: Option<PathBuf>,
}
impl Default for SystemInfoProvider {
fn default() -> Self {
Self::new(Duration::from_secs(300)) }
}
impl SystemInfoProvider {
pub fn new(ttl: Duration) -> Self {
Self {
cache: SystemInfoCache::new(),
ttl,
project_root: None,
}
}
pub fn with_project_root(mut self, root: impl AsRef<Path>) -> Self {
self.project_root = Some(root.as_ref().to_path_buf());
self
}
pub fn get(&mut self) -> crate::Result<SystemInfo> {
if let Some(cached) = self.cache.get_if_valid(self.ttl) {
return Ok(cached.clone());
}
self.refresh()
}
pub fn refresh(&mut self) -> crate::Result<SystemInfo> {
let info = SystemInfo {
os: self.detect_os()?,
shell: self.detect_shell()?,
languages: self.detect_languages(),
package_managers: self.detect_package_managers(),
project: self.detect_project(),
git: self.detect_git(),
build_tools: self.detect_build_tools(),
test_frameworks: self.detect_test_frameworks(),
collected_at: std::time::SystemTime::now(),
};
self.cache.set(info.clone());
Ok(info)
}
pub fn invalidate(&mut self) {
self.cache.invalidate();
}
pub fn is_cache_valid(&self) -> bool {
self.cache.is_valid(self.ttl)
}
pub fn detect_os(&self) -> crate::Result<OsInfo> {
Ok(OsInfo {
name: std::env::consts::OS.to_string(),
version: get_os_version(),
arch: std::env::consts::ARCH.to_string(),
family: std::env::consts::FAMILY.to_string(),
})
}
pub fn detect_shell(&self) -> crate::Result<ShellInfo> {
#[cfg(windows)]
{
if let Ok(output) = Command::new("powershell")
.args(["-Command", "$PSVersionTable.PSVersion.ToString()"])
.output()
{
if output.status.success() {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
return Ok(ShellInfo {
name: "powershell".to_string(),
version: Some(version),
path: PathBuf::from("powershell.exe"),
});
}
}
Ok(ShellInfo {
name: "cmd".to_string(),
version: None,
path: PathBuf::from("cmd.exe"),
})
}
#[cfg(not(windows))]
{
let shell_path = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
let shell_name = Path::new(&shell_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("sh")
.to_string();
let version = get_shell_version(&shell_name);
Ok(ShellInfo {
name: shell_name,
version,
path: PathBuf::from(shell_path),
})
}
}
pub fn detect_languages(&self) -> Vec<LanguageInfo> {
let mut languages = Vec::new();
if let Some(info) = detect_language("rustc", &["--version"], "rust") {
languages.push(info);
}
if let Some(info) = detect_language("python3", &["--version"], "python") {
languages.push(info);
} else if let Some(info) = detect_language("python", &["--version"], "python") {
languages.push(info);
}
if let Some(info) = detect_language("node", &["--version"], "node") {
languages.push(info);
}
if let Some(info) = detect_language("go", &["version"], "go") {
languages.push(info);
}
if let Some(info) = detect_language("java", &["-version"], "java") {
languages.push(info);
}
if let Some(info) = detect_language("ruby", &["--version"], "ruby") {
languages.push(info);
}
if let Some(info) = detect_language("php", &["--version"], "php") {
languages.push(info);
}
languages
}
pub fn detect_package_managers(&self) -> Vec<PackageManagerInfo> {
let mut managers = Vec::new();
if let Some(info) = detect_package_manager("cargo", &["--version"]) {
managers.push(info);
}
if let Some(info) = detect_package_manager("npm", &["--version"]) {
managers.push(info);
}
if let Some(info) = detect_package_manager("yarn", &["--version"]) {
managers.push(info);
}
if let Some(info) = detect_package_manager("pnpm", &["--version"]) {
managers.push(info);
}
if let Some(info) = detect_package_manager("pip", &["--version"]) {
managers.push(info);
}
if let Some(info) = detect_package_manager("go", &["version"]) {
let mut info = info;
info.name = "go mod".to_string();
managers.push(info);
}
managers
}
pub fn detect_project(&self) -> Option<ProjectInfo> {
let root = self.project_root.as_ref()?;
if !root.exists() {
return None;
}
let project_type = detect_project_type(root);
let name = detect_project_name(root, project_type);
let frameworks = detect_frameworks(root, project_type);
let source_dirs = detect_source_dirs(root, project_type);
Some(ProjectInfo {
project_type,
root: root.clone(),
name,
frameworks,
source_dirs,
})
}
pub fn detect_git(&self) -> Option<GitInfo> {
let root = self.project_root.as_ref()?;
if !root.join(".git").exists() {
return None;
}
let branch = run_git_command(root, &["rev-parse", "--abbrev-ref", "HEAD"])?;
let remote = run_git_command(root, &["config", "--get", "remote.origin.url"]);
let is_dirty = run_git_command(root, &["status", "--porcelain"])
.map(|s| !s.is_empty())
.unwrap_or(false);
let (ahead, behind) = get_git_ahead_behind(root);
let last_commit = run_git_command(root, &["rev-parse", "--short", "HEAD"]);
Some(GitInfo {
branch,
remote,
is_dirty,
ahead,
behind,
last_commit,
})
}
pub fn detect_build_tools(&self) -> Vec<BuildToolInfo> {
let mut tools = Vec::new();
if let Some(info) = detect_build_tool("cargo", &["--version"]) {
tools.push(info);
}
if let Some(info) = detect_build_tool("make", &["--version"]) {
tools.push(info);
}
if let Some(info) = detect_build_tool("cmake", &["--version"]) {
tools.push(info);
}
if let Some(info) = detect_build_tool("gradle", &["--version"]) {
tools.push(info);
}
if let Some(info) = detect_build_tool("mvn", &["--version"]) {
let mut info = info;
info.name = "maven".to_string();
tools.push(info);
}
tools
}
pub fn detect_test_frameworks(&self) -> Vec<TestFrameworkInfo> {
let mut frameworks = Vec::new();
if let Some(ref root) = self.project_root {
if root.join("Cargo.toml").exists() {
frameworks.push(TestFrameworkInfo {
name: "cargo test".to_string(),
version: None,
language: "rust".to_string(),
});
}
if root.join("package.json").exists() {
if let Some(fw) = detect_node_test_framework(root) {
frameworks.push(fw);
}
}
if root.join("pyproject.toml").exists() || root.join("setup.py").exists() {
if let Some(fw) = detect_python_test_framework(root) {
frameworks.push(fw);
}
}
}
frameworks
}
}
fn get_os_version() -> String {
#[cfg(windows)]
{
if let Ok(output) = Command::new("cmd").args(["/c", "ver"]).output() {
let version = String::from_utf8_lossy(&output.stdout);
if let Some(start) = version.find('[') {
if let Some(end) = version.find(']') {
return version[start + 1..end].replace("Version ", "");
}
}
}
"unknown".to_string()
}
#[cfg(target_os = "macos")]
{
if let Ok(output) = Command::new("sw_vers").args(["-productVersion"]).output() {
return String::from_utf8_lossy(&output.stdout).trim().to_string();
}
"unknown".to_string()
}
#[cfg(target_os = "linux")]
{
if let Ok(output) = Command::new("uname").args(["-r"]).output() {
return String::from_utf8_lossy(&output.stdout).trim().to_string();
}
"unknown".to_string()
}
#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
{
"unknown".to_string()
}
}
#[cfg(not(windows))]
fn get_shell_version(shell_name: &str) -> Option<String> {
let args = match shell_name {
"bash" => vec!["--version"],
"zsh" => vec!["--version"],
"fish" => vec!["--version"],
_ => return None,
};
Command::new(shell_name)
.args(&args)
.output()
.ok()
.and_then(|output| {
let version = String::from_utf8_lossy(&output.stdout);
version.lines().next().map(|s| s.to_string())
})
}
fn detect_language(cmd: &str, args: &[&str], name: &str) -> Option<LanguageInfo> {
let output = Command::new(cmd).args(args).output().ok()?;
if !output.status.success() {
return None;
}
let version_output = String::from_utf8_lossy(&output.stdout);
let version = extract_version(&version_output);
let path = which::which(cmd).ok()?;
Some(LanguageInfo {
name: name.to_string(),
version,
path,
})
}
fn detect_package_manager(cmd: &str, args: &[&str]) -> Option<PackageManagerInfo> {
let output = Command::new(cmd).args(args).output().ok()?;
if !output.status.success() {
return None;
}
let version_output = String::from_utf8_lossy(&output.stdout);
let version = Some(extract_version(&version_output));
let path = which::which(cmd).ok()?;
Some(PackageManagerInfo {
name: cmd.to_string(),
version,
path,
})
}
fn detect_build_tool(cmd: &str, args: &[&str]) -> Option<BuildToolInfo> {
let output = Command::new(cmd).args(args).output().ok()?;
if !output.status.success() {
return None;
}
let version_output = String::from_utf8_lossy(&output.stdout);
let version = Some(extract_version(&version_output));
let path = which::which(cmd).ok()?;
Some(BuildToolInfo {
name: cmd.to_string(),
version,
path,
})
}
fn extract_version(output: &str) -> String {
let re = regex::Regex::new(r"(\d+\.\d+(?:\.\d+)?(?:-[\w.]+)?)").ok();
if let Some(re) = re {
if let Some(caps) = re.captures(output) {
return caps
.get(1)
.map(|m| m.as_str().to_string())
.unwrap_or_default();
}
}
output.lines().next().unwrap_or("").trim().to_string()
}
fn detect_project_type(root: &Path) -> ProjectType {
let markers: Vec<(&str, ProjectType)> = vec![
("Cargo.toml", ProjectType::Rust),
("package.json", ProjectType::Node),
("pyproject.toml", ProjectType::Python),
("setup.py", ProjectType::Python),
("requirements.txt", ProjectType::Python),
("go.mod", ProjectType::Go),
("pom.xml", ProjectType::Java),
("build.gradle", ProjectType::Java),
("build.gradle.kts", ProjectType::Kotlin),
("*.csproj", ProjectType::CSharp),
("Gemfile", ProjectType::Ruby),
("composer.json", ProjectType::Php),
("Package.swift", ProjectType::Swift),
("CMakeLists.txt", ProjectType::Cpp),
("Makefile", ProjectType::C),
];
let mut detected = Vec::new();
for (marker, project_type) in markers {
if marker.contains('*') {
let pattern = root.join(marker);
if let Ok(entries) = glob::glob(pattern.to_str().unwrap_or("")) {
if entries.count() > 0 {
detected.push(project_type);
}
}
} else if root.join(marker).exists() {
detected.push(project_type);
}
}
match detected.len() {
0 => ProjectType::Unknown,
1 => detected[0],
_ => ProjectType::Mixed,
}
}
fn detect_project_name(root: &Path, project_type: ProjectType) -> Option<String> {
match project_type {
ProjectType::Rust => {
let cargo_toml = root.join("Cargo.toml");
if let Ok(content) = std::fs::read_to_string(&cargo_toml) {
if let Ok(parsed) = content.parse::<toml::Table>() {
return parsed
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.map(|s| s.to_string());
}
}
}
ProjectType::Node => {
let package_json = root.join("package.json");
if let Ok(content) = std::fs::read_to_string(&package_json) {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&content) {
return parsed
.get("name")
.and_then(|n| n.as_str())
.map(|s| s.to_string());
}
}
}
_ => {}
}
root.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
}
fn detect_frameworks(root: &Path, project_type: ProjectType) -> Vec<String> {
let mut frameworks = Vec::new();
match project_type {
ProjectType::Rust => {
if let Ok(content) = std::fs::read_to_string(root.join("Cargo.toml")) {
if content.contains("actix-web") {
frameworks.push("Actix Web".to_string());
}
if content.contains("axum") {
frameworks.push("Axum".to_string());
}
if content.contains("rocket") {
frameworks.push("Rocket".to_string());
}
if content.contains("tokio") {
frameworks.push("Tokio".to_string());
}
}
}
ProjectType::Node => {
if let Ok(content) = std::fs::read_to_string(root.join("package.json")) {
if content.contains("\"react\"") {
frameworks.push("React".to_string());
}
if content.contains("\"vue\"") {
frameworks.push("Vue".to_string());
}
if content.contains("\"next\"") {
frameworks.push("Next.js".to_string());
}
if content.contains("\"express\"") {
frameworks.push("Express".to_string());
}
if content.contains("\"nestjs\"") || content.contains("\"@nestjs") {
frameworks.push("NestJS".to_string());
}
}
}
ProjectType::Python => {
if let Ok(content) = std::fs::read_to_string(root.join("pyproject.toml")) {
if content.contains("django") {
frameworks.push("Django".to_string());
}
if content.contains("flask") {
frameworks.push("Flask".to_string());
}
if content.contains("fastapi") {
frameworks.push("FastAPI".to_string());
}
}
}
_ => {}
}
frameworks
}
fn detect_source_dirs(root: &Path, project_type: ProjectType) -> Vec<PathBuf> {
let mut dirs = Vec::new();
let common_dirs = match project_type {
ProjectType::Rust => vec!["src", "crates"],
ProjectType::Node => vec!["src", "lib", "app"],
ProjectType::Python => vec![
"src",
"lib",
root.file_name().and_then(|n| n.to_str()).unwrap_or(""),
],
ProjectType::Go => vec!["cmd", "pkg", "internal"],
ProjectType::Java => vec!["src/main/java", "src"],
_ => vec!["src", "lib"],
};
for dir in common_dirs {
let path = root.join(dir);
if path.exists() && path.is_dir() {
dirs.push(path);
}
}
dirs
}
fn run_git_command(root: &Path, args: &[&str]) -> Option<String> {
Command::new("git")
.current_dir(root)
.args(args)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
}
fn get_git_ahead_behind(root: &Path) -> (u32, u32) {
let output = run_git_command(
root,
&["rev-list", "--left-right", "--count", "HEAD...@{upstream}"],
);
if let Some(output) = output {
let parts: Vec<&str> = output.split_whitespace().collect();
if parts.len() == 2 {
let ahead = parts[0].parse().unwrap_or(0);
let behind = parts[1].parse().unwrap_or(0);
return (ahead, behind);
}
}
(0, 0)
}
fn detect_node_test_framework(root: &Path) -> Option<TestFrameworkInfo> {
let package_json = root.join("package.json");
if let Ok(content) = std::fs::read_to_string(&package_json) {
if content.contains("\"vitest\"") {
return Some(TestFrameworkInfo {
name: "vitest".to_string(),
version: None,
language: "javascript".to_string(),
});
}
if content.contains("\"jest\"") {
return Some(TestFrameworkInfo {
name: "jest".to_string(),
version: None,
language: "javascript".to_string(),
});
}
if content.contains("\"mocha\"") {
return Some(TestFrameworkInfo {
name: "mocha".to_string(),
version: None,
language: "javascript".to_string(),
});
}
}
None
}
fn detect_python_test_framework(root: &Path) -> Option<TestFrameworkInfo> {
if let Ok(content) = std::fs::read_to_string(root.join("pyproject.toml")) {
if content.contains("pytest") {
return Some(TestFrameworkInfo {
name: "pytest".to_string(),
version: None,
language: "python".to_string(),
});
}
}
if root.join("pytest.ini").exists() || root.join("conftest.py").exists() {
return Some(TestFrameworkInfo {
name: "pytest".to_string(),
version: None,
language: "python".to_string(),
});
}
Some(TestFrameworkInfo {
name: "unittest".to_string(),
version: None,
language: "python".to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_detect_os() {
let provider = SystemInfoProvider::default();
let os = provider.detect_os().unwrap();
assert!(!os.name.is_empty());
assert!(!os.arch.is_empty());
assert!(!os.family.is_empty());
}
#[test]
fn test_detect_shell() {
let provider = SystemInfoProvider::default();
let shell = provider.detect_shell().unwrap();
assert!(!shell.name.is_empty());
assert!(shell.path.exists() || cfg!(windows)); }
#[test]
fn test_detect_languages() {
let provider = SystemInfoProvider::default();
let languages = provider.detect_languages();
assert!(languages.len() >= 0);
}
#[test]
fn test_detect_rust_project() {
let temp = TempDir::new().unwrap();
fs::write(
temp.path().join("Cargo.toml"),
r#"[package]
name = "test-project"
version = "0.1.0"
"#,
)
.unwrap();
fs::create_dir(temp.path().join("src")).unwrap();
let provider = SystemInfoProvider::default().with_project_root(temp.path());
let project = provider.detect_project().unwrap();
assert_eq!(project.project_type, ProjectType::Rust);
assert_eq!(project.name, Some("test-project".to_string()));
}
#[test]
fn test_detect_node_project() {
let temp = TempDir::new().unwrap();
fs::write(
temp.path().join("package.json"),
r#"{"name": "test-node-project", "version": "1.0.0"}"#,
)
.unwrap();
let provider = SystemInfoProvider::default().with_project_root(temp.path());
let project = provider.detect_project().unwrap();
assert_eq!(project.project_type, ProjectType::Node);
assert_eq!(project.name, Some("test-node-project".to_string()));
}
#[test]
fn test_detect_git_repo() {
let temp = TempDir::new().unwrap();
let init_result = std::process::Command::new("git")
.current_dir(temp.path())
.args(["init"])
.output();
if init_result.is_err() || !temp.path().join(".git").exists() {
return;
}
let _ = std::process::Command::new("git")
.current_dir(temp.path())
.args(["config", "user.email", "test@test.com"])
.output();
let _ = std::process::Command::new("git")
.current_dir(temp.path())
.args(["config", "user.name", "Test"])
.output();
let test_file = temp.path().join("test.txt");
std::fs::write(&test_file, "test").unwrap();
let _ = std::process::Command::new("git")
.current_dir(temp.path())
.args(["add", "."])
.output();
let commit_result = std::process::Command::new("git")
.current_dir(temp.path())
.args(["commit", "-m", "initial"])
.output();
if commit_result.is_err() || !commit_result.unwrap().status.success() {
return;
}
let provider = SystemInfoProvider::default().with_project_root(temp.path());
let git = provider.detect_git();
assert!(
git.is_some(),
"Git should be detected when .git directory exists with commits"
);
}
#[test]
fn test_cache_behavior() {
let mut provider = SystemInfoProvider::new(Duration::from_secs(60));
let info1 = provider.get().unwrap();
assert!(provider.is_cache_valid());
let info2 = provider.get().unwrap();
assert_eq!(info1.os, info2.os);
provider.invalidate();
assert!(!provider.is_cache_valid());
}
#[test]
fn test_project_type_display() {
assert_eq!(ProjectType::Rust.to_string(), "Rust");
assert_eq!(ProjectType::Node.to_string(), "Node.js");
assert_eq!(ProjectType::Python.to_string(), "Python");
}
#[test]
fn test_extract_version() {
assert_eq!(extract_version("rustc 1.75.0"), "1.75.0");
assert_eq!(extract_version("Python 3.11.4"), "3.11.4");
assert_eq!(extract_version("v20.10.0"), "20.10.0");
}
#[test]
fn test_detect_frameworks_rust() {
let temp = TempDir::new().unwrap();
fs::write(
temp.path().join("Cargo.toml"),
r#"[dependencies]
tokio = "1.0"
axum = "0.7"
"#,
)
.unwrap();
let frameworks = detect_frameworks(temp.path(), ProjectType::Rust);
assert!(frameworks.contains(&"Tokio".to_string()));
assert!(frameworks.contains(&"Axum".to_string()));
}
#[test]
fn test_detect_frameworks_node() {
let temp = TempDir::new().unwrap();
fs::write(
temp.path().join("package.json"),
r#"{"dependencies": {"react": "^18.0.0", "next": "^14.0.0"}}"#,
)
.unwrap();
let frameworks = detect_frameworks(temp.path(), ProjectType::Node);
assert!(frameworks.contains(&"React".to_string()));
assert!(frameworks.contains(&"Next.js".to_string()));
}
}