Skip to main content

hematite/tools/
host_inspect.rs

1use serde_json::Value;
2use std::collections::HashSet;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7const DEFAULT_MAX_ENTRIES: usize = 10;
8const MAX_ENTRIES_CAP: usize = 25;
9const DIRECTORY_SCAN_NODE_BUDGET: usize = 25_000;
10
11pub async fn inspect_host(args: &Value) -> Result<String, String> {
12    let topic = args
13        .get("topic")
14        .and_then(|v| v.as_str())
15        .unwrap_or("summary");
16    let max_entries = parse_max_entries(args);
17
18    match topic {
19        "summary" => inspect_summary(max_entries),
20        "toolchains" => inspect_toolchains(),
21        "path" => inspect_path(max_entries),
22        "desktop" => inspect_known_directory("Desktop", desktop_dir(), max_entries).await,
23        "downloads" => inspect_known_directory("Downloads", downloads_dir(), max_entries).await,
24        "disk" => {
25            let path = resolve_optional_path(args)?;
26            inspect_disk(path, max_entries).await
27        }
28        "ports" => inspect_ports(parse_port_filter(args), max_entries),
29        "repo_doctor" => {
30            let path = resolve_optional_path(args)?;
31            inspect_repo_doctor(path, max_entries)
32        }
33        "directory" => {
34            let raw_path = args
35                .get("path")
36                .and_then(|v| v.as_str())
37                .ok_or_else(|| {
38                    "Missing required argument: 'path' for inspect_host(topic: \"directory\")"
39                        .to_string()
40                })?;
41            let resolved = resolve_path(raw_path)?;
42            inspect_directory("Directory", resolved, max_entries).await
43        }
44        other => Err(format!(
45            "Unknown inspect_host topic '{}'. Use one of: summary, toolchains, path, desktop, downloads, directory, disk, ports, repo_doctor.",
46            other
47        )),
48    }
49}
50
51fn parse_max_entries(args: &Value) -> usize {
52    args.get("max_entries")
53        .and_then(|v| v.as_u64())
54        .map(|n| n as usize)
55        .unwrap_or(DEFAULT_MAX_ENTRIES)
56        .clamp(1, MAX_ENTRIES_CAP)
57}
58
59fn parse_port_filter(args: &Value) -> Option<u16> {
60    args.get("port")
61        .and_then(|v| v.as_u64())
62        .and_then(|n| u16::try_from(n).ok())
63}
64
65fn resolve_optional_path(args: &Value) -> Result<PathBuf, String> {
66    match args.get("path").and_then(|v| v.as_str()) {
67        Some(raw_path) => resolve_path(raw_path),
68        None => {
69            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))
70        }
71    }
72}
73
74fn inspect_summary(max_entries: usize) -> Result<String, String> {
75    let current_dir =
76        std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
77    let workspace_root = crate::tools::file_ops::workspace_root();
78    let workspace_mode = workspace_mode_label(&workspace_root);
79    let path_stats = analyze_path_env();
80    let toolchains = collect_toolchains();
81
82    let mut out = String::from("Host inspection: summary\n\n");
83    out.push_str(&format!("- OS: {}\n", std::env::consts::OS));
84    out.push_str(&format!("- Current directory: {}\n", current_dir.display()));
85    out.push_str(&format!("- Workspace root: {}\n", workspace_root.display()));
86    out.push_str(&format!("- Workspace mode: {}\n", workspace_mode));
87    out.push_str(&format!("- Preferred shell: {}\n", preferred_shell_label()));
88    out.push_str(&format!(
89        "- PATH entries: {} total, {} unique, {} duplicates, {} missing\n",
90        path_stats.total_entries,
91        path_stats.unique_entries,
92        path_stats.duplicate_entries.len(),
93        path_stats.missing_entries.len()
94    ));
95
96    if toolchains.found.is_empty() {
97        out.push_str(
98            "- Toolchains found: none of the common developer tools were detected on PATH\n",
99        );
100    } else {
101        out.push_str("- Toolchains found:\n");
102        for (label, version) in toolchains.found.iter().take(max_entries.min(8)) {
103            out.push_str(&format!("  - {}: {}\n", label, version));
104        }
105        if toolchains.found.len() > max_entries.min(8) {
106            out.push_str(&format!(
107                "  - ... {} more found tools omitted\n",
108                toolchains.found.len() - max_entries.min(8)
109            ));
110        }
111    }
112
113    if !toolchains.missing.is_empty() {
114        out.push_str(&format!(
115            "- Common tools not detected on PATH: {}\n",
116            toolchains.missing.join(", ")
117        ));
118    }
119
120    for (label, path) in [("Desktop", desktop_dir()), ("Downloads", downloads_dir())] {
121        match path {
122            Some(path) if path.exists() => match count_top_level_items(&path) {
123                Ok(count) => out.push_str(&format!(
124                    "- {}: {} top-level items at {}\n",
125                    label,
126                    count,
127                    path.display()
128                )),
129                Err(e) => out.push_str(&format!(
130                    "- {}: exists at {} but could not inspect ({})\n",
131                    label,
132                    path.display(),
133                    e
134                )),
135            },
136            Some(path) => out.push_str(&format!(
137                "- {}: expected at {} but not found\n",
138                label,
139                path.display()
140            )),
141            None => out.push_str(&format!("- {}: location unavailable on this host\n", label)),
142        }
143    }
144
145    Ok(out.trim_end().to_string())
146}
147
148fn inspect_toolchains() -> Result<String, String> {
149    let report = collect_toolchains();
150    let mut out = String::from("Host inspection: toolchains\n\n");
151
152    if report.found.is_empty() {
153        out.push_str("- No common developer tools were detected on PATH.");
154    } else {
155        out.push_str("Detected developer tools:\n");
156        for (label, version) in report.found {
157            out.push_str(&format!("- {}: {}\n", label, version));
158        }
159    }
160
161    if !report.missing.is_empty() {
162        out.push_str("\nNot detected on PATH:\n");
163        for label in report.missing {
164            out.push_str(&format!("- {}\n", label));
165        }
166    }
167
168    Ok(out.trim_end().to_string())
169}
170
171fn inspect_path(max_entries: usize) -> Result<String, String> {
172    let path_stats = analyze_path_env();
173    let mut out = String::from("Host inspection: PATH\n\n");
174    out.push_str(&format!("- Total entries: {}\n", path_stats.total_entries));
175    out.push_str(&format!(
176        "- Unique entries: {}\n",
177        path_stats.unique_entries
178    ));
179    out.push_str(&format!(
180        "- Duplicate entries: {}\n",
181        path_stats.duplicate_entries.len()
182    ));
183    out.push_str(&format!(
184        "- Missing paths: {}\n",
185        path_stats.missing_entries.len()
186    ));
187
188    out.push_str("\nPATH entries:\n");
189    for entry in path_stats.entries.iter().take(max_entries) {
190        out.push_str(&format!("- {}\n", entry));
191    }
192    if path_stats.entries.len() > max_entries {
193        out.push_str(&format!(
194            "- ... {} more entries omitted\n",
195            path_stats.entries.len() - max_entries
196        ));
197    }
198
199    if !path_stats.duplicate_entries.is_empty() {
200        out.push_str("\nDuplicate entries:\n");
201        for entry in path_stats.duplicate_entries.iter().take(max_entries) {
202            out.push_str(&format!("- {}\n", entry));
203        }
204        if path_stats.duplicate_entries.len() > max_entries {
205            out.push_str(&format!(
206                "- ... {} more duplicates omitted\n",
207                path_stats.duplicate_entries.len() - max_entries
208            ));
209        }
210    }
211
212    if !path_stats.missing_entries.is_empty() {
213        out.push_str("\nMissing directories:\n");
214        for entry in path_stats.missing_entries.iter().take(max_entries) {
215            out.push_str(&format!("- {}\n", entry));
216        }
217        if path_stats.missing_entries.len() > max_entries {
218            out.push_str(&format!(
219                "- ... {} more missing entries omitted\n",
220                path_stats.missing_entries.len() - max_entries
221            ));
222        }
223    }
224
225    Ok(out.trim_end().to_string())
226}
227
228async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
229    inspect_directory("Disk", path, max_entries).await
230}
231
232fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
233    let mut listeners = collect_listening_ports()?;
234    if let Some(port) = port_filter {
235        listeners.retain(|entry| entry.port == port);
236    }
237    listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
238
239    let mut out = String::from("Host inspection: ports\n\n");
240    if let Some(port) = port_filter {
241        out.push_str(&format!("- Filter port: {}\n", port));
242    }
243    out.push_str(&format!(
244        "- Listening endpoints found: {}\n",
245        listeners.len()
246    ));
247
248    if listeners.is_empty() {
249        out.push_str("\nNo listening endpoints matched.");
250        return Ok(out);
251    }
252
253    out.push_str("\nListening endpoints:\n");
254    for entry in listeners.iter().take(max_entries) {
255        let pid = entry
256            .pid
257            .as_deref()
258            .map(|pid| format!(" pid {}", pid))
259            .unwrap_or_default();
260        out.push_str(&format!(
261            "- {} {} ({}){}\n",
262            entry.protocol, entry.local, entry.state, pid
263        ));
264    }
265    if listeners.len() > max_entries {
266        out.push_str(&format!(
267            "- ... {} more listening endpoints omitted\n",
268            listeners.len() - max_entries
269        ));
270    }
271
272    Ok(out.trim_end().to_string())
273}
274
275fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
276    if !path.exists() {
277        return Err(format!("Path does not exist: {}", path.display()));
278    }
279    if !path.is_dir() {
280        return Err(format!("Path is not a directory: {}", path.display()));
281    }
282
283    let markers = collect_project_markers(&path);
284    let hematite_state = collect_hematite_state(&path);
285    let git_state = inspect_git_state(&path);
286    let release_state = inspect_release_artifacts(&path);
287
288    let mut out = String::from("Host inspection: repo_doctor\n\n");
289    out.push_str(&format!("- Path: {}\n", path.display()));
290    out.push_str(&format!(
291        "- Workspace mode: {}\n",
292        workspace_mode_for_path(&path)
293    ));
294
295    if markers.is_empty() {
296        out.push_str("- Project markers: none of Cargo.toml, package.json, pyproject.toml, go.mod, justfile, Makefile, or .git were found at this path\n");
297    } else {
298        out.push_str("- Project markers:\n");
299        for marker in markers.iter().take(max_entries) {
300            out.push_str(&format!("  - {}\n", marker));
301        }
302    }
303
304    match git_state {
305        Some(git) => {
306            out.push_str(&format!("- Git root: {}\n", git.root.display()));
307            out.push_str(&format!("- Git branch: {}\n", git.branch));
308            out.push_str(&format!("- Git status: {}\n", git.status_label()));
309        }
310        None => out.push_str("- Git: not inside a detected work tree\n"),
311    }
312
313    out.push_str(&format!(
314        "- Hematite docs/imports/reports: {}/{}/{}\n",
315        hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
316    ));
317    if hematite_state.workspace_profile {
318        out.push_str("- Workspace profile: present\n");
319    } else {
320        out.push_str("- Workspace profile: absent\n");
321    }
322
323    if let Some(release) = release_state {
324        out.push_str(&format!("- Cargo version: {}\n", release.version));
325        out.push_str(&format!(
326            "- Windows artifacts for current version: {}/{}/{}\n",
327            bool_label(release.portable_dir),
328            bool_label(release.portable_zip),
329            bool_label(release.setup_exe)
330        ));
331    }
332
333    Ok(out.trim_end().to_string())
334}
335
336async fn inspect_known_directory(
337    label: &str,
338    path: Option<PathBuf>,
339    max_entries: usize,
340) -> Result<String, String> {
341    let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
342    inspect_directory(label, path, max_entries).await
343}
344
345async fn inspect_directory(
346    label: &str,
347    path: PathBuf,
348    max_entries: usize,
349) -> Result<String, String> {
350    let label = label.to_string();
351    tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
352        .await
353        .map_err(|e| format!("inspect_host task failed: {e}"))?
354}
355
356fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
357    if !path.exists() {
358        return Err(format!("Path does not exist: {}", path.display()));
359    }
360    if !path.is_dir() {
361        return Err(format!("Path is not a directory: {}", path.display()));
362    }
363
364    let mut top_level_entries = Vec::new();
365    for entry in fs::read_dir(path)
366        .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
367    {
368        match entry {
369            Ok(entry) => top_level_entries.push(entry),
370            Err(_) => continue,
371        }
372    }
373    top_level_entries.sort_by_key(|entry| entry.file_name());
374
375    let top_level_count = top_level_entries.len();
376    let mut sample_names = Vec::new();
377    let mut largest_entries = Vec::new();
378    let mut aggregate = PathAggregate::default();
379    let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
380
381    for entry in top_level_entries {
382        let name = entry.file_name().to_string_lossy().to_string();
383        if sample_names.len() < max_entries {
384            sample_names.push(name.clone());
385        }
386        let kind = match entry.file_type() {
387            Ok(ft) if ft.is_dir() => "dir",
388            Ok(ft) if ft.is_symlink() => "symlink",
389            _ => "file",
390        };
391        let stats = measure_path(&entry.path(), &mut budget);
392        aggregate.merge(&stats);
393        largest_entries.push(LargestEntry {
394            name,
395            kind,
396            bytes: stats.total_bytes,
397        });
398    }
399
400    largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
401
402    let mut out = format!("Directory inspection: {}\n\n", label);
403    out.push_str(&format!("- Path: {}\n", path.display()));
404    out.push_str(&format!("- Top-level items: {}\n", top_level_count));
405    out.push_str(&format!("- Recursive files: {}\n", aggregate.file_count));
406    out.push_str(&format!(
407        "- Recursive directories: {}\n",
408        aggregate.dir_count
409    ));
410    out.push_str(&format!(
411        "- Total size: {}{}\n",
412        human_bytes(aggregate.total_bytes),
413        if aggregate.partial {
414            " (partial scan)"
415        } else {
416            ""
417        }
418    ));
419    if aggregate.skipped_entries > 0 {
420        out.push_str(&format!(
421            "- Skipped entries: {} (permissions, symlinks, or scan budget)\n",
422            aggregate.skipped_entries
423        ));
424    }
425
426    if !largest_entries.is_empty() {
427        out.push_str("\nLargest top-level entries:\n");
428        for entry in largest_entries.iter().take(max_entries) {
429            out.push_str(&format!(
430                "- {} [{}] - {}\n",
431                entry.name,
432                entry.kind,
433                human_bytes(entry.bytes)
434            ));
435        }
436    }
437
438    if !sample_names.is_empty() {
439        out.push_str("\nSample names:\n");
440        for name in sample_names {
441            out.push_str(&format!("- {}\n", name));
442        }
443    }
444
445    Ok(out.trim_end().to_string())
446}
447
448fn resolve_path(raw: &str) -> Result<PathBuf, String> {
449    let trimmed = raw.trim();
450    if trimmed.is_empty() {
451        return Err("Path must not be empty.".to_string());
452    }
453
454    if let Some(rest) = trimmed
455        .strip_prefix("~/")
456        .or_else(|| trimmed.strip_prefix("~\\"))
457    {
458        let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
459        return Ok(home.join(rest));
460    }
461
462    let path = PathBuf::from(trimmed);
463    if path.is_absolute() {
464        Ok(path)
465    } else {
466        let cwd =
467            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
468        Ok(cwd.join(path))
469    }
470}
471
472fn workspace_mode_label(workspace_root: &Path) -> &'static str {
473    workspace_mode_for_path(workspace_root)
474}
475
476fn workspace_mode_for_path(path: &Path) -> &'static str {
477    if is_project_marker_path(path) {
478        "project"
479    } else if path.join(".hematite").join("docs").exists()
480        || path.join(".hematite").join("imports").exists()
481        || path.join(".hematite").join("reports").exists()
482    {
483        "docs-only"
484    } else {
485        "general directory"
486    }
487}
488
489fn is_project_marker_path(path: &Path) -> bool {
490    [
491        "Cargo.toml",
492        "package.json",
493        "pyproject.toml",
494        "go.mod",
495        "composer.json",
496        "requirements.txt",
497        "Makefile",
498        "justfile",
499    ]
500    .iter()
501    .any(|name| path.join(name).exists())
502        || path.join(".git").exists()
503}
504
505fn preferred_shell_label() -> &'static str {
506    #[cfg(target_os = "windows")]
507    {
508        "PowerShell"
509    }
510    #[cfg(not(target_os = "windows"))]
511    {
512        "sh"
513    }
514}
515
516fn desktop_dir() -> Option<PathBuf> {
517    home::home_dir().map(|home| home.join("Desktop"))
518}
519
520fn downloads_dir() -> Option<PathBuf> {
521    home::home_dir().map(|home| home.join("Downloads"))
522}
523
524fn count_top_level_items(path: &Path) -> Result<usize, String> {
525    let mut count = 0usize;
526    for entry in
527        fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
528    {
529        if entry.is_ok() {
530            count += 1;
531        }
532    }
533    Ok(count)
534}
535
536#[derive(Default)]
537struct PathAggregate {
538    total_bytes: u64,
539    file_count: u64,
540    dir_count: u64,
541    skipped_entries: u64,
542    partial: bool,
543}
544
545impl PathAggregate {
546    fn merge(&mut self, other: &PathAggregate) {
547        self.total_bytes += other.total_bytes;
548        self.file_count += other.file_count;
549        self.dir_count += other.dir_count;
550        self.skipped_entries += other.skipped_entries;
551        self.partial |= other.partial;
552    }
553}
554
555struct LargestEntry {
556    name: String,
557    kind: &'static str,
558    bytes: u64,
559}
560
561fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
562    if *budget == 0 {
563        return PathAggregate {
564            partial: true,
565            skipped_entries: 1,
566            ..PathAggregate::default()
567        };
568    }
569    *budget -= 1;
570
571    let metadata = match fs::symlink_metadata(path) {
572        Ok(metadata) => metadata,
573        Err(_) => {
574            return PathAggregate {
575                skipped_entries: 1,
576                ..PathAggregate::default()
577            }
578        }
579    };
580
581    let file_type = metadata.file_type();
582    if file_type.is_symlink() {
583        return PathAggregate {
584            skipped_entries: 1,
585            ..PathAggregate::default()
586        };
587    }
588
589    if metadata.is_file() {
590        return PathAggregate {
591            total_bytes: metadata.len(),
592            file_count: 1,
593            ..PathAggregate::default()
594        };
595    }
596
597    if !metadata.is_dir() {
598        return PathAggregate::default();
599    }
600
601    let mut aggregate = PathAggregate {
602        dir_count: 1,
603        ..PathAggregate::default()
604    };
605
606    let read_dir = match fs::read_dir(path) {
607        Ok(read_dir) => read_dir,
608        Err(_) => {
609            aggregate.skipped_entries += 1;
610            return aggregate;
611        }
612    };
613
614    for child in read_dir {
615        match child {
616            Ok(child) => {
617                let child_stats = measure_path(&child.path(), budget);
618                aggregate.merge(&child_stats);
619            }
620            Err(_) => aggregate.skipped_entries += 1,
621        }
622    }
623
624    aggregate
625}
626
627struct PathAnalysis {
628    total_entries: usize,
629    unique_entries: usize,
630    entries: Vec<String>,
631    duplicate_entries: Vec<String>,
632    missing_entries: Vec<String>,
633}
634
635fn analyze_path_env() -> PathAnalysis {
636    let mut entries = Vec::new();
637    let mut duplicate_entries = Vec::new();
638    let mut missing_entries = Vec::new();
639    let mut seen = HashSet::new();
640
641    let raw_path = std::env::var_os("PATH").unwrap_or_default();
642    for path in std::env::split_paths(&raw_path) {
643        let display = path.display().to_string();
644        if display.trim().is_empty() {
645            continue;
646        }
647
648        let normalized = normalize_path_entry(&display);
649        if !seen.insert(normalized) {
650            duplicate_entries.push(display.clone());
651        }
652        if !path.exists() {
653            missing_entries.push(display.clone());
654        }
655        entries.push(display);
656    }
657
658    let total_entries = entries.len();
659    let unique_entries = seen.len();
660
661    PathAnalysis {
662        total_entries,
663        unique_entries,
664        entries,
665        duplicate_entries,
666        missing_entries,
667    }
668}
669
670fn normalize_path_entry(value: &str) -> String {
671    #[cfg(target_os = "windows")]
672    {
673        value
674            .replace('/', "\\")
675            .trim_end_matches(['\\', '/'])
676            .to_ascii_lowercase()
677    }
678    #[cfg(not(target_os = "windows"))]
679    {
680        value.trim_end_matches('/').to_string()
681    }
682}
683
684struct ToolchainReport {
685    found: Vec<(String, String)>,
686    missing: Vec<String>,
687}
688
689#[derive(Debug, Clone)]
690struct ListeningPort {
691    protocol: String,
692    local: String,
693    port: u16,
694    state: String,
695    pid: Option<String>,
696}
697
698fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
699    #[cfg(target_os = "windows")]
700    {
701        collect_windows_listening_ports()
702    }
703    #[cfg(not(target_os = "windows"))]
704    {
705        collect_unix_listening_ports()
706    }
707}
708
709#[cfg(target_os = "windows")]
710fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
711    let output = Command::new("netstat")
712        .args(["-ano", "-p", "tcp"])
713        .output()
714        .map_err(|e| format!("Failed to run netstat: {e}"))?;
715    if !output.status.success() {
716        return Err("netstat returned a non-success status.".to_string());
717    }
718
719    let text = String::from_utf8_lossy(&output.stdout);
720    let mut listeners = Vec::new();
721    for line in text.lines() {
722        let trimmed = line.trim();
723        if !trimmed.starts_with("TCP") {
724            continue;
725        }
726        let cols: Vec<&str> = trimmed.split_whitespace().collect();
727        if cols.len() < 5 || cols[3] != "LISTENING" {
728            continue;
729        }
730        let Some(port) = extract_port_from_socket(cols[1]) else {
731            continue;
732        };
733        listeners.push(ListeningPort {
734            protocol: cols[0].to_string(),
735            local: cols[1].to_string(),
736            port,
737            state: cols[3].to_string(),
738            pid: Some(cols[4].to_string()),
739        });
740    }
741
742    Ok(listeners)
743}
744
745#[cfg(not(target_os = "windows"))]
746fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
747    let output = Command::new("ss")
748        .args(["-ltn"])
749        .output()
750        .map_err(|e| format!("Failed to run ss: {e}"))?;
751    if !output.status.success() {
752        return Err("ss returned a non-success status.".to_string());
753    }
754
755    let text = String::from_utf8_lossy(&output.stdout);
756    let mut listeners = Vec::new();
757    for line in text.lines().skip(1) {
758        let cols: Vec<&str> = line.split_whitespace().collect();
759        if cols.len() < 4 {
760            continue;
761        }
762        let Some(port) = extract_port_from_socket(cols[3]) else {
763            continue;
764        };
765        listeners.push(ListeningPort {
766            protocol: "tcp".to_string(),
767            local: cols[3].to_string(),
768            port,
769            state: cols[0].to_string(),
770            pid: None,
771        });
772    }
773
774    Ok(listeners)
775}
776
777fn extract_port_from_socket(value: &str) -> Option<u16> {
778    let cleaned = value.trim().trim_matches(['[', ']']);
779    let port_str = cleaned.rsplit(':').next()?;
780    port_str.parse::<u16>().ok()
781}
782
783struct GitState {
784    root: PathBuf,
785    branch: String,
786    dirty_entries: usize,
787}
788
789impl GitState {
790    fn status_label(&self) -> String {
791        if self.dirty_entries == 0 {
792            "clean".to_string()
793        } else {
794            format!("dirty ({} changed path(s))", self.dirty_entries)
795        }
796    }
797}
798
799fn inspect_git_state(path: &Path) -> Option<GitState> {
800    let root = capture_first_line(
801        "git",
802        &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
803    )?;
804    let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
805        .unwrap_or_else(|| "detached".to_string());
806    let output = Command::new("git")
807        .args(["-C", path.to_str()?, "status", "--short"])
808        .output()
809        .ok()?;
810    if !output.status.success() {
811        return None;
812    }
813    let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
814    Some(GitState {
815        root: PathBuf::from(root),
816        branch,
817        dirty_entries,
818    })
819}
820
821struct HematiteState {
822    docs_count: usize,
823    import_count: usize,
824    report_count: usize,
825    workspace_profile: bool,
826}
827
828fn collect_hematite_state(path: &Path) -> HematiteState {
829    let root = path.join(".hematite");
830    HematiteState {
831        docs_count: count_entries_if_exists(&root.join("docs")),
832        import_count: count_entries_if_exists(&root.join("imports")),
833        report_count: count_entries_if_exists(&root.join("reports")),
834        workspace_profile: root.join("workspace_profile.json").exists(),
835    }
836}
837
838fn count_entries_if_exists(path: &Path) -> usize {
839    if !path.exists() || !path.is_dir() {
840        return 0;
841    }
842    fs::read_dir(path)
843        .ok()
844        .map(|iter| iter.filter(|entry| entry.is_ok()).count())
845        .unwrap_or(0)
846}
847
848fn collect_project_markers(path: &Path) -> Vec<String> {
849    [
850        "Cargo.toml",
851        "package.json",
852        "pyproject.toml",
853        "go.mod",
854        "justfile",
855        "Makefile",
856        ".git",
857    ]
858    .iter()
859    .filter_map(|name| path.join(name).exists().then(|| (*name).to_string()))
860    .collect()
861}
862
863struct ReleaseArtifactState {
864    version: String,
865    portable_dir: bool,
866    portable_zip: bool,
867    setup_exe: bool,
868}
869
870fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
871    let cargo_toml = path.join("Cargo.toml");
872    if !cargo_toml.exists() {
873        return None;
874    }
875    let cargo_text = fs::read_to_string(cargo_toml).ok()?;
876    let version = [regex_line_capture(
877        &cargo_text,
878        r#"(?m)^version\s*=\s*"([^"]+)""#,
879    )?]
880    .concat();
881    let dist_windows = path.join("dist").join("windows");
882    let prefix = format!("Hematite-{}", version);
883    Some(ReleaseArtifactState {
884        version,
885        portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
886        portable_zip: dist_windows
887            .join(format!("{}-portable.zip", prefix))
888            .exists(),
889        setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
890    })
891}
892
893fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
894    let regex = regex::Regex::new(pattern).ok()?;
895    let captures = regex.captures(text)?;
896    captures.get(1).map(|m| m.as_str().to_string())
897}
898
899fn bool_label(value: bool) -> &'static str {
900    if value {
901        "yes"
902    } else {
903        "no"
904    }
905}
906
907fn collect_toolchains() -> ToolchainReport {
908    let checks = [
909        ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
910        ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
911        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
912        ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
913        ToolCheck::new(
914            "npm",
915            &[
916                CommandProbe::new("npm", &["--version"]),
917                CommandProbe::new("npm.cmd", &["--version"]),
918            ],
919        ),
920        ToolCheck::new(
921            "pnpm",
922            &[
923                CommandProbe::new("pnpm", &["--version"]),
924                CommandProbe::new("pnpm.cmd", &["--version"]),
925            ],
926        ),
927        ToolCheck::new(
928            "python",
929            &[
930                CommandProbe::new("python", &["--version"]),
931                CommandProbe::new("python3", &["--version"]),
932                CommandProbe::new("py", &["-3", "--version"]),
933                CommandProbe::new("py", &["--version"]),
934            ],
935        ),
936        ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
937        ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
938        ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
939        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
940    ];
941
942    let mut found = Vec::new();
943    let mut missing = Vec::new();
944
945    for check in checks {
946        match check.detect() {
947            Some(version) => found.push((check.label.to_string(), version)),
948            None => missing.push(check.label.to_string()),
949        }
950    }
951
952    ToolchainReport { found, missing }
953}
954
955#[derive(Clone)]
956struct ToolCheck {
957    label: &'static str,
958    probes: Vec<CommandProbe>,
959}
960
961impl ToolCheck {
962    fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
963        Self {
964            label,
965            probes: probes.to_vec(),
966        }
967    }
968
969    fn detect(&self) -> Option<String> {
970        for probe in &self.probes {
971            if let Some(output) = capture_first_line(probe.program, probe.args) {
972                return Some(output);
973            }
974        }
975        None
976    }
977}
978
979#[derive(Clone, Copy)]
980struct CommandProbe {
981    program: &'static str,
982    args: &'static [&'static str],
983}
984
985impl CommandProbe {
986    const fn new(program: &'static str, args: &'static [&'static str]) -> Self {
987        Self { program, args }
988    }
989}
990
991fn capture_first_line(program: &str, args: &[&str]) -> Option<String> {
992    let output = std::process::Command::new(program)
993        .args(args)
994        .output()
995        .ok()?;
996    if !output.status.success() {
997        return None;
998    }
999
1000    let stdout = if output.stdout.is_empty() {
1001        String::from_utf8_lossy(&output.stderr).into_owned()
1002    } else {
1003        String::from_utf8_lossy(&output.stdout).into_owned()
1004    };
1005
1006    stdout
1007        .lines()
1008        .map(str::trim)
1009        .find(|line| !line.is_empty())
1010        .map(|line| line.to_string())
1011}
1012
1013fn human_bytes(bytes: u64) -> String {
1014    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
1015    let mut value = bytes as f64;
1016    let mut unit_index = 0usize;
1017
1018    while value >= 1024.0 && unit_index < UNITS.len() - 1 {
1019        value /= 1024.0;
1020        unit_index += 1;
1021    }
1022
1023    if unit_index == 0 {
1024        format!("{} {}", bytes, UNITS[unit_index])
1025    } else {
1026        format!("{value:.1} {}", UNITS[unit_index])
1027    }
1028}