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}