1use super::cursor::{collect_cursor_inventory, CursorInventory};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::collections::{BTreeMap, HashSet};
5use std::env;
6use std::ffi::OsStr;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use sysinfo::{Disks, System};
11use walkdir::WalkDir;
12
13const PROJECT_SCAN_DEPTH: usize = 5;
14const REPO_SCAN_DEPTH: usize = 5;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
17pub struct SystemDrive {
18 #[serde(default)]
19 pub mount: String,
20 #[serde(default)]
21 pub file_system: Option<String>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
25pub struct GitIdentityRecord {
26 #[serde(default)]
27 pub scope: String,
28 #[serde(default)]
29 pub source_path: Option<String>,
30 #[serde(default)]
31 pub user_name: Option<String>,
32 #[serde(default)]
33 pub user_email: Option<String>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
37pub struct GithubRepoRecord {
38 #[serde(default)]
39 pub owner: String,
40 #[serde(default)]
41 pub repo: String,
42 #[serde(default)]
43 pub full_name: String,
44 #[serde(default)]
45 pub path: String,
46 #[serde(default)]
47 pub remote_url: String,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
51pub struct XbpProjectRecord {
52 #[serde(default)]
53 pub name: String,
54 #[serde(default)]
55 pub root: String,
56 #[serde(default)]
57 pub config_path: String,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
61pub struct OperatingSystemRecord {
62 #[serde(default)]
63 pub family: String,
64 #[serde(default)]
65 pub name: Option<String>,
66 #[serde(default)]
67 pub version: Option<String>,
68 #[serde(default)]
69 pub long_version: Option<String>,
70 #[serde(default)]
71 pub kernel_version: Option<String>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
75pub struct InstalledToolRecord {
76 #[serde(default)]
77 pub name: String,
78 #[serde(default)]
79 pub category: String,
80 #[serde(default)]
81 pub present: bool,
82 #[serde(default)]
83 pub version: Option<String>,
84 #[serde(default)]
85 pub path: Option<String>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
89pub struct SystemInventory {
90 #[serde(default)]
91 pub collected_at: Option<DateTime<Utc>>,
92 #[serde(default)]
93 pub system_user: Option<String>,
94 #[serde(default)]
95 pub host_name: Option<String>,
96 #[serde(default)]
97 pub platform: String,
98 #[serde(default)]
99 pub architecture: String,
100 #[serde(default)]
101 pub operating_system: Option<OperatingSystemRecord>,
102 #[serde(default)]
103 pub drives: Vec<SystemDrive>,
104 #[serde(default)]
105 pub standard_paths: BTreeMap<String, String>,
106 #[serde(default)]
107 pub git_identities: Vec<GitIdentityRecord>,
108 #[serde(default)]
109 pub github_repos: Vec<GithubRepoRecord>,
110 #[serde(default)]
111 pub xbp_projects: Vec<XbpProjectRecord>,
112 #[serde(default)]
113 pub installed_tools: Vec<InstalledToolRecord>,
114 #[serde(default)]
115 pub cursor: Option<CursorInventory>,
116}
117
118#[derive(Debug, Clone, Default)]
119pub struct SystemInventoryOptions {
120 pub include_cursor: bool,
121 pub current_dir: Option<PathBuf>,
122 pub xbp_global_root: Option<PathBuf>,
123}
124
125pub fn collect_system_inventory(options: &SystemInventoryOptions) -> SystemInventory {
126 let search_roots = default_search_roots(options.current_dir.as_deref());
127 let xbp_projects = discover_xbp_projects_with_roots(&search_roots);
128 let github_repos = discover_github_repos_with_roots(&search_roots);
129 let git_identities = discover_git_identities(&github_repos);
130 let standard_paths = collect_standard_paths(options);
131 let cursor_path = standard_paths.get("cursor_roaming").map(PathBuf::from);
132
133 SystemInventory {
134 collected_at: Some(Utc::now()),
135 system_user: resolve_system_user(),
136 host_name: resolve_host_name(),
137 platform: env::consts::OS.to_string(),
138 architecture: env::consts::ARCH.to_string(),
139 operating_system: Some(collect_operating_system_record()),
140 drives: collect_system_drives(),
141 standard_paths,
142 git_identities,
143 github_repos,
144 xbp_projects,
145 installed_tools: collect_installed_tools(),
146 cursor: if options.include_cursor {
147 Some(collect_cursor_inventory(cursor_path.as_deref()))
148 } else {
149 None
150 },
151 }
152}
153
154pub fn discover_xbp_projects(current_dir: Option<&Path>) -> Vec<XbpProjectRecord> {
155 discover_xbp_projects_with_roots(&default_search_roots(current_dir))
156}
157
158fn discover_xbp_projects_with_roots(search_roots: &[PathBuf]) -> Vec<XbpProjectRecord> {
159 let mut projects = Vec::new();
160 let mut seen_roots = HashSet::new();
161
162 for search_root in search_roots {
163 if !search_root.exists() {
164 continue;
165 }
166
167 for entry in WalkDir::new(search_root)
168 .max_depth(PROJECT_SCAN_DEPTH)
169 .follow_links(false)
170 .into_iter()
171 .filter_map(Result::ok)
172 {
173 let path = entry.path();
174
175 let project = if path.file_name() == Some(OsStr::new(".xbp")) && path.is_dir() {
176 let config = first_existing_path(&[
177 path.join("xbp.toml"),
178 path.join("xbp.jsonc"),
179 path.join("xbp.yaml"),
180 path.join("xbp.yml"),
181 path.join("xbp.json"),
182 ]);
183 config.and_then(|config_path| {
184 path.parent()
185 .map(|parent| build_xbp_project_record(parent, config_path.as_path()))
186 })
187 } else if is_xbp_config_file(path) && path.is_file() {
188 path.parent()
189 .map(|parent| build_xbp_project_record(parent, path))
190 } else {
191 None
192 };
193
194 let Some(project) = project else {
195 continue;
196 };
197
198 let canonical_root = canonicalize_or_fallback(Path::new(&project.root));
199 if seen_roots.insert(canonical_root) {
200 projects.push(project);
201 }
202 }
203 }
204
205 projects.sort_by(|left, right| {
206 left.name
207 .cmp(&right.name)
208 .then_with(|| left.root.cmp(&right.root))
209 });
210 projects
211}
212
213fn discover_github_repos_with_roots(search_roots: &[PathBuf]) -> Vec<GithubRepoRecord> {
214 let repo_roots = discover_repo_roots(search_roots);
215 let mut repos = Vec::new();
216 let mut seen = HashSet::new();
217
218 for repo_root in repo_roots {
219 let Some(remote_url) = git_remote_url_from_metadata(&repo_root, "origin") else {
220 continue;
221 };
222 let Some((owner, repo)) = parse_github_repo_from_remote_url(&remote_url) else {
223 continue;
224 };
225
226 let full_name = format!("{owner}/{repo}");
227 let path = repo_root.display().to_string();
228 let dedupe_key = format!("{full_name}::{path}");
229 if seen.insert(dedupe_key) {
230 repos.push(GithubRepoRecord {
231 owner,
232 repo,
233 full_name,
234 path,
235 remote_url,
236 });
237 }
238 }
239
240 repos.sort_by(|left, right| {
241 left.full_name
242 .cmp(&right.full_name)
243 .then_with(|| left.path.cmp(&right.path))
244 });
245 repos
246}
247
248fn collect_standard_paths(options: &SystemInventoryOptions) -> BTreeMap<String, String> {
249 let mut paths = BTreeMap::new();
250 let home_dir = dirs::home_dir();
251
252 insert_path(&mut paths, "home", home_dir.clone());
253 insert_path(&mut paths, "desktop", dirs::desktop_dir());
254 insert_path(&mut paths, "documents", dirs::document_dir());
255 insert_path(&mut paths, "downloads", dirs::download_dir());
256 insert_path(&mut paths, "config", dirs::config_dir());
257 insert_path(&mut paths, "cache", dirs::cache_dir());
258 insert_path(&mut paths, "app_data_local", dirs::data_local_dir());
259 insert_path(&mut paths, "app_data_roaming", dirs::data_dir());
260 insert_path(&mut paths, "temp", Some(std::env::temp_dir()));
261 insert_path(&mut paths, "current_dir", options.current_dir.clone());
262 insert_path(
263 &mut paths,
264 "xbp_global_root",
265 options.xbp_global_root.clone(),
266 );
267 insert_env_path(&mut paths, "user_profile", "USERPROFILE");
268 insert_env_path(&mut paths, "program_files", "ProgramFiles");
269 insert_env_path(&mut paths, "program_files_x86", "ProgramFiles(x86)");
270 insert_env_path(&mut paths, "program_data", "ProgramData");
271 insert_env_path(&mut paths, "one_drive", "OneDrive");
272 insert_env_path(&mut paths, "cargo_home", "CARGO_HOME");
273 insert_env_path(&mut paths, "rustup_home", "RUSTUP_HOME");
274 insert_env_path(&mut paths, "go_path", "GOPATH");
275 insert_env_path(&mut paths, "pnpm_home", "PNPM_HOME");
276 insert_env_path(&mut paths, "bun_install", "BUN_INSTALL");
277 insert_env_path(&mut paths, "volta_home", "VOLTA_HOME");
278 insert_env_path(&mut paths, "nvm_home", "NVM_HOME");
279
280 if !paths.contains_key("cargo_home") {
281 insert_path(
282 &mut paths,
283 "cargo_home",
284 home_dir.as_ref().map(|home| home.join(".cargo")),
285 );
286 }
287 if !paths.contains_key("rustup_home") {
288 insert_path(
289 &mut paths,
290 "rustup_home",
291 home_dir.as_ref().map(|home| home.join(".rustup")),
292 );
293 }
294 insert_path(
295 &mut paths,
296 "local_bin",
297 home_dir
298 .as_ref()
299 .map(|home| home.join(".local").join("bin")),
300 );
301
302 if let Some(roaming) = dirs::data_dir() {
303 insert_path(&mut paths, "cursor_roaming", Some(roaming.join("Cursor")));
304 }
305
306 paths
307}
308
309fn discover_git_identities(github_repos: &[GithubRepoRecord]) -> Vec<GitIdentityRecord> {
310 let mut identities = Vec::new();
311 let mut seen = HashSet::new();
312
313 if let Some(global_config_path) = default_global_git_config_path() {
314 if let Some(identity) = parse_git_identity_from_path("global", &global_config_path) {
315 let key = git_identity_key(&identity);
316 if seen.insert(key) {
317 identities.push(identity);
318 }
319 }
320 }
321
322 for repo in github_repos {
323 let repo_path = PathBuf::from(&repo.path);
324 let Some(git_dir) = resolve_git_dir(&repo_path) else {
325 continue;
326 };
327 let config_path = git_dir.join("config");
328 let scope = format!("repo:{}", repo.full_name);
329 if let Some(identity) = parse_git_identity_from_path(&scope, &config_path) {
330 let key = git_identity_key(&identity);
331 if seen.insert(key) {
332 identities.push(identity);
333 }
334 }
335 }
336
337 identities.sort_by(|left, right| left.scope.cmp(&right.scope));
338 identities
339}
340
341fn collect_system_drives() -> Vec<SystemDrive> {
342 let mut drives = Disks::new_with_refreshed_list()
343 .iter()
344 .map(|disk| SystemDrive {
345 mount: disk.mount_point().display().to_string(),
346 file_system: Some(disk.file_system().to_string_lossy().to_string())
347 .filter(|value| !value.is_empty()),
348 })
349 .collect::<Vec<_>>();
350 drives.sort_by(|left, right| left.mount.cmp(&right.mount));
351 drives
352}
353
354fn default_search_roots(current_dir: Option<&Path>) -> Vec<PathBuf> {
355 let mut roots = Vec::new();
356 let mut seen = HashSet::new();
357
358 if let Some(home) = dirs::home_dir() {
359 push_unique_path(&mut roots, &mut seen, home.join("projects"));
360 push_unique_path(&mut roots, &mut seen, home.join("dev"));
361 push_unique_path(&mut roots, &mut seen, home.join("Documents"));
362 push_unique_path(&mut roots, &mut seen, home.join("src"));
363 push_unique_path(&mut roots, &mut seen, home.clone());
364 }
365
366 if let Some(current_dir) = current_dir {
367 push_unique_path(&mut roots, &mut seen, current_dir.to_path_buf());
368 }
369
370 roots
371}
372
373fn push_unique_path(roots: &mut Vec<PathBuf>, seen: &mut HashSet<PathBuf>, path: PathBuf) {
374 let canonical = canonicalize_or_fallback(&path);
375 if seen.insert(canonical) {
376 roots.push(path);
377 }
378}
379
380fn build_xbp_project_record(project_root: &Path, config_path: &Path) -> XbpProjectRecord {
381 XbpProjectRecord {
382 name: extract_project_name(config_path).unwrap_or_else(|| {
383 project_root
384 .file_name()
385 .and_then(|value| value.to_str())
386 .unwrap_or("unknown")
387 .to_string()
388 }),
389 root: project_root.display().to_string(),
390 config_path: config_path.display().to_string(),
391 }
392}
393
394fn extract_project_name(config_path: &Path) -> Option<String> {
395 let content = fs::read_to_string(config_path).ok()?;
396 let value = if config_path
397 .extension()
398 .and_then(|ext| ext.to_str())
399 .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
400 .unwrap_or(false)
401 {
402 let yaml_value: serde_yaml::Value = serde_yaml::from_str(&content).ok()?;
403 serde_json::to_value(yaml_value).ok()?
404 } else {
405 serde_json::from_str::<serde_json::Value>(&content).ok()?
406 };
407
408 value
409 .get("project_name")
410 .and_then(|value| value.as_str())
411 .map(|value| value.to_string())
412}
413
414fn discover_repo_roots(search_roots: &[PathBuf]) -> Vec<PathBuf> {
415 let mut roots = Vec::new();
416 let mut seen = HashSet::new();
417
418 for search_root in search_roots {
419 if !search_root.exists() {
420 continue;
421 }
422
423 for entry in WalkDir::new(search_root)
424 .max_depth(REPO_SCAN_DEPTH)
425 .follow_links(false)
426 .into_iter()
427 .filter_map(Result::ok)
428 {
429 let path = entry.path();
430 let repo_root = if path.file_name() == Some(OsStr::new(".git")) {
431 path.parent().map(Path::to_path_buf)
432 } else {
433 None
434 };
435
436 let Some(repo_root) = repo_root else {
437 continue;
438 };
439 let canonical = canonicalize_or_fallback(&repo_root);
440 if seen.insert(canonical) {
441 roots.push(repo_root);
442 }
443 }
444 }
445
446 roots.sort();
447 roots
448}
449
450fn git_remote_url_from_metadata(project_root: &Path, remote: &str) -> Option<String> {
451 let git_dir = resolve_git_dir(project_root)?;
452 let config_path = git_dir.join("config");
453 let content = fs::read_to_string(config_path).ok()?;
454 parse_git_remote_url_from_config(&content, remote)
455}
456
457fn resolve_git_dir(project_root: &Path) -> Option<PathBuf> {
458 let dot_git = project_root.join(".git");
459 if dot_git.is_dir() {
460 return Some(dot_git);
461 }
462
463 if !dot_git.exists() {
464 return None;
465 }
466
467 let content = fs::read_to_string(&dot_git).ok()?;
468 let git_dir = content
469 .lines()
470 .find_map(|line| line.trim().strip_prefix("gitdir:").map(str::trim))
471 .filter(|value| !value.is_empty())?;
472
473 let git_dir_path = PathBuf::from(git_dir);
474 Some(if git_dir_path.is_absolute() {
475 git_dir_path
476 } else {
477 dot_git.parent().unwrap_or(project_root).join(git_dir_path)
478 })
479}
480
481fn parse_git_remote_url_from_config(content: &str, remote: &str) -> Option<String> {
482 let expected_quoted = format!(r#"remote "{}""#, remote);
483 let expected_dotted = format!("remote.{}", remote);
484 let mut in_target_section = false;
485
486 for line in content.lines() {
487 let trimmed = line.trim();
488 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
489 continue;
490 }
491
492 if trimmed.starts_with('[') && trimmed.ends_with(']') {
493 let section = trimmed.trim_start_matches('[').trim_end_matches(']').trim();
494 in_target_section = section.eq_ignore_ascii_case(&expected_quoted)
495 || section.eq_ignore_ascii_case(&expected_dotted);
496 continue;
497 }
498
499 if !in_target_section {
500 continue;
501 }
502
503 let (key, value) = trimmed.split_once('=')?;
504 if key.trim().eq_ignore_ascii_case("url") {
505 let value = value.trim();
506 if !value.is_empty() {
507 return Some(value.to_string());
508 }
509 }
510 }
511
512 None
513}
514
515fn parse_github_repo_from_remote_url(url: &str) -> Option<(String, String)> {
516 let normalized = url.trim();
517 let repo_path = normalized
518 .strip_prefix("git@github.com:")
519 .or_else(|| normalized.strip_prefix("ssh://git@github.com/"))
520 .or_else(|| normalized.strip_prefix("https://github.com/"))
521 .or_else(|| normalized.strip_prefix("http://github.com/"))?;
522
523 let cleaned = repo_path.trim_end_matches('/').trim_end_matches(".git");
524 let mut segments = cleaned.split('/');
525 let owner = segments.next()?.trim();
526 let repo = segments.next()?.trim();
527 if owner.is_empty() || repo.is_empty() || segments.next().is_some() {
528 return None;
529 }
530
531 Some((owner.to_string(), repo.to_string()))
532}
533
534fn parse_git_identity_from_path(scope: &str, config_path: &Path) -> Option<GitIdentityRecord> {
535 let content = fs::read_to_string(config_path).ok()?;
536 let (user_name, user_email) = parse_git_user_section(&content)?;
537
538 Some(GitIdentityRecord {
539 scope: scope.to_string(),
540 source_path: Some(config_path.display().to_string()),
541 user_name,
542 user_email,
543 })
544}
545
546fn parse_git_user_section(content: &str) -> Option<(Option<String>, Option<String>)> {
547 let mut in_user_section = false;
548 let mut user_name = None;
549 let mut user_email = None;
550
551 for line in content.lines() {
552 let trimmed = line.trim();
553 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
554 continue;
555 }
556
557 if trimmed.starts_with('[') && trimmed.ends_with(']') {
558 let section = trimmed.trim_start_matches('[').trim_end_matches(']').trim();
559 in_user_section = section.eq_ignore_ascii_case("user");
560 continue;
561 }
562
563 if !in_user_section {
564 continue;
565 }
566
567 let Some((key, value)) = trimmed.split_once('=') else {
568 continue;
569 };
570 let value = value.trim();
571 if value.is_empty() {
572 continue;
573 }
574
575 if key.trim().eq_ignore_ascii_case("name") {
576 user_name = Some(value.to_string());
577 } else if key.trim().eq_ignore_ascii_case("email") {
578 user_email = Some(value.to_string());
579 }
580 }
581
582 if user_name.is_none() && user_email.is_none() {
583 None
584 } else {
585 Some((user_name, user_email))
586 }
587}
588
589fn default_global_git_config_path() -> Option<PathBuf> {
590 dirs::home_dir().map(|home| home.join(".gitconfig"))
591}
592
593fn git_identity_key(identity: &GitIdentityRecord) -> String {
594 format!(
595 "{}::{}::{}",
596 identity.user_name.as_deref().unwrap_or(""),
597 identity.user_email.as_deref().unwrap_or(""),
598 identity.scope
599 )
600}
601
602fn resolve_system_user() -> Option<String> {
603 env::var("USERNAME")
604 .ok()
605 .filter(|value| !value.trim().is_empty())
606 .or_else(|| {
607 env::var("USER")
608 .ok()
609 .filter(|value| !value.trim().is_empty())
610 })
611}
612
613fn resolve_host_name() -> Option<String> {
614 System::host_name()
615 .filter(|value| !value.trim().is_empty())
616 .or_else(|| {
617 env::var("COMPUTERNAME")
618 .ok()
619 .filter(|value| !value.trim().is_empty())
620 })
621 .or_else(|| {
622 env::var("HOSTNAME")
623 .ok()
624 .filter(|value| !value.trim().is_empty())
625 })
626}
627
628fn collect_operating_system_record() -> OperatingSystemRecord {
629 OperatingSystemRecord {
630 family: env::consts::OS.to_string(),
631 name: System::name().filter(|value| !value.trim().is_empty()),
632 version: System::os_version().filter(|value| !value.trim().is_empty()),
633 long_version: System::long_os_version().filter(|value| !value.trim().is_empty()),
634 kernel_version: System::kernel_version().filter(|value| !value.trim().is_empty()),
635 }
636}
637
638fn collect_installed_tools() -> Vec<InstalledToolRecord> {
639 let mut probes = vec![
640 ToolProbe::new("git", "git", "vcs", &["--version"]),
641 ToolProbe::new("gh", "gh", "vcs", &["--version"]),
642 ToolProbe::new("node", "node", "runtime", &["--version"]),
643 ToolProbe::new("npm", "npm", "package-manager", &["--version"]),
644 ToolProbe::new("pnpm", "pnpm", "package-manager", &["--version"]),
645 ToolProbe::new("yarn", "yarn", "package-manager", &["--version"]),
646 ToolProbe::new("bun", "bun", "runtime", &["--version"]),
647 ToolProbe::new("deno", "deno", "runtime", &["--version"]),
648 ToolProbe::new("python", "python", "runtime", &["--version"]),
649 ToolProbe::new("cargo", "cargo", "build", &["--version"]),
650 ToolProbe::new("rustc", "rustc", "build", &["--version"]),
651 ToolProbe::new("go", "go", "runtime", &["version"]),
652 ToolProbe::new("docker", "docker", "container", &["--version"]),
653 ToolProbe::new(
654 "docker-compose",
655 "docker-compose",
656 "container",
657 &["version"],
658 ),
659 ToolProbe::new(
660 "kubectl",
661 "kubectl",
662 "orchestration",
663 &["version", "--client=true"],
664 ),
665 ToolProbe::new("pm2", "pm2", "process-manager", &["--version"]),
666 ToolProbe::new("cloudflared", "cloudflared", "network", &["--version"]),
667 ToolProbe::new("wrangler", "wrangler", "cloud", &["--version"]),
668 ToolProbe::new("code", "code", "editor", &["--version"]),
669 ToolProbe::new("cursor", "cursor", "editor", &["--version"]),
670 ];
671
672 if cfg!(windows) {
673 probes.extend_from_slice(&[
674 ToolProbe::new("winget", "winget", "package-manager", &["--version"]),
675 ToolProbe::new("choco", "choco", "package-manager", &["--version"]),
676 ToolProbe::new("scoop", "scoop", "package-manager", &["--version"]),
677 ToolProbe::new("wsl", "wsl", "virtualization", &["--version"]),
678 ToolProbe::new("pwsh", "pwsh", "shell", &["--version"]),
679 ]);
680 }
681
682 let mut installed_tools = probes
683 .into_iter()
684 .map(collect_installed_tool_record)
685 .collect::<Vec<_>>();
686 installed_tools.sort_by(|left, right| left.name.cmp(&right.name));
687 installed_tools
688}
689
690fn collect_installed_tool_record(probe: ToolProbe) -> InstalledToolRecord {
691 let path = resolve_command_path(probe.command);
692 let version = path
693 .as_ref()
694 .and_then(|_| capture_command_version(probe.command, probe.version_args));
695
696 InstalledToolRecord {
697 name: probe.name.to_string(),
698 category: probe.category.to_string(),
699 present: path.is_some(),
700 version,
701 path: path.map(|value| value.display().to_string()),
702 }
703}
704
705fn resolve_command_path(command: &str) -> Option<PathBuf> {
706 let path = PathBuf::from(command);
707 if path.is_absolute() || command.contains('/') || command.contains('\\') {
708 return path.is_file().then_some(path);
709 }
710
711 let path_var = env::var_os("PATH")?;
712 for dir in env::split_paths(&path_var) {
713 for candidate in command_path_candidates(&dir, command) {
714 if candidate.is_file() {
715 return Some(candidate);
716 }
717 }
718 }
719
720 None
721}
722
723fn command_path_candidates(dir: &Path, command: &str) -> Vec<PathBuf> {
724 let base = dir.join(command);
725 if base.extension().is_some() {
726 return vec![base];
727 }
728
729 #[cfg(windows)]
730 let mut candidates = vec![base.clone()];
731 #[cfg(not(windows))]
732 let candidates = vec![base.clone()];
733 #[cfg(windows)]
734 {
735 for extension in windows_path_extensions() {
736 candidates.push(dir.join(format!("{}{}", command, extension)));
737 }
738 }
739 candidates
740}
741
742#[cfg(windows)]
743fn windows_path_extensions() -> Vec<String> {
744 let default = vec![
745 ".COM".to_string(),
746 ".EXE".to_string(),
747 ".BAT".to_string(),
748 ".CMD".to_string(),
749 ];
750 let Some(value) = env::var_os("PATHEXT") else {
751 return default;
752 };
753
754 let parsed = value
755 .to_string_lossy()
756 .split(';')
757 .map(str::trim)
758 .filter(|value| !value.is_empty())
759 .map(|value| value.to_ascii_uppercase())
760 .collect::<Vec<_>>();
761 if parsed.is_empty() {
762 default
763 } else {
764 parsed
765 }
766}
767
768fn capture_command_version(command: &str, args: &[&str]) -> Option<String> {
769 let output = Command::new(command).args(args).output().ok()?;
770 let stdout = String::from_utf8_lossy(&output.stdout);
771 let stderr = String::from_utf8_lossy(&output.stderr);
772 extract_version_line(stdout.lines().chain(stderr.lines()))
773}
774
775fn extract_version_line<'a, I>(lines: I) -> Option<String>
776where
777 I: Iterator<Item = &'a str>,
778{
779 lines
780 .map(str::trim)
781 .find(|line| !line.is_empty())
782 .map(|line| line.chars().take(160).collect())
783}
784
785fn insert_path(paths: &mut BTreeMap<String, String>, key: &str, value: Option<PathBuf>) {
786 if let Some(value) = value {
787 paths.insert(key.to_string(), value.display().to_string());
788 }
789}
790
791fn insert_env_path(paths: &mut BTreeMap<String, String>, key: &str, env_key: &str) {
792 if let Some(value) = env::var_os(env_key).filter(|value| !value.is_empty()) {
793 paths.insert(key.to_string(), PathBuf::from(value).display().to_string());
794 }
795}
796
797#[derive(Clone, Copy)]
798struct ToolProbe {
799 name: &'static str,
800 command: &'static str,
801 category: &'static str,
802 version_args: &'static [&'static str],
803}
804
805impl ToolProbe {
806 const fn new(
807 name: &'static str,
808 command: &'static str,
809 category: &'static str,
810 version_args: &'static [&'static str],
811 ) -> Self {
812 Self {
813 name,
814 command,
815 category,
816 version_args,
817 }
818 }
819}
820
821fn first_existing_path(candidates: &[PathBuf]) -> Option<PathBuf> {
822 candidates
823 .iter()
824 .find(|candidate| candidate.exists())
825 .cloned()
826}
827
828fn is_xbp_config_file(path: &Path) -> bool {
829 path.file_name()
830 .and_then(|value| value.to_str())
831 .is_some_and(|name| {
832 crate::utils::is_xbp_project_config_filename(name)
833 })
834}
835
836fn canonicalize_or_fallback(path: &Path) -> PathBuf {
837 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
838}
839
840#[cfg(test)]
841mod tests {
842 use super::{
843 collect_system_inventory, discover_xbp_projects, parse_github_repo_from_remote_url,
844 SystemInventoryOptions,
845 };
846 use std::fs;
847 use std::path::PathBuf;
848 use std::time::{SystemTime, UNIX_EPOCH};
849
850 fn temp_dir(label: &str) -> PathBuf {
851 let nanos = SystemTime::now()
852 .duration_since(UNIX_EPOCH)
853 .expect("time")
854 .as_nanos();
855 let path = std::env::temp_dir().join(format!("xbp-codetime-{}-{}", label, nanos));
856 fs::create_dir_all(&path).expect("temp dir");
857 path
858 }
859
860 #[test]
861 fn parses_github_https_remote() {
862 assert_eq!(
863 parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
864 Some(("xylex-group".to_string(), "xbp".to_string()))
865 );
866 }
867
868 #[test]
869 fn parses_github_ssh_remote() {
870 assert_eq!(
871 parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
872 Some(("xylex-group".to_string(), "xbp".to_string()))
873 );
874 }
875
876 #[test]
877 fn discovers_xbp_projects_from_current_dir_root() {
878 let root = temp_dir("xbp-project");
879 let project_root = root.join("demo");
880 fs::create_dir_all(project_root.join(".xbp")).expect("project dir");
881 fs::write(
882 project_root.join(".xbp").join("xbp.yaml"),
883 "project_name: demo-project\n",
884 )
885 .expect("config");
886
887 let projects = discover_xbp_projects(Some(root.as_path()));
888 assert!(projects
889 .iter()
890 .any(|project| project.name == "demo-project"));
891 }
892
893 #[test]
894 fn system_inventory_can_include_cursor_snapshot() {
895 let root = temp_dir("cursor");
896 let options = SystemInventoryOptions {
897 include_cursor: true,
898 current_dir: Some(root.clone()),
899 xbp_global_root: Some(root.join(".xbp")),
900 };
901
902 let inventory = collect_system_inventory(&options);
903 let expected_root = root.join(".xbp").display().to_string();
904 assert!(inventory.cursor.is_some());
905 assert_eq!(
906 inventory
907 .standard_paths
908 .get("xbp_global_root")
909 .map(String::as_str),
910 Some(expected_root.as_str())
911 );
912 assert!(inventory.operating_system.is_some());
913 assert!(inventory
914 .installed_tools
915 .iter()
916 .any(|tool| tool.name == "git"));
917 }
918}