use super::cursor::{collect_cursor_inventory, CursorInventory};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashSet};
use std::env;
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use sysinfo::{Disks, System};
use walkdir::WalkDir;
const PROJECT_SCAN_DEPTH: usize = 5;
const REPO_SCAN_DEPTH: usize = 5;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct SystemDrive {
#[serde(default)]
pub mount: String,
#[serde(default)]
pub file_system: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct GitIdentityRecord {
#[serde(default)]
pub scope: String,
#[serde(default)]
pub source_path: Option<String>,
#[serde(default)]
pub user_name: Option<String>,
#[serde(default)]
pub user_email: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct GithubRepoRecord {
#[serde(default)]
pub owner: String,
#[serde(default)]
pub repo: String,
#[serde(default)]
pub full_name: String,
#[serde(default)]
pub path: String,
#[serde(default)]
pub remote_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct XbpProjectRecord {
#[serde(default)]
pub name: String,
#[serde(default)]
pub root: String,
#[serde(default)]
pub config_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct OperatingSystemRecord {
#[serde(default)]
pub family: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub long_version: Option<String>,
#[serde(default)]
pub kernel_version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct InstalledToolRecord {
#[serde(default)]
pub name: String,
#[serde(default)]
pub category: String,
#[serde(default)]
pub present: bool,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct SystemInventory {
#[serde(default)]
pub collected_at: Option<DateTime<Utc>>,
#[serde(default)]
pub system_user: Option<String>,
#[serde(default)]
pub host_name: Option<String>,
#[serde(default)]
pub platform: String,
#[serde(default)]
pub architecture: String,
#[serde(default)]
pub operating_system: Option<OperatingSystemRecord>,
#[serde(default)]
pub drives: Vec<SystemDrive>,
#[serde(default)]
pub standard_paths: BTreeMap<String, String>,
#[serde(default)]
pub git_identities: Vec<GitIdentityRecord>,
#[serde(default)]
pub github_repos: Vec<GithubRepoRecord>,
#[serde(default)]
pub xbp_projects: Vec<XbpProjectRecord>,
#[serde(default)]
pub installed_tools: Vec<InstalledToolRecord>,
#[serde(default)]
pub cursor: Option<CursorInventory>,
}
#[derive(Debug, Clone, Default)]
pub struct SystemInventoryOptions {
pub include_cursor: bool,
pub current_dir: Option<PathBuf>,
pub xbp_global_root: Option<PathBuf>,
}
pub fn collect_system_inventory(options: &SystemInventoryOptions) -> SystemInventory {
let search_roots = default_search_roots(options.current_dir.as_deref());
let xbp_projects = discover_xbp_projects_with_roots(&search_roots);
let github_repos = discover_github_repos_with_roots(&search_roots);
let git_identities = discover_git_identities(&github_repos);
let standard_paths = collect_standard_paths(options);
let cursor_path = standard_paths.get("cursor_roaming").map(PathBuf::from);
SystemInventory {
collected_at: Some(Utc::now()),
system_user: resolve_system_user(),
host_name: resolve_host_name(),
platform: env::consts::OS.to_string(),
architecture: env::consts::ARCH.to_string(),
operating_system: Some(collect_operating_system_record()),
drives: collect_system_drives(),
standard_paths,
git_identities,
github_repos,
xbp_projects,
installed_tools: collect_installed_tools(),
cursor: if options.include_cursor {
Some(collect_cursor_inventory(cursor_path.as_deref()))
} else {
None
},
}
}
pub fn discover_xbp_projects(current_dir: Option<&Path>) -> Vec<XbpProjectRecord> {
discover_xbp_projects_with_roots(&default_search_roots(current_dir))
}
fn discover_xbp_projects_with_roots(search_roots: &[PathBuf]) -> Vec<XbpProjectRecord> {
let mut projects = Vec::new();
let mut seen_roots = HashSet::new();
for search_root in search_roots {
if !search_root.exists() {
continue;
}
for entry in WalkDir::new(search_root)
.max_depth(PROJECT_SCAN_DEPTH)
.follow_links(false)
.into_iter()
.filter_map(Result::ok)
{
let path = entry.path();
let project = if path.file_name() == Some(OsStr::new(".xbp")) && path.is_dir() {
let config = first_existing_path(&[
path.join("xbp.yaml"),
path.join("xbp.yml"),
path.join("xbp.json"),
]);
config.and_then(|config_path| {
path.parent()
.map(|parent| build_xbp_project_record(parent, config_path.as_path()))
})
} else if is_xbp_config_file(path) && path.is_file() {
path.parent()
.map(|parent| build_xbp_project_record(parent, path))
} else {
None
};
let Some(project) = project else {
continue;
};
let canonical_root = canonicalize_or_fallback(Path::new(&project.root));
if seen_roots.insert(canonical_root) {
projects.push(project);
}
}
}
projects.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then_with(|| left.root.cmp(&right.root))
});
projects
}
fn discover_github_repos_with_roots(search_roots: &[PathBuf]) -> Vec<GithubRepoRecord> {
let repo_roots = discover_repo_roots(search_roots);
let mut repos = Vec::new();
let mut seen = HashSet::new();
for repo_root in repo_roots {
let Some(remote_url) = git_remote_url_from_metadata(&repo_root, "origin") else {
continue;
};
let Some((owner, repo)) = parse_github_repo_from_remote_url(&remote_url) else {
continue;
};
let full_name = format!("{owner}/{repo}");
let path = repo_root.display().to_string();
let dedupe_key = format!("{full_name}::{path}");
if seen.insert(dedupe_key) {
repos.push(GithubRepoRecord {
owner,
repo,
full_name,
path,
remote_url,
});
}
}
repos.sort_by(|left, right| {
left.full_name
.cmp(&right.full_name)
.then_with(|| left.path.cmp(&right.path))
});
repos
}
fn collect_standard_paths(options: &SystemInventoryOptions) -> BTreeMap<String, String> {
let mut paths = BTreeMap::new();
let home_dir = dirs::home_dir();
insert_path(&mut paths, "home", home_dir.clone());
insert_path(&mut paths, "desktop", dirs::desktop_dir());
insert_path(&mut paths, "documents", dirs::document_dir());
insert_path(&mut paths, "downloads", dirs::download_dir());
insert_path(&mut paths, "config", dirs::config_dir());
insert_path(&mut paths, "cache", dirs::cache_dir());
insert_path(&mut paths, "app_data_local", dirs::data_local_dir());
insert_path(&mut paths, "app_data_roaming", dirs::data_dir());
insert_path(&mut paths, "temp", Some(std::env::temp_dir()));
insert_path(&mut paths, "current_dir", options.current_dir.clone());
insert_path(
&mut paths,
"xbp_global_root",
options.xbp_global_root.clone(),
);
insert_env_path(&mut paths, "user_profile", "USERPROFILE");
insert_env_path(&mut paths, "program_files", "ProgramFiles");
insert_env_path(&mut paths, "program_files_x86", "ProgramFiles(x86)");
insert_env_path(&mut paths, "program_data", "ProgramData");
insert_env_path(&mut paths, "one_drive", "OneDrive");
insert_env_path(&mut paths, "cargo_home", "CARGO_HOME");
insert_env_path(&mut paths, "rustup_home", "RUSTUP_HOME");
insert_env_path(&mut paths, "go_path", "GOPATH");
insert_env_path(&mut paths, "pnpm_home", "PNPM_HOME");
insert_env_path(&mut paths, "bun_install", "BUN_INSTALL");
insert_env_path(&mut paths, "volta_home", "VOLTA_HOME");
insert_env_path(&mut paths, "nvm_home", "NVM_HOME");
if !paths.contains_key("cargo_home") {
insert_path(
&mut paths,
"cargo_home",
home_dir.as_ref().map(|home| home.join(".cargo")),
);
}
if !paths.contains_key("rustup_home") {
insert_path(
&mut paths,
"rustup_home",
home_dir.as_ref().map(|home| home.join(".rustup")),
);
}
insert_path(
&mut paths,
"local_bin",
home_dir
.as_ref()
.map(|home| home.join(".local").join("bin")),
);
if let Some(roaming) = dirs::data_dir() {
insert_path(&mut paths, "cursor_roaming", Some(roaming.join("Cursor")));
}
paths
}
fn discover_git_identities(github_repos: &[GithubRepoRecord]) -> Vec<GitIdentityRecord> {
let mut identities = Vec::new();
let mut seen = HashSet::new();
if let Some(global_config_path) = default_global_git_config_path() {
if let Some(identity) = parse_git_identity_from_path("global", &global_config_path) {
let key = git_identity_key(&identity);
if seen.insert(key) {
identities.push(identity);
}
}
}
for repo in github_repos {
let repo_path = PathBuf::from(&repo.path);
let Some(git_dir) = resolve_git_dir(&repo_path) else {
continue;
};
let config_path = git_dir.join("config");
let scope = format!("repo:{}", repo.full_name);
if let Some(identity) = parse_git_identity_from_path(&scope, &config_path) {
let key = git_identity_key(&identity);
if seen.insert(key) {
identities.push(identity);
}
}
}
identities.sort_by(|left, right| left.scope.cmp(&right.scope));
identities
}
fn collect_system_drives() -> Vec<SystemDrive> {
let mut drives = Disks::new_with_refreshed_list()
.iter()
.map(|disk| SystemDrive {
mount: disk.mount_point().display().to_string(),
file_system: Some(disk.file_system().to_string_lossy().to_string())
.filter(|value| !value.is_empty()),
})
.collect::<Vec<_>>();
drives.sort_by(|left, right| left.mount.cmp(&right.mount));
drives
}
fn default_search_roots(current_dir: Option<&Path>) -> Vec<PathBuf> {
let mut roots = Vec::new();
let mut seen = HashSet::new();
if let Some(home) = dirs::home_dir() {
push_unique_path(&mut roots, &mut seen, home.join("projects"));
push_unique_path(&mut roots, &mut seen, home.join("dev"));
push_unique_path(&mut roots, &mut seen, home.join("Documents"));
push_unique_path(&mut roots, &mut seen, home.join("src"));
push_unique_path(&mut roots, &mut seen, home.clone());
}
if let Some(current_dir) = current_dir {
push_unique_path(&mut roots, &mut seen, current_dir.to_path_buf());
}
roots
}
fn push_unique_path(roots: &mut Vec<PathBuf>, seen: &mut HashSet<PathBuf>, path: PathBuf) {
let canonical = canonicalize_or_fallback(&path);
if seen.insert(canonical) {
roots.push(path);
}
}
fn build_xbp_project_record(project_root: &Path, config_path: &Path) -> XbpProjectRecord {
XbpProjectRecord {
name: extract_project_name(config_path).unwrap_or_else(|| {
project_root
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("unknown")
.to_string()
}),
root: project_root.display().to_string(),
config_path: config_path.display().to_string(),
}
}
fn extract_project_name(config_path: &Path) -> Option<String> {
let content = fs::read_to_string(config_path).ok()?;
let value = if config_path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
.unwrap_or(false)
{
let yaml_value: serde_yaml::Value = serde_yaml::from_str(&content).ok()?;
serde_json::to_value(yaml_value).ok()?
} else {
serde_json::from_str::<serde_json::Value>(&content).ok()?
};
value
.get("project_name")
.and_then(|value| value.as_str())
.map(|value| value.to_string())
}
fn discover_repo_roots(search_roots: &[PathBuf]) -> Vec<PathBuf> {
let mut roots = Vec::new();
let mut seen = HashSet::new();
for search_root in search_roots {
if !search_root.exists() {
continue;
}
for entry in WalkDir::new(search_root)
.max_depth(REPO_SCAN_DEPTH)
.follow_links(false)
.into_iter()
.filter_map(Result::ok)
{
let path = entry.path();
let repo_root = if path.file_name() == Some(OsStr::new(".git")) {
path.parent().map(Path::to_path_buf)
} else {
None
};
let Some(repo_root) = repo_root else {
continue;
};
let canonical = canonicalize_or_fallback(&repo_root);
if seen.insert(canonical) {
roots.push(repo_root);
}
}
}
roots.sort();
roots
}
fn git_remote_url_from_metadata(project_root: &Path, remote: &str) -> Option<String> {
let git_dir = resolve_git_dir(project_root)?;
let config_path = git_dir.join("config");
let content = fs::read_to_string(config_path).ok()?;
parse_git_remote_url_from_config(&content, remote)
}
fn resolve_git_dir(project_root: &Path) -> Option<PathBuf> {
let dot_git = project_root.join(".git");
if dot_git.is_dir() {
return Some(dot_git);
}
if !dot_git.exists() {
return None;
}
let content = fs::read_to_string(&dot_git).ok()?;
let git_dir = content
.lines()
.find_map(|line| line.trim().strip_prefix("gitdir:").map(str::trim))
.filter(|value| !value.is_empty())?;
let git_dir_path = PathBuf::from(git_dir);
Some(if git_dir_path.is_absolute() {
git_dir_path
} else {
dot_git.parent().unwrap_or(project_root).join(git_dir_path)
})
}
fn parse_git_remote_url_from_config(content: &str, remote: &str) -> Option<String> {
let expected_quoted = format!(r#"remote "{}""#, remote);
let expected_dotted = format!("remote.{}", remote);
let mut in_target_section = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let section = trimmed.trim_start_matches('[').trim_end_matches(']').trim();
in_target_section = section.eq_ignore_ascii_case(&expected_quoted)
|| section.eq_ignore_ascii_case(&expected_dotted);
continue;
}
if !in_target_section {
continue;
}
let (key, value) = trimmed.split_once('=')?;
if key.trim().eq_ignore_ascii_case("url") {
let value = value.trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
}
None
}
fn parse_github_repo_from_remote_url(url: &str) -> Option<(String, String)> {
let normalized = url.trim();
let repo_path = normalized
.strip_prefix("git@github.com:")
.or_else(|| normalized.strip_prefix("ssh://git@github.com/"))
.or_else(|| normalized.strip_prefix("https://github.com/"))
.or_else(|| normalized.strip_prefix("http://github.com/"))?;
let cleaned = repo_path.trim_end_matches('/').trim_end_matches(".git");
let mut segments = cleaned.split('/');
let owner = segments.next()?.trim();
let repo = segments.next()?.trim();
if owner.is_empty() || repo.is_empty() || segments.next().is_some() {
return None;
}
Some((owner.to_string(), repo.to_string()))
}
fn parse_git_identity_from_path(scope: &str, config_path: &Path) -> Option<GitIdentityRecord> {
let content = fs::read_to_string(config_path).ok()?;
let (user_name, user_email) = parse_git_user_section(&content)?;
Some(GitIdentityRecord {
scope: scope.to_string(),
source_path: Some(config_path.display().to_string()),
user_name,
user_email,
})
}
fn parse_git_user_section(content: &str) -> Option<(Option<String>, Option<String>)> {
let mut in_user_section = false;
let mut user_name = None;
let mut user_email = None;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let section = trimmed.trim_start_matches('[').trim_end_matches(']').trim();
in_user_section = section.eq_ignore_ascii_case("user");
continue;
}
if !in_user_section {
continue;
}
let Some((key, value)) = trimmed.split_once('=') else {
continue;
};
let value = value.trim();
if value.is_empty() {
continue;
}
if key.trim().eq_ignore_ascii_case("name") {
user_name = Some(value.to_string());
} else if key.trim().eq_ignore_ascii_case("email") {
user_email = Some(value.to_string());
}
}
if user_name.is_none() && user_email.is_none() {
None
} else {
Some((user_name, user_email))
}
}
fn default_global_git_config_path() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".gitconfig"))
}
fn git_identity_key(identity: &GitIdentityRecord) -> String {
format!(
"{}::{}::{}",
identity.user_name.as_deref().unwrap_or(""),
identity.user_email.as_deref().unwrap_or(""),
identity.scope
)
}
fn resolve_system_user() -> Option<String> {
env::var("USERNAME")
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
env::var("USER")
.ok()
.filter(|value| !value.trim().is_empty())
})
}
fn resolve_host_name() -> Option<String> {
System::host_name()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
env::var("COMPUTERNAME")
.ok()
.filter(|value| !value.trim().is_empty())
})
.or_else(|| {
env::var("HOSTNAME")
.ok()
.filter(|value| !value.trim().is_empty())
})
}
fn collect_operating_system_record() -> OperatingSystemRecord {
OperatingSystemRecord {
family: env::consts::OS.to_string(),
name: System::name().filter(|value| !value.trim().is_empty()),
version: System::os_version().filter(|value| !value.trim().is_empty()),
long_version: System::long_os_version().filter(|value| !value.trim().is_empty()),
kernel_version: System::kernel_version().filter(|value| !value.trim().is_empty()),
}
}
fn collect_installed_tools() -> Vec<InstalledToolRecord> {
let mut probes = vec![
ToolProbe::new("git", "git", "vcs", &["--version"]),
ToolProbe::new("gh", "gh", "vcs", &["--version"]),
ToolProbe::new("node", "node", "runtime", &["--version"]),
ToolProbe::new("npm", "npm", "package-manager", &["--version"]),
ToolProbe::new("pnpm", "pnpm", "package-manager", &["--version"]),
ToolProbe::new("yarn", "yarn", "package-manager", &["--version"]),
ToolProbe::new("bun", "bun", "runtime", &["--version"]),
ToolProbe::new("deno", "deno", "runtime", &["--version"]),
ToolProbe::new("python", "python", "runtime", &["--version"]),
ToolProbe::new("cargo", "cargo", "build", &["--version"]),
ToolProbe::new("rustc", "rustc", "build", &["--version"]),
ToolProbe::new("go", "go", "runtime", &["version"]),
ToolProbe::new("docker", "docker", "container", &["--version"]),
ToolProbe::new(
"docker-compose",
"docker-compose",
"container",
&["version"],
),
ToolProbe::new(
"kubectl",
"kubectl",
"orchestration",
&["version", "--client=true"],
),
ToolProbe::new("pm2", "pm2", "process-manager", &["--version"]),
ToolProbe::new("cloudflared", "cloudflared", "network", &["--version"]),
ToolProbe::new("wrangler", "wrangler", "cloud", &["--version"]),
ToolProbe::new("code", "code", "editor", &["--version"]),
ToolProbe::new("cursor", "cursor", "editor", &["--version"]),
];
if cfg!(windows) {
probes.extend_from_slice(&[
ToolProbe::new("winget", "winget", "package-manager", &["--version"]),
ToolProbe::new("choco", "choco", "package-manager", &["--version"]),
ToolProbe::new("scoop", "scoop", "package-manager", &["--version"]),
ToolProbe::new("wsl", "wsl", "virtualization", &["--version"]),
ToolProbe::new("pwsh", "pwsh", "shell", &["--version"]),
]);
}
let mut installed_tools = probes
.into_iter()
.map(collect_installed_tool_record)
.collect::<Vec<_>>();
installed_tools.sort_by(|left, right| left.name.cmp(&right.name));
installed_tools
}
fn collect_installed_tool_record(probe: ToolProbe) -> InstalledToolRecord {
let path = resolve_command_path(probe.command);
let version = path
.as_ref()
.and_then(|_| capture_command_version(probe.command, probe.version_args));
InstalledToolRecord {
name: probe.name.to_string(),
category: probe.category.to_string(),
present: path.is_some(),
version,
path: path.map(|value| value.display().to_string()),
}
}
fn resolve_command_path(command: &str) -> Option<PathBuf> {
let path = PathBuf::from(command);
if path.is_absolute() || command.contains('/') || command.contains('\\') {
return path.is_file().then_some(path);
}
let path_var = env::var_os("PATH")?;
for dir in env::split_paths(&path_var) {
for candidate in command_path_candidates(&dir, command) {
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}
fn command_path_candidates(dir: &Path, command: &str) -> Vec<PathBuf> {
let base = dir.join(command);
if base.extension().is_some() {
return vec![base];
}
let mut candidates = Vec::new();
candidates.push(base.clone());
#[cfg(windows)]
{
for extension in windows_path_extensions() {
candidates.push(dir.join(format!("{}{}", command, extension)));
}
}
candidates
}
#[cfg(windows)]
fn windows_path_extensions() -> Vec<String> {
let default = vec![
".COM".to_string(),
".EXE".to_string(),
".BAT".to_string(),
".CMD".to_string(),
];
let Some(value) = env::var_os("PATHEXT") else {
return default;
};
let parsed = value
.to_string_lossy()
.split(';')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_ascii_uppercase())
.collect::<Vec<_>>();
if parsed.is_empty() {
default
} else {
parsed
}
}
fn capture_command_version(command: &str, args: &[&str]) -> Option<String> {
let output = Command::new(command).args(args).output().ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
extract_version_line(stdout.lines().chain(stderr.lines()))
}
fn extract_version_line<'a, I>(lines: I) -> Option<String>
where
I: Iterator<Item = &'a str>,
{
lines
.map(str::trim)
.find(|line| !line.is_empty())
.map(|line| line.chars().take(160).collect())
}
fn insert_path(paths: &mut BTreeMap<String, String>, key: &str, value: Option<PathBuf>) {
if let Some(value) = value {
paths.insert(key.to_string(), value.display().to_string());
}
}
fn insert_env_path(paths: &mut BTreeMap<String, String>, key: &str, env_key: &str) {
if let Some(value) = env::var_os(env_key).filter(|value| !value.is_empty()) {
paths.insert(key.to_string(), PathBuf::from(value).display().to_string());
}
}
#[derive(Clone, Copy)]
struct ToolProbe {
name: &'static str,
command: &'static str,
category: &'static str,
version_args: &'static [&'static str],
}
impl ToolProbe {
const fn new(
name: &'static str,
command: &'static str,
category: &'static str,
version_args: &'static [&'static str],
) -> Self {
Self {
name,
command,
category,
version_args,
}
}
}
fn first_existing_path(candidates: &[PathBuf]) -> Option<PathBuf> {
candidates
.iter()
.find(|candidate| candidate.exists())
.cloned()
}
fn is_xbp_config_file(path: &Path) -> bool {
matches!(
path.file_name().and_then(|value| value.to_str()),
Some("xbp.yaml" | "xbp.yml" | "xbp.json")
)
}
fn canonicalize_or_fallback(path: &Path) -> PathBuf {
fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::{
collect_system_inventory, discover_xbp_projects, parse_github_repo_from_remote_url,
SystemInventoryOptions,
};
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
let path = std::env::temp_dir().join(format!("xbp-codetime-{}-{}", label, nanos));
fs::create_dir_all(&path).expect("temp dir");
path
}
#[test]
fn parses_github_https_remote() {
assert_eq!(
parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
Some(("xylex-group".to_string(), "xbp".to_string()))
);
}
#[test]
fn parses_github_ssh_remote() {
assert_eq!(
parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
Some(("xylex-group".to_string(), "xbp".to_string()))
);
}
#[test]
fn discovers_xbp_projects_from_current_dir_root() {
let root = temp_dir("xbp-project");
let project_root = root.join("demo");
fs::create_dir_all(project_root.join(".xbp")).expect("project dir");
fs::write(
project_root.join(".xbp").join("xbp.yaml"),
"project_name: demo-project\n",
)
.expect("config");
let projects = discover_xbp_projects(Some(root.as_path()));
assert!(projects
.iter()
.any(|project| project.name == "demo-project"));
}
#[test]
fn system_inventory_can_include_cursor_snapshot() {
let root = temp_dir("cursor");
let options = SystemInventoryOptions {
include_cursor: true,
current_dir: Some(root.clone()),
xbp_global_root: Some(root.join(".xbp")),
};
let inventory = collect_system_inventory(&options);
let expected_root = root.join(".xbp").display().to_string();
assert!(inventory.cursor.is_some());
assert_eq!(
inventory
.standard_paths
.get("xbp_global_root")
.map(String::as_str),
Some(expected_root.as_str())
);
assert!(inventory.operating_system.is_some());
assert!(inventory
.installed_tools
.iter()
.any(|tool| tool.name == "git"));
}
}