1use serde::{Deserialize, Serialize};
13use std::path::{Path, PathBuf};
14use std::process::Command;
15use std::time::Duration;
16
17mod cache;
18mod detectors;
19
20#[cfg(test)]
21mod property_tests;
22
23pub use cache::{CacheEntry, SystemInfoCache};
24pub use detectors::*;
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct SystemInfo {
29 pub os: OsInfo,
31 pub shell: ShellInfo,
33 pub languages: Vec<LanguageInfo>,
35 pub package_managers: Vec<PackageManagerInfo>,
37 pub project: Option<ProjectInfo>,
39 pub git: Option<GitInfo>,
41 pub build_tools: Vec<BuildToolInfo>,
43 pub test_frameworks: Vec<TestFrameworkInfo>,
45 pub collected_at: std::time::SystemTime,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub struct OsInfo {
52 pub name: String,
54 pub version: String,
56 pub arch: String,
58 pub family: String,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
64pub struct ShellInfo {
65 pub name: String,
67 pub version: Option<String>,
69 pub path: PathBuf,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
75pub struct LanguageInfo {
76 pub name: String,
78 pub version: String,
80 pub path: PathBuf,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
86pub struct PackageManagerInfo {
87 pub name: String,
89 pub version: Option<String>,
91 pub path: PathBuf,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
97pub struct ProjectInfo {
98 pub project_type: ProjectType,
100 pub root: PathBuf,
102 pub name: Option<String>,
104 pub frameworks: Vec<String>,
106 pub source_dirs: Vec<PathBuf>,
108}
109
110#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
112pub enum ProjectType {
113 Rust,
114 Node,
115 Python,
116 Go,
117 Java,
118 CSharp,
119 Ruby,
120 Php,
121 Swift,
122 Kotlin,
123 Cpp,
124 C,
125 Mixed,
126 Unknown,
127}
128
129impl std::fmt::Display for ProjectType {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 match self {
132 ProjectType::Rust => write!(f, "Rust"),
133 ProjectType::Node => write!(f, "Node.js"),
134 ProjectType::Python => write!(f, "Python"),
135 ProjectType::Go => write!(f, "Go"),
136 ProjectType::Java => write!(f, "Java"),
137 ProjectType::CSharp => write!(f, "C#"),
138 ProjectType::Ruby => write!(f, "Ruby"),
139 ProjectType::Php => write!(f, "PHP"),
140 ProjectType::Swift => write!(f, "Swift"),
141 ProjectType::Kotlin => write!(f, "Kotlin"),
142 ProjectType::Cpp => write!(f, "C++"),
143 ProjectType::C => write!(f, "C"),
144 ProjectType::Mixed => write!(f, "Mixed"),
145 ProjectType::Unknown => write!(f, "Unknown"),
146 }
147 }
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
152pub struct GitInfo {
153 pub branch: String,
155 pub remote: Option<String>,
157 pub is_dirty: bool,
159 pub ahead: u32,
161 pub behind: u32,
163 pub last_commit: Option<String>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
169pub struct BuildToolInfo {
170 pub name: String,
172 pub version: Option<String>,
174 pub path: PathBuf,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
180pub struct TestFrameworkInfo {
181 pub name: String,
183 pub version: Option<String>,
185 pub language: String,
187}
188
189pub struct SystemInfoProvider {
191 cache: SystemInfoCache,
192 ttl: Duration,
193 project_root: Option<PathBuf>,
194}
195
196impl Default for SystemInfoProvider {
197 fn default() -> Self {
198 Self::new(Duration::from_secs(300)) }
200}
201
202impl SystemInfoProvider {
203 pub fn new(ttl: Duration) -> Self {
205 Self {
206 cache: SystemInfoCache::new(),
207 ttl,
208 project_root: None,
209 }
210 }
211
212 pub fn with_project_root(mut self, root: impl AsRef<Path>) -> Self {
214 self.project_root = Some(root.as_ref().to_path_buf());
215 self
216 }
217
218 pub fn get(&mut self) -> crate::Result<SystemInfo> {
220 if let Some(cached) = self.cache.get_if_valid(self.ttl) {
221 return Ok(cached.clone());
222 }
223 self.refresh()
224 }
225
226 pub fn refresh(&mut self) -> crate::Result<SystemInfo> {
228 let info = SystemInfo {
229 os: self.detect_os()?,
230 shell: self.detect_shell()?,
231 languages: self.detect_languages(),
232 package_managers: self.detect_package_managers(),
233 project: self.detect_project(),
234 git: self.detect_git(),
235 build_tools: self.detect_build_tools(),
236 test_frameworks: self.detect_test_frameworks(),
237 collected_at: std::time::SystemTime::now(),
238 };
239 self.cache.set(info.clone());
240 Ok(info)
241 }
242
243 pub fn invalidate(&mut self) {
245 self.cache.invalidate();
246 }
247
248 pub fn is_cache_valid(&self) -> bool {
250 self.cache.is_valid(self.ttl)
251 }
252
253 pub fn detect_os(&self) -> crate::Result<OsInfo> {
255 Ok(OsInfo {
256 name: std::env::consts::OS.to_string(),
257 version: get_os_version(),
258 arch: std::env::consts::ARCH.to_string(),
259 family: std::env::consts::FAMILY.to_string(),
260 })
261 }
262
263 pub fn detect_shell(&self) -> crate::Result<ShellInfo> {
265 #[cfg(windows)]
266 {
267 if let Ok(output) = Command::new("powershell")
269 .args(["-Command", "$PSVersionTable.PSVersion.ToString()"])
270 .output()
271 {
272 if output.status.success() {
273 let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
274 return Ok(ShellInfo {
275 name: "powershell".to_string(),
276 version: Some(version),
277 path: PathBuf::from("powershell.exe"),
278 });
279 }
280 }
281 Ok(ShellInfo {
282 name: "cmd".to_string(),
283 version: None,
284 path: PathBuf::from("cmd.exe"),
285 })
286 }
287
288 #[cfg(not(windows))]
289 {
290 let shell_path = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
291 let shell_name = Path::new(&shell_path)
292 .file_name()
293 .and_then(|n| n.to_str())
294 .unwrap_or("sh")
295 .to_string();
296
297 let version = get_shell_version(&shell_name);
298
299 Ok(ShellInfo {
300 name: shell_name,
301 version,
302 path: PathBuf::from(shell_path),
303 })
304 }
305 }
306
307 pub fn detect_languages(&self) -> Vec<LanguageInfo> {
309 let mut languages = Vec::new();
310
311 if let Some(info) = detect_language("rustc", &["--version"], "rust") {
313 languages.push(info);
314 }
315
316 if let Some(info) = detect_language("python3", &["--version"], "python") {
318 languages.push(info);
319 } else if let Some(info) = detect_language("python", &["--version"], "python") {
320 languages.push(info);
321 }
322
323 if let Some(info) = detect_language("node", &["--version"], "node") {
325 languages.push(info);
326 }
327
328 if let Some(info) = detect_language("go", &["version"], "go") {
330 languages.push(info);
331 }
332
333 if let Some(info) = detect_language("java", &["-version"], "java") {
335 languages.push(info);
336 }
337
338 if let Some(info) = detect_language("ruby", &["--version"], "ruby") {
340 languages.push(info);
341 }
342
343 if let Some(info) = detect_language("php", &["--version"], "php") {
345 languages.push(info);
346 }
347
348 languages
349 }
350
351 pub fn detect_package_managers(&self) -> Vec<PackageManagerInfo> {
353 let mut managers = Vec::new();
354
355 if let Some(info) = detect_package_manager("cargo", &["--version"]) {
357 managers.push(info);
358 }
359
360 if let Some(info) = detect_package_manager("npm", &["--version"]) {
362 managers.push(info);
363 }
364
365 if let Some(info) = detect_package_manager("yarn", &["--version"]) {
367 managers.push(info);
368 }
369
370 if let Some(info) = detect_package_manager("pnpm", &["--version"]) {
372 managers.push(info);
373 }
374
375 if let Some(info) = detect_package_manager("pip", &["--version"]) {
377 managers.push(info);
378 }
379
380 if let Some(info) = detect_package_manager("go", &["version"]) {
382 let mut info = info;
383 info.name = "go mod".to_string();
384 managers.push(info);
385 }
386
387 managers
388 }
389
390 pub fn detect_project(&self) -> Option<ProjectInfo> {
392 let root = self.project_root.as_ref()?;
393
394 if !root.exists() {
395 return None;
396 }
397
398 let project_type = detect_project_type(root);
399 let name = detect_project_name(root, project_type);
400 let frameworks = detect_frameworks(root, project_type);
401 let source_dirs = detect_source_dirs(root, project_type);
402
403 Some(ProjectInfo {
404 project_type,
405 root: root.clone(),
406 name,
407 frameworks,
408 source_dirs,
409 })
410 }
411
412 pub fn detect_git(&self) -> Option<GitInfo> {
414 let root = self.project_root.as_ref()?;
415
416 if !root.join(".git").exists() {
418 return None;
419 }
420
421 let branch = run_git_command(root, &["rev-parse", "--abbrev-ref", "HEAD"])?;
422 let remote = run_git_command(root, &["config", "--get", "remote.origin.url"]);
423 let is_dirty = run_git_command(root, &["status", "--porcelain"])
424 .map(|s| !s.is_empty())
425 .unwrap_or(false);
426
427 let (ahead, behind) = get_git_ahead_behind(root);
428 let last_commit = run_git_command(root, &["rev-parse", "--short", "HEAD"]);
429
430 Some(GitInfo {
431 branch,
432 remote,
433 is_dirty,
434 ahead,
435 behind,
436 last_commit,
437 })
438 }
439
440 pub fn detect_build_tools(&self) -> Vec<BuildToolInfo> {
442 let mut tools = Vec::new();
443
444 if let Some(info) = detect_build_tool("cargo", &["--version"]) {
446 tools.push(info);
447 }
448
449 if let Some(info) = detect_build_tool("make", &["--version"]) {
451 tools.push(info);
452 }
453
454 if let Some(info) = detect_build_tool("cmake", &["--version"]) {
456 tools.push(info);
457 }
458
459 if let Some(info) = detect_build_tool("gradle", &["--version"]) {
461 tools.push(info);
462 }
463
464 if let Some(info) = detect_build_tool("mvn", &["--version"]) {
466 let mut info = info;
467 info.name = "maven".to_string();
468 tools.push(info);
469 }
470
471 tools
472 }
473
474 pub fn detect_test_frameworks(&self) -> Vec<TestFrameworkInfo> {
476 let mut frameworks = Vec::new();
477
478 if let Some(ref root) = self.project_root {
480 if root.join("Cargo.toml").exists() {
482 frameworks.push(TestFrameworkInfo {
483 name: "cargo test".to_string(),
484 version: None,
485 language: "rust".to_string(),
486 });
487 }
488
489 if root.join("package.json").exists() {
491 if let Some(fw) = detect_node_test_framework(root) {
492 frameworks.push(fw);
493 }
494 }
495
496 if root.join("pyproject.toml").exists() || root.join("setup.py").exists() {
498 if let Some(fw) = detect_python_test_framework(root) {
499 frameworks.push(fw);
500 }
501 }
502 }
503
504 frameworks
505 }
506}
507
508fn get_os_version() -> String {
511 #[cfg(windows)]
512 {
513 if let Ok(output) = Command::new("cmd").args(["/c", "ver"]).output() {
514 let version = String::from_utf8_lossy(&output.stdout);
515 if let Some(start) = version.find('[') {
517 if let Some(end) = version.find(']') {
518 return version[start + 1..end].replace("Version ", "");
519 }
520 }
521 }
522 "unknown".to_string()
523 }
524
525 #[cfg(target_os = "macos")]
526 {
527 if let Ok(output) = Command::new("sw_vers").args(["-productVersion"]).output() {
528 return String::from_utf8_lossy(&output.stdout).trim().to_string();
529 }
530 "unknown".to_string()
531 }
532
533 #[cfg(target_os = "linux")]
534 {
535 if let Ok(output) = Command::new("uname").args(["-r"]).output() {
536 return String::from_utf8_lossy(&output.stdout).trim().to_string();
537 }
538 "unknown".to_string()
539 }
540
541 #[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
542 {
543 "unknown".to_string()
544 }
545}
546
547#[cfg(not(windows))]
548fn get_shell_version(shell_name: &str) -> Option<String> {
549 let args = match shell_name {
550 "bash" => vec!["--version"],
551 "zsh" => vec!["--version"],
552 "fish" => vec!["--version"],
553 _ => return None,
554 };
555
556 Command::new(shell_name)
557 .args(&args)
558 .output()
559 .ok()
560 .and_then(|output| {
561 let version = String::from_utf8_lossy(&output.stdout);
562 version.lines().next().map(|s| s.to_string())
563 })
564}
565
566fn detect_language(cmd: &str, args: &[&str], name: &str) -> Option<LanguageInfo> {
567 let output = Command::new(cmd).args(args).output().ok()?;
568
569 if !output.status.success() {
570 return None;
571 }
572
573 let version_output = String::from_utf8_lossy(&output.stdout);
574 let version = extract_version(&version_output);
575
576 let path = which::which(cmd).ok()?;
577
578 Some(LanguageInfo {
579 name: name.to_string(),
580 version,
581 path,
582 })
583}
584
585fn detect_package_manager(cmd: &str, args: &[&str]) -> Option<PackageManagerInfo> {
586 let output = Command::new(cmd).args(args).output().ok()?;
587
588 if !output.status.success() {
589 return None;
590 }
591
592 let version_output = String::from_utf8_lossy(&output.stdout);
593 let version = Some(extract_version(&version_output));
594
595 let path = which::which(cmd).ok()?;
596
597 Some(PackageManagerInfo {
598 name: cmd.to_string(),
599 version,
600 path,
601 })
602}
603
604fn detect_build_tool(cmd: &str, args: &[&str]) -> Option<BuildToolInfo> {
605 let output = Command::new(cmd).args(args).output().ok()?;
606
607 if !output.status.success() {
608 return None;
609 }
610
611 let version_output = String::from_utf8_lossy(&output.stdout);
612 let version = Some(extract_version(&version_output));
613
614 let path = which::which(cmd).ok()?;
615
616 Some(BuildToolInfo {
617 name: cmd.to_string(),
618 version,
619 path,
620 })
621}
622
623fn extract_version(output: &str) -> String {
624 let re = regex::Regex::new(r"(\d+\.\d+(?:\.\d+)?(?:-[\w.]+)?)").ok();
626
627 if let Some(re) = re {
628 if let Some(caps) = re.captures(output) {
629 return caps
630 .get(1)
631 .map(|m| m.as_str().to_string())
632 .unwrap_or_default();
633 }
634 }
635
636 output.lines().next().unwrap_or("").trim().to_string()
637}
638
639fn detect_project_type(root: &Path) -> ProjectType {
640 let markers: Vec<(&str, ProjectType)> = vec![
642 ("Cargo.toml", ProjectType::Rust),
643 ("package.json", ProjectType::Node),
644 ("pyproject.toml", ProjectType::Python),
645 ("setup.py", ProjectType::Python),
646 ("requirements.txt", ProjectType::Python),
647 ("go.mod", ProjectType::Go),
648 ("pom.xml", ProjectType::Java),
649 ("build.gradle", ProjectType::Java),
650 ("build.gradle.kts", ProjectType::Kotlin),
651 ("*.csproj", ProjectType::CSharp),
652 ("Gemfile", ProjectType::Ruby),
653 ("composer.json", ProjectType::Php),
654 ("Package.swift", ProjectType::Swift),
655 ("CMakeLists.txt", ProjectType::Cpp),
656 ("Makefile", ProjectType::C),
657 ];
658
659 let mut detected = Vec::new();
660
661 for (marker, project_type) in markers {
662 if marker.contains('*') {
663 let pattern = root.join(marker);
665 if let Ok(entries) = glob::glob(pattern.to_str().unwrap_or("")) {
666 if entries.count() > 0 {
667 detected.push(project_type);
668 }
669 }
670 } else if root.join(marker).exists() {
671 detected.push(project_type);
672 }
673 }
674
675 match detected.len() {
676 0 => ProjectType::Unknown,
677 1 => detected[0],
678 _ => ProjectType::Mixed,
679 }
680}
681
682fn detect_project_name(root: &Path, project_type: ProjectType) -> Option<String> {
683 match project_type {
684 ProjectType::Rust => {
685 let cargo_toml = root.join("Cargo.toml");
686 if let Ok(content) = std::fs::read_to_string(&cargo_toml) {
687 if let Ok(parsed) = content.parse::<toml::Table>() {
688 return parsed
689 .get("package")
690 .and_then(|p| p.get("name"))
691 .and_then(|n| n.as_str())
692 .map(|s| s.to_string());
693 }
694 }
695 }
696 ProjectType::Node => {
697 let package_json = root.join("package.json");
698 if let Ok(content) = std::fs::read_to_string(&package_json) {
699 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&content) {
700 return parsed
701 .get("name")
702 .and_then(|n| n.as_str())
703 .map(|s| s.to_string());
704 }
705 }
706 }
707 _ => {}
708 }
709
710 root.file_name()
712 .and_then(|n| n.to_str())
713 .map(|s| s.to_string())
714}
715
716fn detect_frameworks(root: &Path, project_type: ProjectType) -> Vec<String> {
717 let mut frameworks = Vec::new();
718
719 match project_type {
720 ProjectType::Rust => {
721 if let Ok(content) = std::fs::read_to_string(root.join("Cargo.toml")) {
722 if content.contains("actix-web") {
724 frameworks.push("Actix Web".to_string());
725 }
726 if content.contains("axum") {
727 frameworks.push("Axum".to_string());
728 }
729 if content.contains("rocket") {
730 frameworks.push("Rocket".to_string());
731 }
732 if content.contains("tokio") {
733 frameworks.push("Tokio".to_string());
734 }
735 }
736 }
737 ProjectType::Node => {
738 if let Ok(content) = std::fs::read_to_string(root.join("package.json")) {
739 if content.contains("\"react\"") {
740 frameworks.push("React".to_string());
741 }
742 if content.contains("\"vue\"") {
743 frameworks.push("Vue".to_string());
744 }
745 if content.contains("\"next\"") {
746 frameworks.push("Next.js".to_string());
747 }
748 if content.contains("\"express\"") {
749 frameworks.push("Express".to_string());
750 }
751 if content.contains("\"nestjs\"") || content.contains("\"@nestjs") {
752 frameworks.push("NestJS".to_string());
753 }
754 }
755 }
756 ProjectType::Python => {
757 if let Ok(content) = std::fs::read_to_string(root.join("pyproject.toml")) {
758 if content.contains("django") {
759 frameworks.push("Django".to_string());
760 }
761 if content.contains("flask") {
762 frameworks.push("Flask".to_string());
763 }
764 if content.contains("fastapi") {
765 frameworks.push("FastAPI".to_string());
766 }
767 }
768 }
769 _ => {}
770 }
771
772 frameworks
773}
774
775fn detect_source_dirs(root: &Path, project_type: ProjectType) -> Vec<PathBuf> {
776 let mut dirs = Vec::new();
777
778 let common_dirs = match project_type {
779 ProjectType::Rust => vec!["src", "crates"],
780 ProjectType::Node => vec!["src", "lib", "app"],
781 ProjectType::Python => vec![
782 "src",
783 "lib",
784 root.file_name().and_then(|n| n.to_str()).unwrap_or(""),
785 ],
786 ProjectType::Go => vec!["cmd", "pkg", "internal"],
787 ProjectType::Java => vec!["src/main/java", "src"],
788 _ => vec!["src", "lib"],
789 };
790
791 for dir in common_dirs {
792 let path = root.join(dir);
793 if path.exists() && path.is_dir() {
794 dirs.push(path);
795 }
796 }
797
798 dirs
799}
800
801fn run_git_command(root: &Path, args: &[&str]) -> Option<String> {
802 Command::new("git")
803 .current_dir(root)
804 .args(args)
805 .output()
806 .ok()
807 .filter(|o| o.status.success())
808 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
809}
810
811fn get_git_ahead_behind(root: &Path) -> (u32, u32) {
812 let output = run_git_command(
813 root,
814 &["rev-list", "--left-right", "--count", "HEAD...@{upstream}"],
815 );
816
817 if let Some(output) = output {
818 let parts: Vec<&str> = output.split_whitespace().collect();
819 if parts.len() == 2 {
820 let ahead = parts[0].parse().unwrap_or(0);
821 let behind = parts[1].parse().unwrap_or(0);
822 return (ahead, behind);
823 }
824 }
825
826 (0, 0)
827}
828
829fn detect_node_test_framework(root: &Path) -> Option<TestFrameworkInfo> {
830 let package_json = root.join("package.json");
831 if let Ok(content) = std::fs::read_to_string(&package_json) {
832 if content.contains("\"vitest\"") {
833 return Some(TestFrameworkInfo {
834 name: "vitest".to_string(),
835 version: None,
836 language: "javascript".to_string(),
837 });
838 }
839 if content.contains("\"jest\"") {
840 return Some(TestFrameworkInfo {
841 name: "jest".to_string(),
842 version: None,
843 language: "javascript".to_string(),
844 });
845 }
846 if content.contains("\"mocha\"") {
847 return Some(TestFrameworkInfo {
848 name: "mocha".to_string(),
849 version: None,
850 language: "javascript".to_string(),
851 });
852 }
853 }
854 None
855}
856
857fn detect_python_test_framework(root: &Path) -> Option<TestFrameworkInfo> {
858 if let Ok(content) = std::fs::read_to_string(root.join("pyproject.toml")) {
860 if content.contains("pytest") {
861 return Some(TestFrameworkInfo {
862 name: "pytest".to_string(),
863 version: None,
864 language: "python".to_string(),
865 });
866 }
867 }
868
869 if root.join("pytest.ini").exists() || root.join("conftest.py").exists() {
871 return Some(TestFrameworkInfo {
872 name: "pytest".to_string(),
873 version: None,
874 language: "python".to_string(),
875 });
876 }
877
878 Some(TestFrameworkInfo {
880 name: "unittest".to_string(),
881 version: None,
882 language: "python".to_string(),
883 })
884}
885
886#[cfg(test)]
887mod tests {
888 use super::*;
889 use std::fs;
890 use tempfile::TempDir;
891
892 #[test]
893 fn test_detect_os() {
894 let provider = SystemInfoProvider::default();
895 let os = provider.detect_os().unwrap();
896
897 assert!(!os.name.is_empty());
898 assert!(!os.arch.is_empty());
899 assert!(!os.family.is_empty());
900 }
901
902 #[test]
903 fn test_detect_shell() {
904 let provider = SystemInfoProvider::default();
905 let shell = provider.detect_shell().unwrap();
906
907 assert!(!shell.name.is_empty());
908 assert!(shell.path.exists() || cfg!(windows)); }
910
911 #[test]
912 fn test_detect_languages() {
913 let provider = SystemInfoProvider::default();
914 let languages = provider.detect_languages();
915
916 assert!(languages.len() >= 0);
919 }
920
921 #[test]
922 fn test_detect_rust_project() {
923 let temp = TempDir::new().unwrap();
924 fs::write(
925 temp.path().join("Cargo.toml"),
926 r#"[package]
927name = "test-project"
928version = "0.1.0"
929"#,
930 )
931 .unwrap();
932 fs::create_dir(temp.path().join("src")).unwrap();
933
934 let provider = SystemInfoProvider::default().with_project_root(temp.path());
935
936 let project = provider.detect_project().unwrap();
937 assert_eq!(project.project_type, ProjectType::Rust);
938 assert_eq!(project.name, Some("test-project".to_string()));
939 }
940
941 #[test]
942 fn test_detect_node_project() {
943 let temp = TempDir::new().unwrap();
944 fs::write(
945 temp.path().join("package.json"),
946 r#"{"name": "test-node-project", "version": "1.0.0"}"#,
947 )
948 .unwrap();
949
950 let provider = SystemInfoProvider::default().with_project_root(temp.path());
951
952 let project = provider.detect_project().unwrap();
953 assert_eq!(project.project_type, ProjectType::Node);
954 assert_eq!(project.name, Some("test-node-project".to_string()));
955 }
956
957 #[test]
958 fn test_detect_git_repo() {
959 let temp = TempDir::new().unwrap();
960
961 let init_result = std::process::Command::new("git")
963 .current_dir(temp.path())
964 .args(["init"])
965 .output();
966
967 if init_result.is_err() || !temp.path().join(".git").exists() {
969 return;
970 }
971
972 let _ = std::process::Command::new("git")
974 .current_dir(temp.path())
975 .args(["config", "user.email", "test@test.com"])
976 .output();
977 let _ = std::process::Command::new("git")
978 .current_dir(temp.path())
979 .args(["config", "user.name", "Test"])
980 .output();
981
982 let test_file = temp.path().join("test.txt");
984 std::fs::write(&test_file, "test").unwrap();
985 let _ = std::process::Command::new("git")
986 .current_dir(temp.path())
987 .args(["add", "."])
988 .output();
989 let commit_result = std::process::Command::new("git")
990 .current_dir(temp.path())
991 .args(["commit", "-m", "initial"])
992 .output();
993
994 if commit_result.is_err() || !commit_result.unwrap().status.success() {
996 return;
997 }
998
999 let provider = SystemInfoProvider::default().with_project_root(temp.path());
1000
1001 let git = provider.detect_git();
1002 assert!(
1003 git.is_some(),
1004 "Git should be detected when .git directory exists with commits"
1005 );
1006 }
1007
1008 #[test]
1009 fn test_cache_behavior() {
1010 let mut provider = SystemInfoProvider::new(Duration::from_secs(60));
1011
1012 let info1 = provider.get().unwrap();
1014 assert!(provider.is_cache_valid());
1015
1016 let info2 = provider.get().unwrap();
1018 assert_eq!(info1.os, info2.os);
1019
1020 provider.invalidate();
1022 assert!(!provider.is_cache_valid());
1023 }
1024
1025 #[test]
1026 fn test_project_type_display() {
1027 assert_eq!(ProjectType::Rust.to_string(), "Rust");
1028 assert_eq!(ProjectType::Node.to_string(), "Node.js");
1029 assert_eq!(ProjectType::Python.to_string(), "Python");
1030 }
1031
1032 #[test]
1033 fn test_extract_version() {
1034 assert_eq!(extract_version("rustc 1.75.0"), "1.75.0");
1035 assert_eq!(extract_version("Python 3.11.4"), "3.11.4");
1036 assert_eq!(extract_version("v20.10.0"), "20.10.0");
1037 }
1038
1039 #[test]
1040 fn test_detect_frameworks_rust() {
1041 let temp = TempDir::new().unwrap();
1042 fs::write(
1043 temp.path().join("Cargo.toml"),
1044 r#"[dependencies]
1045tokio = "1.0"
1046axum = "0.7"
1047"#,
1048 )
1049 .unwrap();
1050
1051 let frameworks = detect_frameworks(temp.path(), ProjectType::Rust);
1052 assert!(frameworks.contains(&"Tokio".to_string()));
1053 assert!(frameworks.contains(&"Axum".to_string()));
1054 }
1055
1056 #[test]
1057 fn test_detect_frameworks_node() {
1058 let temp = TempDir::new().unwrap();
1059 fs::write(
1060 temp.path().join("package.json"),
1061 r#"{"dependencies": {"react": "^18.0.0", "next": "^14.0.0"}}"#,
1062 )
1063 .unwrap();
1064
1065 let frameworks = detect_frameworks(temp.path(), ProjectType::Node);
1066 assert!(frameworks.contains(&"React".to_string()));
1067 assert!(frameworks.contains(&"Next.js".to_string()));
1068 }
1069}