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