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.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}