Skip to main content

xbp_cli/codetime/
inventory.rs

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}