Skip to main content

driven/sysinfo/
mod.rs

1//! System Information Provider
2//!
3//! Provides comprehensive system information to agents, including OS details,
4//! shell environment, installed languages, package managers, project structure,
5//! git status, build tools, and test frameworks.
6//!
7//! Features:
8//! - Caching with configurable TTL
9//! - Automatic cache invalidation on system changes
10//! - Project type detection from marker files
11
12use 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/// Complete system information
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct SystemInfo {
29    /// Operating system details
30    pub os: OsInfo,
31    /// Shell environment
32    pub shell: ShellInfo,
33    /// Installed programming languages
34    pub languages: Vec<LanguageInfo>,
35    /// Available package managers
36    pub package_managers: Vec<PackageManagerInfo>,
37    /// Project information
38    pub project: Option<ProjectInfo>,
39    /// Git repository status
40    pub git: Option<GitInfo>,
41    /// Available build tools
42    pub build_tools: Vec<BuildToolInfo>,
43    /// Available test frameworks
44    pub test_frameworks: Vec<TestFrameworkInfo>,
45    /// Timestamp when info was collected
46    pub collected_at: std::time::SystemTime,
47}
48
49/// Operating system information
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub struct OsInfo {
52    /// OS name (e.g., "Windows", "Linux", "macOS")
53    pub name: String,
54    /// OS version
55    pub version: String,
56    /// Architecture (e.g., "x86_64", "aarch64")
57    pub arch: String,
58    /// OS family (e.g., "unix", "windows")
59    pub family: String,
60}
61
62/// Shell environment information
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
64pub struct ShellInfo {
65    /// Shell name (e.g., "bash", "zsh", "powershell")
66    pub name: String,
67    /// Shell version
68    pub version: Option<String>,
69    /// Path to shell executable
70    pub path: PathBuf,
71}
72
73/// Programming language information
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
75pub struct LanguageInfo {
76    /// Language name
77    pub name: String,
78    /// Version string
79    pub version: String,
80    /// Path to executable
81    pub path: PathBuf,
82}
83
84/// Package manager information
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
86pub struct PackageManagerInfo {
87    /// Package manager name (e.g., "cargo", "npm", "pip")
88    pub name: String,
89    /// Version string
90    pub version: Option<String>,
91    /// Path to executable
92    pub path: PathBuf,
93}
94
95/// Project information
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
97pub struct ProjectInfo {
98    /// Detected project type
99    pub project_type: ProjectType,
100    /// Project root directory
101    pub root: PathBuf,
102    /// Project name (from manifest if available)
103    pub name: Option<String>,
104    /// Detected frameworks
105    pub frameworks: Vec<String>,
106    /// Source directories
107    pub source_dirs: Vec<PathBuf>,
108}
109
110/// Project type enumeration
111#[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/// Git repository information
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
152pub struct GitInfo {
153    /// Current branch name
154    pub branch: String,
155    /// Remote URL (if configured)
156    pub remote: Option<String>,
157    /// Whether there are uncommitted changes
158    pub is_dirty: bool,
159    /// Number of commits ahead of remote
160    pub ahead: u32,
161    /// Number of commits behind remote
162    pub behind: u32,
163    /// Last commit hash (short)
164    pub last_commit: Option<String>,
165}
166
167/// Build tool information
168#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
169pub struct BuildToolInfo {
170    /// Tool name (e.g., "cargo", "make", "gradle")
171    pub name: String,
172    /// Version string
173    pub version: Option<String>,
174    /// Path to executable
175    pub path: PathBuf,
176}
177
178/// Test framework information
179#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
180pub struct TestFrameworkInfo {
181    /// Framework name (e.g., "cargo test", "jest", "pytest")
182    pub name: String,
183    /// Version string
184    pub version: Option<String>,
185    /// Associated language
186    pub language: String,
187}
188
189/// System information provider with caching
190pub 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)) // 5 minute default TTL
199    }
200}
201
202impl SystemInfoProvider {
203    /// Create a new provider with specified TTL
204    pub fn new(ttl: Duration) -> Self {
205        Self {
206            cache: SystemInfoCache::new(),
207            ttl,
208            project_root: None,
209        }
210    }
211
212    /// Set the project root for project-specific detection
213    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    /// Get system information (cached if within TTL)
219    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    /// Force refresh of system information
227    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    /// Invalidate the cache
244    pub fn invalidate(&mut self) {
245        self.cache.invalidate();
246    }
247
248    /// Check if cache is valid
249    pub fn is_cache_valid(&self) -> bool {
250        self.cache.is_valid(self.ttl)
251    }
252
253    /// Detect operating system information
254    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    /// Detect shell environment
264    pub fn detect_shell(&self) -> crate::Result<ShellInfo> {
265        #[cfg(windows)]
266        {
267            // Check for PowerShell first, then cmd
268            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    /// Detect installed programming languages
308    pub fn detect_languages(&self) -> Vec<LanguageInfo> {
309        let mut languages = Vec::new();
310
311        // Rust
312        if let Some(info) = detect_language("rustc", &["--version"], "rust") {
313            languages.push(info);
314        }
315
316        // Python
317        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        // Node.js
324        if let Some(info) = detect_language("node", &["--version"], "node") {
325            languages.push(info);
326        }
327
328        // Go
329        if let Some(info) = detect_language("go", &["version"], "go") {
330            languages.push(info);
331        }
332
333        // Java
334        if let Some(info) = detect_language("java", &["-version"], "java") {
335            languages.push(info);
336        }
337
338        // Ruby
339        if let Some(info) = detect_language("ruby", &["--version"], "ruby") {
340            languages.push(info);
341        }
342
343        // PHP
344        if let Some(info) = detect_language("php", &["--version"], "php") {
345            languages.push(info);
346        }
347
348        languages
349    }
350
351    /// Detect available package managers
352    pub fn detect_package_managers(&self) -> Vec<PackageManagerInfo> {
353        let mut managers = Vec::new();
354
355        // Cargo (Rust)
356        if let Some(info) = detect_package_manager("cargo", &["--version"]) {
357            managers.push(info);
358        }
359
360        // npm (Node)
361        if let Some(info) = detect_package_manager("npm", &["--version"]) {
362            managers.push(info);
363        }
364
365        // yarn (Node)
366        if let Some(info) = detect_package_manager("yarn", &["--version"]) {
367            managers.push(info);
368        }
369
370        // pnpm (Node)
371        if let Some(info) = detect_package_manager("pnpm", &["--version"]) {
372            managers.push(info);
373        }
374
375        // pip (Python)
376        if let Some(info) = detect_package_manager("pip", &["--version"]) {
377            managers.push(info);
378        }
379
380        // go mod (Go)
381        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    /// Detect project structure and type
391    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    /// Detect git repository status
413    pub fn detect_git(&self) -> Option<GitInfo> {
414        let root = self.project_root.as_ref()?;
415
416        // Check if .git exists
417        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    /// Detect available build tools
441    pub fn detect_build_tools(&self) -> Vec<BuildToolInfo> {
442        let mut tools = Vec::new();
443
444        // Cargo (Rust)
445        if let Some(info) = detect_build_tool("cargo", &["--version"]) {
446            tools.push(info);
447        }
448
449        // Make
450        if let Some(info) = detect_build_tool("make", &["--version"]) {
451            tools.push(info);
452        }
453
454        // CMake
455        if let Some(info) = detect_build_tool("cmake", &["--version"]) {
456            tools.push(info);
457        }
458
459        // Gradle
460        if let Some(info) = detect_build_tool("gradle", &["--version"]) {
461            tools.push(info);
462        }
463
464        // Maven
465        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    /// Detect available test frameworks
475    pub fn detect_test_frameworks(&self) -> Vec<TestFrameworkInfo> {
476        let mut frameworks = Vec::new();
477
478        // Check project-specific test frameworks
479        if let Some(ref root) = self.project_root {
480            // Rust: cargo test is always available if Cargo.toml exists
481            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            // Node: check for jest, mocha, vitest
490            if root.join("package.json").exists() {
491                if let Some(fw) = detect_node_test_framework(root) {
492                    frameworks.push(fw);
493                }
494            }
495
496            // Python: check for pytest, unittest
497            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
508// Helper functions
509
510fn 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            // Extract version number from "Microsoft Windows [Version 10.0.xxxxx]"
516            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    // Try to extract version number from common patterns
625    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    // Check for project marker files
641    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            // Glob pattern
664            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    // Fall back to directory name
711    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                // Check for common Rust frameworks
723                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    // Check pyproject.toml for pytest
859    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    // Check for pytest.ini or conftest.py
870    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    // Default to unittest
879    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)); // On Windows, path might be relative
909    }
910
911    #[test]
912    fn test_detect_languages() {
913        let provider = SystemInfoProvider::default();
914        let languages = provider.detect_languages();
915
916        // At minimum, Rust should be detected since we're running Rust tests
917        // But this might not always be true in CI, so just check it doesn't panic
918        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        // Initialize a git repo
962        let init_result = std::process::Command::new("git")
963            .current_dir(temp.path())
964            .args(["init"])
965            .output();
966
967        // Skip test if git is not available
968        if init_result.is_err() || !temp.path().join(".git").exists() {
969            return;
970        }
971
972        // Configure git user for the commit (required in some environments)
973        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        // Create an initial commit so HEAD exists
983        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        // Skip if commit failed (git might not be properly configured)
995        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        // First call should populate cache
1013        let info1 = provider.get().unwrap();
1014        assert!(provider.is_cache_valid());
1015
1016        // Second call should return cached value
1017        let info2 = provider.get().unwrap();
1018        assert_eq!(info1.os, info2.os);
1019
1020        // Invalidate and check
1021        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}