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 "env_doctor" => inspect_env_doctor(max_entries),
23 "fix_plan" => inspect_fix_plan(parse_issue_text(args), parse_port_filter(args), max_entries).await,
24 "network" => inspect_network(max_entries),
25 "services" => inspect_services(parse_name_filter(args), max_entries),
26 "processes" => inspect_processes(parse_name_filter(args), max_entries),
27 "desktop" => inspect_known_directory("Desktop", desktop_dir(), max_entries).await,
28 "downloads" => inspect_known_directory("Downloads", downloads_dir(), max_entries).await,
29 "disk" => {
30 let path = resolve_optional_path(args)?;
31 inspect_disk(path, max_entries).await
32 }
33 "ports" => inspect_ports(parse_port_filter(args), max_entries),
34 "log_check" => inspect_log_check(max_entries),
35 "startup_items" | "startup" | "boot" | "autorun" => inspect_startup_items(max_entries),
36 "health_report" | "system_health" => inspect_health_report(),
37 "storage" => inspect_storage(max_entries),
38 "hardware" => inspect_hardware(),
39 "updates" | "windows_update" => inspect_updates(),
40 "security" | "antivirus" | "defender" => inspect_security(),
41 "pending_reboot" | "reboot_required" => inspect_pending_reboot(),
42 "disk_health" | "smart" | "drive_health" => inspect_disk_health(),
43 "battery" => inspect_battery(),
44 "recent_crashes" | "crashes" | "bsod" => inspect_recent_crashes(max_entries),
45 "scheduled_tasks" | "tasks" => inspect_scheduled_tasks(max_entries),
46 "dev_conflicts" | "dev_environment" => inspect_dev_conflicts(),
47 "connectivity" | "internet" | "internet_check" => inspect_connectivity(),
48 "wifi" | "wi-fi" | "wireless" | "wlan" => inspect_wifi(),
49 "connections" | "tcp_connections" | "active_connections" => inspect_connections(max_entries),
50 "vpn" => inspect_vpn(),
51 "proxy" | "proxy_settings" => inspect_proxy(),
52 "firewall_rules" | "firewall-rules" => inspect_firewall_rules(max_entries),
53 "traceroute" | "tracert" | "trace_route" | "trace" => {
54 let host = args
55 .get("host")
56 .and_then(|v| v.as_str())
57 .unwrap_or("8.8.8.8")
58 .to_string();
59 inspect_traceroute(&host, max_entries)
60 }
61 "dns_cache" | "dnscache" | "dns-cache" => inspect_dns_cache(max_entries),
62 "arp" | "arp_table" => inspect_arp(),
63 "route_table" | "routes" | "routing_table" => inspect_route_table(max_entries),
64 "os_config" | "system_config" => inspect_os_config(),
65 "resource_load" | "performance" | "system_load" | "performance_diagnosis" => inspect_resource_load(),
66 "env" | "environment" | "environment_variables" | "env_vars" => inspect_env(max_entries),
67 "hosts_file" | "hosts" | "etc_hosts" => inspect_hosts_file(),
68 "docker" | "containers" | "docker_status" => inspect_docker(max_entries),
69 "wsl" | "wsl_distros" | "subsystem" => inspect_wsl(),
70 "ssh" | "ssh_config" | "ssh_status" => inspect_ssh(),
71 "installed_software" | "installed" | "programs" | "software" | "packages" => inspect_installed_software(max_entries),
72 "git_config" | "git_global" => inspect_git_config(),
73 "databases" | "database" | "db_services" | "db" => inspect_databases(),
74 "user_accounts" | "users" | "local_users" | "accounts" => inspect_user_accounts(max_entries),
75 "audit_policy" | "audit" | "auditpol" => inspect_audit_policy(),
76 "shares" | "smb_shares" | "network_shares" | "mapped_drives" => inspect_shares(max_entries),
77 "dns_servers" | "dns_config" | "dns_resolver" | "nameservers" => inspect_dns_servers(),
78 "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => inspect_bitlocker(),
79 "rdp" | "remote_desktop" | "rdp_status" => inspect_rdp(),
80 "shadow_copies" | "vss" | "volume_shadow" | "backups" | "snapshots" => inspect_shadow_copies(),
81 "pagefile" | "page_file" | "virtual_memory" | "swap" => inspect_pagefile(),
82 "windows_features" | "optional_features" | "installed_features" | "features" => inspect_windows_features(max_entries),
83 "printers" | "printer" | "print_queue" | "printing" => inspect_printers(max_entries),
84 "winrm" | "remote_management" | "psremoting" => inspect_winrm(),
85 "network_stats" | "adapter_stats" | "nic_stats" | "interface_stats" => inspect_network_stats(max_entries),
86 "udp_ports" | "udp_listeners" | "udp" => inspect_udp_ports(max_entries),
87 "gpo" | "group_policy" | "applied_policies" => inspect_gpo(),
88 "certificates" | "certs" | "ssl_certs" => inspect_certificates(max_entries),
89 "integrity" | "sfc" | "dism" | "system_health_deep" => inspect_integrity(),
90 "domain" | "active_directory" | "ad_context" | "workgroup" => inspect_domain(),
91 "device_health" | "hardware_errors" | "yellow_bangs" => inspect_device_health(),
92 "drivers" | "system_drivers" | "driver_list" => inspect_drivers(max_entries),
93 "peripherals" | "usb" | "input_devices" | "connected_hardware" => inspect_peripherals(max_entries),
94 "sessions" | "logins" | "active_sessions" => inspect_sessions(max_entries),
95 "repo_doctor" => {
96 let path = resolve_optional_path(args)?;
97 inspect_repo_doctor(path, max_entries)
98 }
99 "directory" => {
100 let raw_path = args
101 .get("path")
102 .and_then(|v| v.as_str())
103 .ok_or_else(|| {
104 "Missing required argument: 'path' for inspect_host(topic: \"directory\")"
105 .to_string()
106 })?;
107 let resolved = resolve_path(raw_path)?;
108 inspect_directory("Directory", resolved, max_entries).await
109 }
110 "disk_benchmark" | "stress_test" | "io_intensity" => {
111 let path = resolve_optional_path(args)?;
112 inspect_disk_benchmark(path).await
113 }
114 other => Err(format!(
115 "Unknown inspect_host topic '{}'. Use one of: summary, toolchains, path, env_doctor, fix_plan, network, services, processes, desktop, downloads, directory, disk_benchmark, disk, ports, repo_doctor, log_check, startup_items, health_report, storage, hardware, updates, security, pending_reboot, disk_health, battery, recent_crashes, scheduled_tasks, dev_conflicts, connectivity, wifi, connections, vpn, proxy, firewall_rules, traceroute, dns_cache, arp, route_table, os_config, resource_load, env, hosts_file, docker, wsl, ssh, installed_software, git_config, databases, user_accounts, audit_policy, shares, dns_servers, bitlocker, rdp, shadow_copies, pagefile, windows_features, printers, winrm, network_stats, udp_ports, gpo, certificates, integrity, domain, device_health, drivers, peripherals, sessions.",
116 other
117 )),
118
119 }
120}
121
122fn parse_max_entries(args: &Value) -> usize {
123 args.get("max_entries")
124 .and_then(|v| v.as_u64())
125 .map(|n| n as usize)
126 .unwrap_or(DEFAULT_MAX_ENTRIES)
127 .clamp(1, MAX_ENTRIES_CAP)
128}
129
130fn parse_port_filter(args: &Value) -> Option<u16> {
131 args.get("port")
132 .and_then(|v| v.as_u64())
133 .and_then(|n| u16::try_from(n).ok())
134}
135
136fn parse_name_filter(args: &Value) -> Option<String> {
137 args.get("name")
138 .and_then(|v| v.as_str())
139 .map(str::trim)
140 .filter(|value| !value.is_empty())
141 .map(|value| value.to_string())
142}
143
144fn parse_issue_text(args: &Value) -> Option<String> {
145 args.get("issue")
146 .and_then(|v| v.as_str())
147 .map(str::trim)
148 .filter(|value| !value.is_empty())
149 .map(|value| value.to_string())
150}
151
152fn resolve_optional_path(args: &Value) -> Result<PathBuf, String> {
153 match args.get("path").and_then(|v| v.as_str()) {
154 Some(raw_path) => resolve_path(raw_path),
155 None => {
156 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))
157 }
158 }
159}
160
161fn inspect_summary(max_entries: usize) -> Result<String, String> {
162 let current_dir =
163 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
164 let workspace_root = crate::tools::file_ops::workspace_root();
165 let workspace_mode = workspace_mode_label(&workspace_root);
166 let path_stats = analyze_path_env();
167 let toolchains = collect_toolchains();
168
169 let mut out = String::from("Host inspection: summary\n\n");
170 out.push_str(&format!("- OS: {}\n", std::env::consts::OS));
171 out.push_str(&format!("- Current directory: {}\n", current_dir.display()));
172 out.push_str(&format!("- Workspace root: {}\n", workspace_root.display()));
173 out.push_str(&format!("- Workspace mode: {}\n", workspace_mode));
174 out.push_str(&format!("- Preferred shell: {}\n", preferred_shell_label()));
175 out.push_str(&format!(
176 "- PATH entries: {} total, {} unique, {} duplicates, {} missing\n",
177 path_stats.total_entries,
178 path_stats.unique_entries,
179 path_stats.duplicate_entries.len(),
180 path_stats.missing_entries.len()
181 ));
182
183 if toolchains.found.is_empty() {
184 out.push_str(
185 "- Toolchains found: none of the common developer tools were detected on PATH\n",
186 );
187 } else {
188 out.push_str("- Toolchains found:\n");
189 for (label, version) in toolchains.found.iter().take(max_entries.min(8)) {
190 out.push_str(&format!(" - {}: {}\n", label, version));
191 }
192 if toolchains.found.len() > max_entries.min(8) {
193 out.push_str(&format!(
194 " - ... {} more found tools omitted\n",
195 toolchains.found.len() - max_entries.min(8)
196 ));
197 }
198 }
199
200 if !toolchains.missing.is_empty() {
201 out.push_str(&format!(
202 "- Common tools not detected on PATH: {}\n",
203 toolchains.missing.join(", ")
204 ));
205 }
206
207 for (label, path) in [("Desktop", desktop_dir()), ("Downloads", downloads_dir())] {
208 match path {
209 Some(path) if path.exists() => match count_top_level_items(&path) {
210 Ok(count) => out.push_str(&format!(
211 "- {}: {} top-level items at {}\n",
212 label,
213 count,
214 path.display()
215 )),
216 Err(e) => out.push_str(&format!(
217 "- {}: exists at {} but could not inspect ({})\n",
218 label,
219 path.display(),
220 e
221 )),
222 },
223 Some(path) => out.push_str(&format!(
224 "- {}: expected at {} but not found\n",
225 label,
226 path.display()
227 )),
228 None => out.push_str(&format!("- {}: location unavailable on this host\n", label)),
229 }
230 }
231
232 Ok(out.trim_end().to_string())
233}
234
235fn inspect_toolchains() -> Result<String, String> {
236 let report = collect_toolchains();
237 let mut out = String::from("Host inspection: toolchains\n\n");
238
239 if report.found.is_empty() {
240 out.push_str("- No common developer tools were detected on PATH.");
241 } else {
242 out.push_str("Detected developer tools:\n");
243 for (label, version) in report.found {
244 out.push_str(&format!("- {}: {}\n", label, version));
245 }
246 }
247
248 if !report.missing.is_empty() {
249 out.push_str("\nNot detected on PATH:\n");
250 for label in report.missing {
251 out.push_str(&format!("- {}\n", label));
252 }
253 }
254
255 Ok(out.trim_end().to_string())
256}
257
258fn inspect_path(max_entries: usize) -> Result<String, String> {
259 let path_stats = analyze_path_env();
260 let mut out = String::from("Host inspection: PATH\n\n");
261 out.push_str(&format!("- Total entries: {}\n", path_stats.total_entries));
262 out.push_str(&format!(
263 "- Unique entries: {}\n",
264 path_stats.unique_entries
265 ));
266 out.push_str(&format!(
267 "- Duplicate entries: {}\n",
268 path_stats.duplicate_entries.len()
269 ));
270 out.push_str(&format!(
271 "- Missing paths: {}\n",
272 path_stats.missing_entries.len()
273 ));
274
275 out.push_str("\nPATH entries:\n");
276 for entry in path_stats.entries.iter().take(max_entries) {
277 out.push_str(&format!("- {}\n", entry));
278 }
279 if path_stats.entries.len() > max_entries {
280 out.push_str(&format!(
281 "- ... {} more entries omitted\n",
282 path_stats.entries.len() - max_entries
283 ));
284 }
285
286 if !path_stats.duplicate_entries.is_empty() {
287 out.push_str("\nDuplicate entries:\n");
288 for entry in path_stats.duplicate_entries.iter().take(max_entries) {
289 out.push_str(&format!("- {}\n", entry));
290 }
291 if path_stats.duplicate_entries.len() > max_entries {
292 out.push_str(&format!(
293 "- ... {} more duplicates omitted\n",
294 path_stats.duplicate_entries.len() - max_entries
295 ));
296 }
297 }
298
299 if !path_stats.missing_entries.is_empty() {
300 out.push_str("\nMissing directories:\n");
301 for entry in path_stats.missing_entries.iter().take(max_entries) {
302 out.push_str(&format!("- {}\n", entry));
303 }
304 if path_stats.missing_entries.len() > max_entries {
305 out.push_str(&format!(
306 "- ... {} more missing entries omitted\n",
307 path_stats.missing_entries.len() - max_entries
308 ));
309 }
310 }
311
312 Ok(out.trim_end().to_string())
313}
314
315fn inspect_env_doctor(max_entries: usize) -> Result<String, String> {
316 let path_stats = analyze_path_env();
317 let toolchains = collect_toolchains();
318 let package_managers = collect_package_managers();
319 let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
320
321 let mut out = String::from("Host inspection: env_doctor\n\n");
322 out.push_str(&format!(
323 "- PATH health: {} duplicates, {} missing entries\n",
324 path_stats.duplicate_entries.len(),
325 path_stats.missing_entries.len()
326 ));
327 out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
328 out.push_str(&format!(
329 "- Package managers found: {}\n",
330 package_managers.found.len()
331 ));
332
333 if !package_managers.found.is_empty() {
334 out.push_str("\nPackage managers:\n");
335 for (label, version) in package_managers.found.iter().take(max_entries) {
336 out.push_str(&format!("- {}: {}\n", label, version));
337 }
338 if package_managers.found.len() > max_entries {
339 out.push_str(&format!(
340 "- ... {} more package managers omitted\n",
341 package_managers.found.len() - max_entries
342 ));
343 }
344 }
345
346 if !path_stats.duplicate_entries.is_empty() {
347 out.push_str("\nDuplicate PATH entries:\n");
348 for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
349 out.push_str(&format!("- {}\n", entry));
350 }
351 if path_stats.duplicate_entries.len() > max_entries.min(5) {
352 out.push_str(&format!(
353 "- ... {} more duplicate entries omitted\n",
354 path_stats.duplicate_entries.len() - max_entries.min(5)
355 ));
356 }
357 }
358
359 if !path_stats.missing_entries.is_empty() {
360 out.push_str("\nMissing PATH entries:\n");
361 for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
362 out.push_str(&format!("- {}\n", entry));
363 }
364 if path_stats.missing_entries.len() > max_entries.min(5) {
365 out.push_str(&format!(
366 "- ... {} more missing entries omitted\n",
367 path_stats.missing_entries.len() - max_entries.min(5)
368 ));
369 }
370 }
371
372 if !findings.is_empty() {
373 out.push_str("\nFindings:\n");
374 for finding in findings.iter().take(max_entries.max(5)) {
375 out.push_str(&format!("- {}\n", finding));
376 }
377 if findings.len() > max_entries.max(5) {
378 out.push_str(&format!(
379 "- ... {} more findings omitted\n",
380 findings.len() - max_entries.max(5)
381 ));
382 }
383 } else {
384 out.push_str("\nFindings:\n- No obvious environment drift was detected from PATH and package-manager checks.");
385 }
386
387 out.push_str(
388 "\nGuidance:\n- This report already includes the PATH and package-manager health details. Do not call `inspect_host(path)` next unless the user explicitly asks for the raw PATH list.",
389 );
390
391 Ok(out.trim_end().to_string())
392}
393
394#[derive(Clone, Copy, Debug, Eq, PartialEq)]
395enum FixPlanKind {
396 EnvPath,
397 PortConflict,
398 LmStudio,
399 DriverInstall,
400 GroupPolicy,
401 FirewallRule,
402 SshKey,
403 WslSetup,
404 ServiceConfig,
405 WindowsActivation,
406 RegistryEdit,
407 ScheduledTaskCreate,
408 DiskCleanup,
409 Generic,
410}
411
412async fn inspect_fix_plan(
413 issue: Option<String>,
414 port_filter: Option<u16>,
415 max_entries: usize,
416) -> Result<String, String> {
417 let issue = issue.unwrap_or_else(|| {
418 "Help me fix PATH, toolchain, port-conflict, or LM Studio connectivity problems."
419 .to_string()
420 });
421 let plan_kind = classify_fix_plan_kind(&issue, port_filter);
422 match plan_kind {
423 FixPlanKind::EnvPath => inspect_env_fix_plan(&issue, max_entries),
424 FixPlanKind::PortConflict => inspect_port_fix_plan(&issue, port_filter, max_entries),
425 FixPlanKind::LmStudio => inspect_lm_studio_fix_plan(&issue, max_entries).await,
426 FixPlanKind::DriverInstall => inspect_driver_install_fix_plan(&issue),
427 FixPlanKind::GroupPolicy => inspect_group_policy_fix_plan(&issue),
428 FixPlanKind::FirewallRule => inspect_firewall_rule_fix_plan(&issue),
429 FixPlanKind::SshKey => inspect_ssh_key_fix_plan(&issue),
430 FixPlanKind::WslSetup => inspect_wsl_setup_fix_plan(&issue),
431 FixPlanKind::ServiceConfig => inspect_service_config_fix_plan(&issue),
432 FixPlanKind::WindowsActivation => inspect_windows_activation_fix_plan(&issue),
433 FixPlanKind::RegistryEdit => inspect_registry_edit_fix_plan(&issue),
434 FixPlanKind::ScheduledTaskCreate => inspect_scheduled_task_fix_plan(&issue),
435 FixPlanKind::DiskCleanup => inspect_disk_cleanup_fix_plan(&issue),
436 FixPlanKind::Generic => inspect_generic_fix_plan(&issue),
437 }
438}
439
440fn classify_fix_plan_kind(issue: &str, port_filter: Option<u16>) -> FixPlanKind {
441 let lower = issue.to_ascii_lowercase();
442 if lower.contains("firewall rule")
445 || lower.contains("inbound rule")
446 || lower.contains("outbound rule")
447 || (lower.contains("firewall")
448 && (lower.contains("allow")
449 || lower.contains("block")
450 || lower.contains("create")
451 || lower.contains("open")))
452 {
453 FixPlanKind::FirewallRule
454 } else if port_filter.is_some()
455 || lower.contains("port ")
456 || lower.contains("address already in use")
457 || lower.contains("already in use")
458 || lower.contains("what owns port")
459 || lower.contains("listening on port")
460 {
461 FixPlanKind::PortConflict
462 } else if lower.contains("lm studio")
463 || lower.contains("localhost:1234")
464 || lower.contains("/v1/models")
465 || lower.contains("no coding model loaded")
466 || lower.contains("embedding model")
467 || lower.contains("server on port 1234")
468 || lower.contains("runtime refresh")
469 {
470 FixPlanKind::LmStudio
471 } else if lower.contains("driver")
472 || lower.contains("gpu driver")
473 || lower.contains("nvidia driver")
474 || lower.contains("amd driver")
475 || lower.contains("install driver")
476 || lower.contains("update driver")
477 {
478 FixPlanKind::DriverInstall
479 } else if lower.contains("group policy")
480 || lower.contains("gpedit")
481 || lower.contains("local policy")
482 || lower.contains("secpol")
483 || lower.contains("administrative template")
484 {
485 FixPlanKind::GroupPolicy
486 } else if lower.contains("ssh key")
487 || lower.contains("ssh-keygen")
488 || lower.contains("generate ssh")
489 || lower.contains("authorized_keys")
490 || lower.contains("id_rsa")
491 || lower.contains("id_ed25519")
492 {
493 FixPlanKind::SshKey
494 } else if lower.contains("wsl")
495 || lower.contains("windows subsystem for linux")
496 || lower.contains("install ubuntu")
497 || lower.contains("install linux on windows")
498 || lower.contains("wsl2")
499 {
500 FixPlanKind::WslSetup
501 } else if lower.contains("service")
502 && (lower.contains("start ")
503 || lower.contains("stop ")
504 || lower.contains("restart ")
505 || lower.contains("enable ")
506 || lower.contains("disable ")
507 || lower.contains("configure service"))
508 {
509 FixPlanKind::ServiceConfig
510 } else if lower.contains("activate windows")
511 || lower.contains("windows activation")
512 || lower.contains("product key")
513 || lower.contains("kms")
514 || lower.contains("not activated")
515 {
516 FixPlanKind::WindowsActivation
517 } else if lower.contains("registry")
518 || lower.contains("regedit")
519 || lower.contains("hklm")
520 || lower.contains("hkcu")
521 || lower.contains("reg add")
522 || lower.contains("reg delete")
523 || lower.contains("registry key")
524 {
525 FixPlanKind::RegistryEdit
526 } else if lower.contains("scheduled task")
527 || lower.contains("task scheduler")
528 || lower.contains("schtasks")
529 || lower.contains("create task")
530 || lower.contains("run on startup")
531 || lower.contains("run on schedule")
532 || lower.contains("cron")
533 {
534 FixPlanKind::ScheduledTaskCreate
535 } else if lower.contains("disk cleanup")
536 || lower.contains("free up disk")
537 || lower.contains("free up space")
538 || lower.contains("clear cache")
539 || lower.contains("disk full")
540 || lower.contains("low disk space")
541 || lower.contains("reclaim space")
542 {
543 FixPlanKind::DiskCleanup
544 } else if lower.contains("cargo")
545 || lower.contains("rustc")
546 || lower.contains("path")
547 || lower.contains("package manager")
548 || lower.contains("package managers")
549 || lower.contains("toolchain")
550 || lower.contains("winget")
551 || lower.contains("choco")
552 || lower.contains("scoop")
553 || lower.contains("python")
554 || lower.contains("node")
555 {
556 FixPlanKind::EnvPath
557 } else {
558 FixPlanKind::Generic
559 }
560}
561
562fn inspect_env_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
563 let path_stats = analyze_path_env();
564 let toolchains = collect_toolchains();
565 let package_managers = collect_package_managers();
566 let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
567 let found_tools = toolchains
568 .found
569 .iter()
570 .map(|(label, _)| label.as_str())
571 .collect::<HashSet<_>>();
572 let found_managers = package_managers
573 .found
574 .iter()
575 .map(|(label, _)| label.as_str())
576 .collect::<HashSet<_>>();
577
578 let mut out = String::from("Host inspection: fix_plan\n\n");
579 out.push_str(&format!("- Requested issue: {}\n", issue));
580 out.push_str("- Fix-plan type: environment/path\n");
581 out.push_str(&format!(
582 "- PATH health: {} duplicates, {} missing entries\n",
583 path_stats.duplicate_entries.len(),
584 path_stats.missing_entries.len()
585 ));
586 out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
587 out.push_str(&format!(
588 "- Package managers found: {}\n",
589 package_managers.found.len()
590 ));
591
592 out.push_str("\nLikely causes:\n");
593 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
594 out.push_str(
595 "- Rust is present but Cargo is not. The most common cause is a missing Rustup bin path such as `%USERPROFILE%\\.cargo\\bin` on Windows or `$HOME/.cargo/bin` on Unix.\n",
596 );
597 }
598 if path_stats.duplicate_entries.is_empty()
599 && path_stats.missing_entries.is_empty()
600 && !findings.is_empty()
601 {
602 for finding in findings.iter().take(max_entries.max(4)) {
603 out.push_str(&format!("- {}\n", finding));
604 }
605 } else {
606 if !path_stats.duplicate_entries.is_empty() {
607 out.push_str("- Duplicate PATH rows create clutter and can hide which install path is actually winning.\n");
608 }
609 if !path_stats.missing_entries.is_empty() {
610 out.push_str("- Stale PATH rows point at directories that no longer exist, which makes environment drift harder to reason about.\n");
611 }
612 }
613 if found_tools.contains("node")
614 && !found_managers.contains("npm")
615 && !found_managers.contains("pnpm")
616 {
617 out.push_str("- Node is present without a detected package manager. That usually means a partial install or PATH drift.\n");
618 }
619 if found_tools.contains("python")
620 && !found_managers.contains("pip")
621 && !found_managers.contains("uv")
622 && !found_managers.contains("pipx")
623 {
624 out.push_str("- Python is present without a detected package manager. That usually means the launcher works but Scripts/bin is not discoverable.\n");
625 }
626
627 out.push_str("\nFix plan:\n");
628 out.push_str("- Verify the command resolution first with `where cargo`, `where rustc`, `where python`, or `Get-Command cargo` so you know whether the tool is missing or just hidden behind PATH drift.\n");
629 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
630 out.push_str("- Add the Rustup bin directory to your user PATH, then restart the terminal. On Windows that is usually `%USERPROFILE%\\.cargo\\bin`.\n");
631 } else if !found_tools.contains("rustc") && !found_managers.contains("cargo") {
632 out.push_str("- If Rust is not installed at all, install Rustup first, then reopen the terminal. On Windows the clean path is `winget install Rustlang.Rustup`.\n");
633 }
634 if !path_stats.duplicate_entries.is_empty() || !path_stats.missing_entries.is_empty() {
635 out.push_str("- Clean duplicate or dead PATH rows in Environment Variables so the winning toolchain path is obvious and stable.\n");
636 }
637 if found_tools.contains("node")
638 && !found_managers.contains("npm")
639 && !found_managers.contains("pnpm")
640 {
641 out.push_str("- Repair the Node install or reinstall Node so `npm` is restored. If you prefer `pnpm`, install it after Node is healthy.\n");
642 }
643 if found_tools.contains("python")
644 && !found_managers.contains("pip")
645 && !found_managers.contains("uv")
646 && !found_managers.contains("pipx")
647 {
648 out.push_str("- Repair Python or install a Python package manager explicitly. `py -m ensurepip --upgrade` is the least-invasive first check on Windows.\n");
649 }
650
651 if !path_stats.duplicate_entries.is_empty() {
652 out.push_str("\nExample duplicate PATH rows:\n");
653 for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
654 out.push_str(&format!("- {}\n", entry));
655 }
656 }
657 if !path_stats.missing_entries.is_empty() {
658 out.push_str("\nExample missing PATH rows:\n");
659 for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
660 out.push_str(&format!("- {}\n", entry));
661 }
662 }
663
664 out.push_str(
665 "\nWhy this works:\n- PATH problems are usually resolution problems, not mysterious tool failures. Verify the executable path, repair the install only when needed, then restart the shell so the environment is rebuilt cleanly.",
666 );
667 Ok(out.trim_end().to_string())
668}
669
670fn inspect_port_fix_plan(
671 issue: &str,
672 port_filter: Option<u16>,
673 max_entries: usize,
674) -> Result<String, String> {
675 let requested_port = port_filter.or_else(|| first_port_in_text(issue));
676 let listeners = collect_listening_ports().unwrap_or_default();
677 let mut matching = listeners;
678 if let Some(port) = requested_port {
679 matching.retain(|entry| entry.port == port);
680 }
681 let processes = collect_processes().unwrap_or_default();
682
683 let mut out = String::from("Host inspection: fix_plan\n\n");
684 out.push_str(&format!("- Requested issue: {}\n", issue));
685 out.push_str("- Fix-plan type: port_conflict\n");
686 if let Some(port) = requested_port {
687 out.push_str(&format!("- Requested port: {}\n", port));
688 } else {
689 out.push_str("- Requested port: not parsed from the issue text\n");
690 }
691 out.push_str(&format!("- Matching listeners found: {}\n", matching.len()));
692
693 if !matching.is_empty() {
694 out.push_str("\nCurrent listeners:\n");
695 for entry in matching.iter().take(max_entries.min(5)) {
696 let process_name = entry
697 .pid
698 .as_deref()
699 .and_then(|pid| pid.parse::<u32>().ok())
700 .and_then(|pid| {
701 processes
702 .iter()
703 .find(|process| process.pid == pid)
704 .map(|process| process.name.as_str())
705 })
706 .unwrap_or("unknown");
707 let pid = entry.pid.as_deref().unwrap_or("unknown");
708 out.push_str(&format!(
709 "- {} {} ({}) pid {} process {}\n",
710 entry.protocol, entry.local, entry.state, pid, process_name
711 ));
712 }
713 }
714
715 out.push_str("\nFix plan:\n");
716 out.push_str("- Identify whether the existing listener is expected. If it is your dev server, reuse it or change your app config instead of killing it blindly.\n");
717 if !matching.is_empty() {
718 out.push_str("- If the listener is stale, stop the owning process by PID or close the parent app cleanly. On Windows, `taskkill /PID <pid> /F` is the blunt option, but closing the app normally is safer.\n");
719 } else {
720 out.push_str("- Re-run a listener check right before changing anything. Port conflicts can disappear if a stale dev process exits between checks.\n");
721 }
722 out.push_str("- If the port is intentionally occupied, move your app to another port instead of fighting the existing process.\n");
723 out.push_str("- If the port keeps getting reclaimed after you kill it, inspect startup services or background tools rather than repeating `taskkill` loops.\n");
724 out.push_str(
725 "\nWhy this works:\n- Port conflicts are ownership problems. Once you know which PID owns the listener, the clean fix is either stop that owner or move your app to a different port.",
726 );
727 Ok(out.trim_end().to_string())
728}
729
730async fn inspect_lm_studio_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
731 let config = crate::agent::config::load_config();
732 let configured_api = config
733 .api_url
734 .unwrap_or_else(|| "http://localhost:1234/v1".to_string());
735 let models_url = format!("{}/models", configured_api.trim_end_matches('/'));
736 let reachability = probe_http_endpoint(&models_url).await;
737 let embed_model = detect_loaded_embed_model(&configured_api).await;
738
739 let mut out = String::from("Host inspection: fix_plan\n\n");
740 out.push_str(&format!("- Requested issue: {}\n", issue));
741 out.push_str("- Fix-plan type: lm_studio\n");
742 out.push_str(&format!("- Configured API URL: {}\n", configured_api));
743 out.push_str(&format!("- Probe URL: {}\n", models_url));
744 match &reachability {
745 EndpointProbe::Reachable(status) => {
746 out.push_str(&format!("- Endpoint reachable: yes (HTTP {})\n", status))
747 }
748 EndpointProbe::Unreachable(detail) => {
749 out.push_str(&format!("- Endpoint reachable: no ({})\n", detail))
750 }
751 }
752 out.push_str(&format!(
753 "- Embedding model loaded: {}\n",
754 embed_model.as_deref().unwrap_or("none detected")
755 ));
756
757 out.push_str("\nFix plan:\n");
758 match reachability {
759 EndpointProbe::Reachable(_) => {
760 out.push_str("- LM Studio is reachable, so the first fix step is model state, not networking. Check whether a chat model is actually loaded and whether the local server is still serving the model you expect.\n");
761 }
762 EndpointProbe::Unreachable(_) => {
763 out.push_str("- Start LM Studio and make sure the local server is running on the configured endpoint. Hematite defaults to `http://localhost:1234/v1` unless `.hematite/settings.json` overrides `api_url`.\n");
764 }
765 }
766 out.push_str("- If Hematite is pointed at the wrong endpoint, fix `api_url` in `.hematite/settings.json` and restart or run `/runtime-refresh`.\n");
767 out.push_str("- If chat works but semantic search does not, load the embedding model as a second resident model in LM Studio. Hematite expects a `nomic-embed` style model there.\n");
768 out.push_str("- If LM Studio keeps responding with no model loaded, load the coding model first, then start the server again before blaming Hematite.\n");
769 out.push_str("- If the server is up but turns still fail, narrow the prompt or refresh the runtime profile so Hematite picks up the live model and context budget.\n");
770 if let Some(model) = embed_model {
771 out.push_str(&format!(
772 "- Current embedding model already visible: {}. That means the embeddings lane is configured, so focus on the chat model or endpoint next.\n",
773 model
774 ));
775 }
776 if max_entries > 0 {
777 out.push_str(
778 "\nWhy this works:\n- LM Studio failures usually collapse into three buckets: wrong endpoint, server not running, or models not loaded. Confirm the endpoint first, then fix model state instead of guessing.",
779 );
780 }
781 Ok(out.trim_end().to_string())
782}
783
784fn inspect_driver_install_fix_plan(issue: &str) -> Result<String, String> {
785 #[cfg(target_os = "windows")]
787 let gpu_info = {
788 let out = Command::new("powershell")
789 .args([
790 "-NoProfile",
791 "-NonInteractive",
792 "-Command",
793 "Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate | ForEach-Object { \"GPU: $($_.Name) | Driver: $($_.DriverVersion) | Date: $($_.DriverDate)\" }",
794 ])
795 .output()
796 .ok()
797 .and_then(|o| String::from_utf8(o.stdout).ok())
798 .unwrap_or_default();
799 out.trim().to_string()
800 };
801 #[cfg(not(target_os = "windows"))]
802 let gpu_info = String::from("(GPU detection not available on this platform)");
803
804 let mut out = String::from("Host inspection: fix_plan\n\n");
805 out.push_str(&format!("- Requested issue: {}\n", issue));
806 out.push_str("- Fix-plan type: driver_install\n");
807 if !gpu_info.is_empty() {
808 out.push_str(&format!("\nDetected GPU(s):\n{}\n", gpu_info));
809 }
810 out.push_str("\nFix plan — Installing or updating GPU drivers:\n");
811 out.push_str("1. Identify your GPU make from the detection above (NVIDIA, AMD, or Intel).\n");
812 out.push_str(
813 "2. Open Device Manager: press Win+X → Device Manager → expand Display Adapters.\n",
814 );
815 out.push_str("3. Right-click your GPU → Properties → Driver tab — note the current driver version and date.\n");
816 out.push_str("4. Download the latest driver directly from the manufacturer:\n");
817 out.push_str(" - NVIDIA: geforce.com/drivers (use GeForce Experience for auto-detection)\n");
818 out.push_str(" - AMD: amd.com/support (use Auto-Detect tool)\n");
819 out.push_str(" - Intel: intel.com/content/www/us/en/download-center/home.html\n");
820 out.push_str("5. Run the downloaded installer. Choose 'Express Install' (keeps settings) or 'Custom / Clean Install' (wipes old driver state — recommended if fixing corruption).\n");
821 out.push_str("6. Reboot when prompted — driver installs always require a restart.\n");
822 out.push_str("\nVerification:\n");
823 out.push_str("- After reboot, run in PowerShell:\n Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate\n");
824 out.push_str("- The DriverVersion should match what you installed.\n");
825 out.push_str("\nWhy this works:\nManufacturer installers handle INF signing, kernel-mode driver registration, and WDDM version negotiation automatically. Manual Device Manager updates often miss supporting components.");
826 Ok(out.trim_end().to_string())
827}
828
829fn inspect_group_policy_fix_plan(issue: &str) -> Result<String, String> {
830 #[cfg(target_os = "windows")]
832 let edition = {
833 Command::new("powershell")
834 .args([
835 "-NoProfile",
836 "-NonInteractive",
837 "-Command",
838 "(Get-CimInstance Win32_OperatingSystem).Caption",
839 ])
840 .output()
841 .ok()
842 .and_then(|o| String::from_utf8(o.stdout).ok())
843 .unwrap_or_default()
844 .trim()
845 .to_string()
846 };
847 #[cfg(not(target_os = "windows"))]
848 let edition = String::from("(Windows edition detection not available)");
849
850 let is_home = edition.to_lowercase().contains("home");
851
852 let mut out = String::from("Host inspection: fix_plan\n\n");
853 out.push_str(&format!("- Requested issue: {}\n", issue));
854 out.push_str("- Fix-plan type: group_policy\n");
855 out.push_str(&format!(
856 "- Windows edition detected: {}\n",
857 if edition.is_empty() {
858 "unknown".to_string()
859 } else {
860 edition.clone()
861 }
862 ));
863
864 if is_home {
865 out.push_str("\nWARNING: Windows Home does not include the Local Group Policy Editor (gpedit.msc).\n");
866 out.push_str("Options on Home edition:\n");
867 out.push_str("1. Use the Registry Editor (regedit) as an alternative — most Group Policy settings map to registry keys under HKLM\\SOFTWARE\\Policies or HKCU\\SOFTWARE\\Policies.\n");
868 out.push_str(
869 "2. Install the gpedit.msc enabler script (third-party — use with caution).\n",
870 );
871 out.push_str("3. Upgrade to Windows Pro if you need full Group Policy support.\n");
872 } else {
873 out.push_str("\nFix plan — Editing Local Group Policy:\n");
874 out.push_str("1. Press Win+R → type gpedit.msc → press Enter (requires administrator).\n");
875 out.push_str("2. Navigate the tree: Computer Configuration (machine-wide) or User Configuration (current user).\n");
876 out.push_str("3. Drill into Administrative Templates → find the policy you want.\n");
877 out.push_str("4. Double-click a policy → set to Enabled, Disabled, or Not Configured.\n");
878 out.push_str("5. Click OK — most policies apply on next logon or after gpupdate.\n");
879 out.push_str("6. To force immediate application, run in an elevated PowerShell:\n gpupdate /force\n");
880 }
881 out.push_str("\nVerification:\n");
882 out.push_str("- Run `gpresult /r` in an elevated command prompt to see applied policies.\n");
883 out.push_str(
884 "- Or: `Get-GPResultantSetOfPolicy` in PowerShell (requires RSAT on domain machines).\n",
885 );
886 out.push_str("\nWhy this works:\nGroup Policy writes settings to well-known registry paths that Windows reads at logon and on policy refresh cycles. gpupdate /force triggers an immediate refresh without requiring a restart.");
887 Ok(out.trim_end().to_string())
888}
889
890fn inspect_firewall_rule_fix_plan(issue: &str) -> Result<String, String> {
891 #[cfg(target_os = "windows")]
892 let profile_state = {
893 Command::new("powershell")
894 .args([
895 "-NoProfile",
896 "-NonInteractive",
897 "-Command",
898 "Get-NetFirewallProfile | Select-Object Name,Enabled | ForEach-Object { \"$($_.Name): $($_.Enabled)\" }",
899 ])
900 .output()
901 .ok()
902 .and_then(|o| String::from_utf8(o.stdout).ok())
903 .unwrap_or_default()
904 .trim()
905 .to_string()
906 };
907 #[cfg(not(target_os = "windows"))]
908 let profile_state = String::new();
909
910 let mut out = String::from("Host inspection: fix_plan\n\n");
911 out.push_str(&format!("- Requested issue: {}\n", issue));
912 out.push_str("- Fix-plan type: firewall_rule\n");
913 if !profile_state.is_empty() {
914 out.push_str(&format!("\nFirewall profile state:\n{}\n", profile_state));
915 }
916 out.push_str("\nFix plan — Creating or modifying a Windows Firewall rule (PowerShell, run as Administrator):\n");
917 out.push_str("\nTo ALLOW inbound traffic on a port:\n");
918 out.push_str(" New-NetFirewallRule -DisplayName \"My App Port 8080\" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any\n");
919 out.push_str("\nTo BLOCK outbound traffic to an address:\n");
920 out.push_str(" New-NetFirewallRule -DisplayName \"Block Example\" -Direction Outbound -RemoteAddress 1.2.3.4 -Action Block\n");
921 out.push_str("\nTo ALLOW an application through the firewall:\n");
922 out.push_str(" New-NetFirewallRule -DisplayName \"My App\" -Direction Inbound -Program \"C:\\Path\\To\\App.exe\" -Action Allow\n");
923 out.push_str("\nTo REMOVE a rule you created:\n");
924 out.push_str(" Remove-NetFirewallRule -DisplayName \"My App Port 8080\"\n");
925 out.push_str("\nTo see existing custom rules:\n");
926 out.push_str(" Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.PolicyStoreSourceType -ne 'GroupPolicy' } | Select-Object DisplayName,Direction,Action\n");
927 out.push_str("\nVerification:\n");
928 out.push_str("- After creating the rule, test reachability from another machine or use:\n Test-NetConnection -ComputerName localhost -Port 8080\n");
929 out.push_str("\nWhy this works:\nNew-NetFirewallRule writes directly to the Windows Filtering Platform (WFP) rule store — the same engine used by the Firewall GUI, but scriptable and reproducible.");
930 Ok(out.trim_end().to_string())
931}
932
933fn inspect_ssh_key_fix_plan(issue: &str) -> Result<String, String> {
934 let home = dirs_home().unwrap_or_else(|| std::path::PathBuf::from("~"));
935 let ssh_dir = home.join(".ssh");
936 let has_ssh_dir = ssh_dir.exists();
937 let has_ed25519 = ssh_dir.join("id_ed25519").exists();
938 let has_rsa = ssh_dir.join("id_rsa").exists();
939 let has_authorized_keys = ssh_dir.join("authorized_keys").exists();
940
941 let mut out = String::from("Host inspection: fix_plan\n\n");
942 out.push_str(&format!("- Requested issue: {}\n", issue));
943 out.push_str("- Fix-plan type: ssh_key\n");
944 out.push_str(&format!("- ~/.ssh directory exists: {}\n", has_ssh_dir));
945 out.push_str(&format!("- id_ed25519 key found: {}\n", has_ed25519));
946 out.push_str(&format!("- id_rsa key found: {}\n", has_rsa));
947 out.push_str(&format!(
948 "- authorized_keys found: {}\n",
949 has_authorized_keys
950 ));
951
952 if has_ed25519 {
953 out.push_str("\nYou already have an Ed25519 key. If you want to use it, skip to the 'Add to agent' step.\n");
954 }
955
956 out.push_str("\nFix plan — Generating an SSH key pair:\n");
957 out.push_str("1. Open PowerShell (or Terminal) — no elevation needed.\n");
958 out.push_str("2. Generate an Ed25519 key (preferred over RSA):\n");
959 out.push_str(" ssh-keygen -t ed25519 -C \"your@email.com\"\n");
960 out.push_str(
961 " - Accept the default path (~/.ssh/id_ed25519) unless you need a custom name.\n",
962 );
963 out.push_str(" - Set a passphrase (recommended) or press Enter twice for no passphrase.\n");
964 out.push_str("3. Start the SSH agent and add your key:\n");
965 out.push_str(" # Windows (PowerShell, run as Admin once to enable the service):\n");
966 out.push_str(" Set-Service -Name ssh-agent -StartupType Automatic\n");
967 out.push_str(" Start-Service ssh-agent\n");
968 out.push_str(" # Then add the key (normal PowerShell):\n");
969 out.push_str(" ssh-add ~/.ssh/id_ed25519\n");
970 out.push_str("4. Copy your PUBLIC key to the target server's authorized_keys:\n");
971 out.push_str(" # Print your public key:\n");
972 out.push_str(" cat ~/.ssh/id_ed25519.pub\n");
973 out.push_str(" # On the target server, append it:\n");
974 out.push_str(" echo \"<paste public key>\" >> ~/.ssh/authorized_keys\n");
975 out.push_str(" chmod 600 ~/.ssh/authorized_keys\n");
976 out.push_str("5. Test the connection:\n");
977 out.push_str(" ssh user@server-address\n");
978 out.push_str("\nFor GitHub/GitLab:\n");
979 out.push_str("- Copy the public key: Get-Content ~/.ssh/id_ed25519.pub | Set-Clipboard\n");
980 out.push_str("- Paste it into GitHub Settings → SSH and GPG keys → New SSH key\n");
981 out.push_str("- Test: ssh -T git@github.com\n");
982 out.push_str("\nWhy this works:\nEd25519 keys use elliptic-curve cryptography — shorter than RSA, harder to brute-force, and supported by all modern SSH servers. The agent caches the decrypted key so you only enter the passphrase once per session.");
983 Ok(out.trim_end().to_string())
984}
985
986fn inspect_wsl_setup_fix_plan(issue: &str) -> Result<String, String> {
987 #[cfg(target_os = "windows")]
988 let wsl_status = {
989 let out = Command::new("wsl")
990 .args(["--status"])
991 .output()
992 .ok()
993 .and_then(|o| {
994 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
995 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
996 Some(format!("{}{}", stdout, stderr))
997 })
998 .unwrap_or_default();
999 out.trim().to_string()
1000 };
1001 #[cfg(not(target_os = "windows"))]
1002 let wsl_status = String::new();
1003
1004 let wsl_installed =
1005 !wsl_status.is_empty() && !wsl_status.to_lowercase().contains("not installed");
1006
1007 let mut out = String::from("Host inspection: fix_plan\n\n");
1008 out.push_str(&format!("- Requested issue: {}\n", issue));
1009 out.push_str("- Fix-plan type: wsl_setup\n");
1010 out.push_str(&format!("- WSL already installed: {}\n", wsl_installed));
1011 if !wsl_status.is_empty() {
1012 out.push_str(&format!("- WSL status:\n{}\n", wsl_status));
1013 }
1014
1015 if wsl_installed {
1016 out.push_str("\nWSL is already installed. To install a new Linux distro:\n");
1017 out.push_str("1. Run in PowerShell (Admin): wsl --install -d Ubuntu\n");
1018 out.push_str(" Available distros: wsl --list --online\n");
1019 out.push_str("2. After install, launch from Start menu or type 'ubuntu' in PowerShell.\n");
1020 out.push_str("3. Create your Linux username and password when prompted.\n");
1021 } else {
1022 out.push_str("\nFix plan — Installing WSL2 (Windows Subsystem for Linux):\n");
1023 out.push_str("1. Open PowerShell as Administrator.\n");
1024 out.push_str("2. Install WSL with the default Ubuntu distro:\n");
1025 out.push_str(" wsl --install\n");
1026 out.push_str(" (This enables the required Windows features, downloads WSL2, and installs Ubuntu)\n");
1027 out.push_str("3. Reboot when prompted — WSL requires a restart after the first install.\n");
1028 out.push_str("4. After reboot, Ubuntu will launch automatically and ask you to create a username and password.\n");
1029 out.push_str("5. Set WSL2 as the default version (should already be set, but confirm):\n");
1030 out.push_str(" wsl --set-default-version 2\n");
1031 out.push_str("\nTo install a different distro instead of Ubuntu:\n");
1032 out.push_str(" wsl --install -d Debian\n");
1033 out.push_str(" wsl --list --online # to see all available distros\n");
1034 }
1035 out.push_str("\nVerification:\n");
1036 out.push_str("- Run: wsl --list --verbose\n");
1037 out.push_str("- You should see your distro with State: Running and Version: 2\n");
1038 out.push_str("\nWhy this works:\nWSL2 runs a real Linux kernel inside a lightweight Hyper-V VM. The `wsl --install` command handles all the Windows feature enablement, kernel download, and distro bootstrapping automatically.");
1039 Ok(out.trim_end().to_string())
1040}
1041
1042fn inspect_service_config_fix_plan(issue: &str) -> Result<String, String> {
1043 let lower = issue.to_ascii_lowercase();
1044 let service_hint = if lower.contains("ssh") {
1046 Some("sshd")
1047 } else if lower.contains("mysql") {
1048 Some("MySQL80")
1049 } else if lower.contains("postgres") || lower.contains("postgresql") {
1050 Some("postgresql")
1051 } else if lower.contains("redis") {
1052 Some("Redis")
1053 } else if lower.contains("nginx") {
1054 Some("nginx")
1055 } else if lower.contains("apache") {
1056 Some("Apache2.4")
1057 } else {
1058 None
1059 };
1060
1061 #[cfg(target_os = "windows")]
1062 let service_state = if let Some(svc) = service_hint {
1063 Command::new("powershell")
1064 .args([
1065 "-NoProfile",
1066 "-NonInteractive",
1067 "-Command",
1068 &format!("Get-Service -Name '{}' -ErrorAction SilentlyContinue | Select-Object Name,Status,StartType | ForEach-Object {{ \"Service: $($_.Name) | Status: $($_.Status) | StartType: $($_.StartType)\" }}", svc),
1069 ])
1070 .output()
1071 .ok()
1072 .and_then(|o| String::from_utf8(o.stdout).ok())
1073 .unwrap_or_default()
1074 .trim()
1075 .to_string()
1076 } else {
1077 String::new()
1078 };
1079 #[cfg(not(target_os = "windows"))]
1080 let service_state = String::new();
1081
1082 let mut out = String::from("Host inspection: fix_plan\n\n");
1083 out.push_str(&format!("- Requested issue: {}\n", issue));
1084 out.push_str("- Fix-plan type: service_config\n");
1085 if let Some(svc) = service_hint {
1086 out.push_str(&format!("- Service detected in request: {}\n", svc));
1087 }
1088 if !service_state.is_empty() {
1089 out.push_str(&format!("- Current state: {}\n", service_state));
1090 }
1091
1092 out.push_str("\nFix plan — Managing Windows services (PowerShell, run as Administrator):\n");
1093 out.push_str("\nStart a service:\n");
1094 out.push_str(" Start-Service -Name \"ServiceName\"\n");
1095 out.push_str("\nStop a service:\n");
1096 out.push_str(" Stop-Service -Name \"ServiceName\"\n");
1097 out.push_str("\nRestart a service:\n");
1098 out.push_str(" Restart-Service -Name \"ServiceName\"\n");
1099 out.push_str("\nEnable a service to start automatically:\n");
1100 out.push_str(" Set-Service -Name \"ServiceName\" -StartupType Automatic\n");
1101 out.push_str("\nDisable a service (stops it from auto-starting):\n");
1102 out.push_str(" Set-Service -Name \"ServiceName\" -StartupType Disabled\n");
1103 out.push_str("\nFind the exact service name:\n");
1104 out.push_str(" Get-Service | Where-Object { $_.DisplayName -like '*mysql*' }\n");
1105 out.push_str("\nVerification:\n");
1106 out.push_str(" Get-Service -Name \"ServiceName\" | Select-Object Name,Status,StartType\n");
1107 if let Some(svc) = service_hint {
1108 out.push_str(&format!(
1109 "\nFor your detected service ({}):\n Get-Service -Name '{}'\n",
1110 svc, svc
1111 ));
1112 }
1113 out.push_str("\nWhy this works:\nPowerShell's service cmdlets talk directly to the Windows Service Control Manager (SCM) — the same authority that manages auto-start, stop, and dependency resolution for all registered Windows services.");
1114 Ok(out.trim_end().to_string())
1115}
1116
1117fn inspect_windows_activation_fix_plan(issue: &str) -> Result<String, String> {
1118 #[cfg(target_os = "windows")]
1119 let activation_status = {
1120 Command::new("powershell")
1121 .args([
1122 "-NoProfile",
1123 "-NonInteractive",
1124 "-Command",
1125 "Get-CimInstance SoftwareLicensingProduct -Filter \"Name like 'Windows%'\" | Where-Object { $_.PartialProductKey } | Select-Object Name,LicenseStatus | ForEach-Object { \"Product: $($_.Name) | Status: $(if ($_.LicenseStatus -eq 1) { 'LICENSED' } else { 'NOT LICENSED (code ' + $_.LicenseStatus + ')' })\" }",
1126 ])
1127 .output()
1128 .ok()
1129 .and_then(|o| String::from_utf8(o.stdout).ok())
1130 .unwrap_or_default()
1131 .trim()
1132 .to_string()
1133 };
1134 #[cfg(not(target_os = "windows"))]
1135 let activation_status = String::new();
1136
1137 let is_licensed = activation_status.to_lowercase().contains("licensed")
1138 && !activation_status.to_lowercase().contains("not licensed");
1139
1140 let mut out = String::from("Host inspection: fix_plan\n\n");
1141 out.push_str(&format!("- Requested issue: {}\n", issue));
1142 out.push_str("- Fix-plan type: windows_activation\n");
1143 if !activation_status.is_empty() {
1144 out.push_str(&format!(
1145 "- Current activation state:\n{}\n",
1146 activation_status
1147 ));
1148 }
1149
1150 if is_licensed {
1151 out.push_str(
1152 "\nWindows appears to be activated. If you are still seeing activation prompts, try:\n",
1153 );
1154 out.push_str("1. Run in elevated PowerShell: slmgr /ato\n");
1155 out.push_str(" (Forces an online activation attempt)\n");
1156 out.push_str("2. Check activation details: slmgr /dli\n");
1157 } else {
1158 out.push_str("\nFix plan — Activating Windows:\n");
1159 out.push_str("1. Check your current status first:\n");
1160 out.push_str(" slmgr /dli (basic info)\n");
1161 out.push_str(" slmgr /dlv (detailed — shows remaining rearms, grace period)\n");
1162 out.push_str("\n2. If you have a retail product key:\n");
1163 out.push_str(" slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX (install key)\n");
1164 out.push_str(" slmgr /ato (activate online)\n");
1165 out.push_str("\n3. If you had a digital license (linked to your Microsoft account):\n");
1166 out.push_str(" - Go to Settings → System → Activation\n");
1167 out.push_str(" - Click 'Troubleshoot' → 'I changed hardware on this device recently'\n");
1168 out.push_str(" - Sign in with the Microsoft account that holds the license\n");
1169 out.push_str("\n4. If using a volume license (organization/enterprise):\n");
1170 out.push_str(" - Contact your IT department for the KMS server address\n");
1171 out.push_str(" - Set KMS host: slmgr /skms kms.yourorg.com\n");
1172 out.push_str(" - Activate: slmgr /ato\n");
1173 }
1174 out.push_str("\nVerification:\n");
1175 out.push_str(" slmgr /dli — should show 'License Status: Licensed'\n");
1176 out.push_str(" Or: Settings → System → Activation → 'Windows is activated'\n");
1177 out.push_str("\nWhy this works:\nslmgr.vbs is the Software License Manager — Microsoft's official command-line tool for all Windows license operations. It talks directly to the Software Protection Platform service.");
1178 Ok(out.trim_end().to_string())
1179}
1180
1181fn inspect_registry_edit_fix_plan(issue: &str) -> Result<String, String> {
1182 let mut out = String::from("Host inspection: fix_plan\n\n");
1183 out.push_str(&format!("- Requested issue: {}\n", issue));
1184 out.push_str("- Fix-plan type: registry_edit\n");
1185 out.push_str(
1186 "\nCAUTION: Registry edits affect core Windows behavior. Always back up before editing.\n",
1187 );
1188 out.push_str("\nFix plan — Safely editing the Windows Registry:\n");
1189 out.push_str("\n1. Back up before you touch anything:\n");
1190 out.push_str(" # Export the key you're about to change (PowerShell):\n");
1191 out.push_str(" reg export \"HKLM\\SOFTWARE\\MyKey\" C:\\backup\\MyKey_backup.reg\n");
1192 out.push_str(" # Or export the whole registry (takes a while):\n");
1193 out.push_str(" reg export HKLM C:\\backup\\HKLM_full.reg\n");
1194 out.push_str("\n2. Read a value (PowerShell, no elevation needed for HKCU):\n");
1195 out.push_str(" Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1196 out.push_str("\n3. Create or update a DWORD value (PowerShell, Admin for HKLM):\n");
1197 out.push_str(
1198 " Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue' -Value 1 -Type DWord\n",
1199 );
1200 out.push_str("\n4. Create a new key:\n");
1201 out.push_str(" New-Item -Path 'HKLM:\\SOFTWARE\\MyNewKey' -Force\n");
1202 out.push_str("\n5. Delete a value:\n");
1203 out.push_str(" Remove-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1204 out.push_str("\n6. Restore from backup if something breaks:\n");
1205 out.push_str(" reg import C:\\backup\\MyKey_backup.reg\n");
1206 out.push_str("\nCommon registry hives:\n");
1207 out.push_str(" HKLM = HKEY_LOCAL_MACHINE (machine-wide, requires Admin)\n");
1208 out.push_str(" HKCU = HKEY_CURRENT_USER (current user, no elevation needed)\n");
1209 out.push_str(" HKCR = HKEY_CLASSES_ROOT (file associations)\n");
1210 out.push_str("\nVerification:\n");
1211 out.push_str(" Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' | Select-Object MyValue\n");
1212 out.push_str("\nWhy this works:\nPowerShell's registry provider (HKLM:, HKCU:) is the safest scripted way to edit the registry — it validates paths and types, unlike raw reg.exe which accepts anything silently.");
1213 Ok(out.trim_end().to_string())
1214}
1215
1216fn inspect_scheduled_task_fix_plan(issue: &str) -> Result<String, String> {
1217 let mut out = String::from("Host inspection: fix_plan\n\n");
1218 out.push_str(&format!("- Requested issue: {}\n", issue));
1219 out.push_str("- Fix-plan type: scheduled_task_create\n");
1220 out.push_str("\nFix plan — Creating a Scheduled Task (PowerShell, run as Administrator):\n");
1221 out.push_str("\nExample: Run a script at 9 AM every day\n");
1222 out.push_str(" $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-File C:\\Scripts\\MyScript.ps1'\n");
1223 out.push_str(" $trigger = New-ScheduledTaskTrigger -Daily -At '09:00AM'\n");
1224 out.push_str(" Register-ScheduledTask -TaskName 'MyDailyTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1225 out.push_str("\nExample: Run at Windows startup\n");
1226 out.push_str(" $trigger = New-ScheduledTaskTrigger -AtStartup\n");
1227 out.push_str(" Register-ScheduledTask -TaskName 'MyStartupTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1228 out.push_str("\nExample: Run at user logon\n");
1229 out.push_str(" $trigger = New-ScheduledTaskTrigger -AtLogon\n");
1230 out.push_str(
1231 " Register-ScheduledTask -TaskName 'MyLogonTask' -Action $action -Trigger $trigger\n",
1232 );
1233 out.push_str("\nExample: Run every 30 minutes\n");
1234 out.push_str(" $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 30) -Once -At (Get-Date)\n");
1235 out.push_str("\nView all tasks:\n");
1236 out.push_str(" Get-ScheduledTask | Select-Object TaskName,State | Sort-Object TaskName\n");
1237 out.push_str("\nDelete a task:\n");
1238 out.push_str(" Unregister-ScheduledTask -TaskName 'MyDailyTask' -Confirm:$false\n");
1239 out.push_str("\nRun a task immediately:\n");
1240 out.push_str(" Start-ScheduledTask -TaskName 'MyDailyTask'\n");
1241 out.push_str("\nVerification:\n");
1242 out.push_str(" Get-ScheduledTask -TaskName 'MyDailyTask' | Select-Object TaskName,State,LastRunTime,NextRunTime\n");
1243 out.push_str("\nWhy this works:\nPowerShell's ScheduledTask cmdlets use the Task Scheduler COM interface — the same engine as the Task Scheduler GUI (taskschd.msc). Tasks persist in the Windows Task Scheduler database across reboots.");
1244 Ok(out.trim_end().to_string())
1245}
1246
1247fn inspect_disk_cleanup_fix_plan(issue: &str) -> Result<String, String> {
1248 #[cfg(target_os = "windows")]
1249 let disk_info = {
1250 Command::new("powershell")
1251 .args([
1252 "-NoProfile",
1253 "-NonInteractive",
1254 "-Command",
1255 "Get-PSDrive -PSProvider FileSystem | Select-Object Name,@{N='Used_GB';E={[Math]::Round($_.Used/1GB,1)}},@{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}} | Where-Object { $_.Used_GB -gt 0 } | ForEach-Object { \"Drive $($_.Name): Used $($_.Used_GB) GB, Free $($_.Free_GB) GB\" }",
1256 ])
1257 .output()
1258 .ok()
1259 .and_then(|o| String::from_utf8(o.stdout).ok())
1260 .unwrap_or_default()
1261 .trim()
1262 .to_string()
1263 };
1264 #[cfg(not(target_os = "windows"))]
1265 let disk_info = String::new();
1266
1267 let mut out = String::from("Host inspection: fix_plan\n\n");
1268 out.push_str(&format!("- Requested issue: {}\n", issue));
1269 out.push_str("- Fix-plan type: disk_cleanup\n");
1270 if !disk_info.is_empty() {
1271 out.push_str(&format!("\nCurrent drive usage:\n{}\n", disk_info));
1272 }
1273 out.push_str("\nFix plan — Reclaiming disk space (ordered by impact):\n");
1274 out.push_str("\n1. Run Windows Disk Cleanup (built-in, GUI):\n");
1275 out.push_str(" cleanmgr /sageset:1 (configure what to clean)\n");
1276 out.push_str(" cleanmgr /sagerun:1 (run the cleanup)\n");
1277 out.push_str(" Tick 'Windows Update Cleanup' for the biggest reclaim (often 5-20 GB).\n");
1278 out.push_str("\n2. Clear the Windows Update cache (PowerShell, Admin):\n");
1279 out.push_str(" Stop-Service wuauserv\n");
1280 out.push_str(" Remove-Item C:\\Windows\\SoftwareDistribution\\Download\\* -Recurse -Force\n");
1281 out.push_str(" Start-Service wuauserv\n");
1282 out.push_str("\n3. Clear Windows Temp folder:\n");
1283 out.push_str(" Remove-Item $env:TEMP\\* -Recurse -Force -ErrorAction SilentlyContinue\n");
1284 out.push_str(
1285 " Remove-Item C:\\Windows\\Temp\\* -Recurse -Force -ErrorAction SilentlyContinue\n",
1286 );
1287 out.push_str("\n4. Developer cache directories (often the biggest culprits):\n");
1288 out.push_str(" - Rust build artifacts: cargo clean (inside each project)\n");
1289 out.push_str(" - npm cache: npm cache clean --force\n");
1290 out.push_str(" - pip cache: pip cache purge\n");
1291 out.push_str(
1292 " - Docker: docker system prune -a (removes all unused images/containers)\n",
1293 );
1294 out.push_str(" - Cargo registry cache: Remove-Item ~\\.cargo\\registry -Recurse -Force (will redownload on next build)\n");
1295 out.push_str("\n5. Check for large files:\n");
1296 out.push_str(" Get-ChildItem C:\\ -Recurse -ErrorAction SilentlyContinue | Sort-Object Length -Descending | Select-Object -First 20 FullName,@{N='MB';E={[Math]::Round($_.Length/1MB,1)}}\n");
1297 out.push_str("\nVerification:\n");
1298 out.push_str(
1299 " Get-PSDrive C | Select-Object @{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}}\n",
1300 );
1301 out.push_str("\nWhy this works:\nWindows accumulates update packages, temp files, and developer build artifacts over months. Targeting those specific locations gives the most space back with the least risk of breaking anything.");
1302 Ok(out.trim_end().to_string())
1303}
1304
1305fn inspect_generic_fix_plan(issue: &str) -> Result<String, String> {
1306 let mut out = String::from("Host inspection: fix_plan\n\n");
1307 out.push_str(&format!("- Requested issue: {}\n", issue));
1308 out.push_str("- Fix-plan type: generic\n");
1309 out.push_str(
1310 "\nGuidance:\n- Use `fix_plan` with a descriptive issue string to get a grounded, machine-specific walkthrough.\n\
1311 Structured lanes available:\n\
1312 - PATH/toolchain drift (cargo, rustc, node, python, winget, choco, scoop)\n\
1313 - Port conflict (address already in use, what owns port)\n\
1314 - LM Studio connectivity (localhost:1234, no coding model loaded, embedding model)\n\
1315 - Driver install (GPU driver, nvidia driver, install driver, update driver)\n\
1316 - Group Policy (gpedit, local policy, administrative template)\n\
1317 - Firewall rule (inbound rule, outbound rule, open port, allow port, block port)\n\
1318 - SSH key (ssh-keygen, generate ssh, authorized_keys)\n\
1319 - WSL setup (wsl2, windows subsystem for linux, install ubuntu)\n\
1320 - Service config (start/stop/restart/enable/disable a service)\n\
1321 - Windows activation (product key, not activated, kms)\n\
1322 - Registry edit (regedit, reg add, hklm, hkcu, registry key)\n\
1323 - Scheduled task (task scheduler, schtasks, run on startup, cron)\n\
1324 - Disk cleanup (free up disk, clear cache, disk full, reclaim space)\n\
1325 - If your issue is outside these lanes, run the closest `inspect_host` topic first to ground the diagnosis.",
1326 );
1327 Ok(out.trim_end().to_string())
1328}
1329
1330fn inspect_resource_load() -> Result<String, String> {
1331 #[cfg(target_os = "windows")]
1332 {
1333 let output = Command::new("powershell")
1334 .args([
1335 "-NoProfile",
1336 "-Command",
1337 "(Get-CimInstance Win32_Processor).LoadPercentage; Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json -Compress",
1338 ])
1339 .output()
1340 .map_err(|e| format!("Failed to run powershell: {e}"))?;
1341
1342 let text = String::from_utf8_lossy(&output.stdout);
1343 let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
1344
1345 let cpu_load = lines
1346 .next()
1347 .and_then(|l| l.parse::<u32>().ok())
1348 .unwrap_or(0);
1349 let mem_json = lines.collect::<Vec<_>>().join("");
1350 let mem_val: Value = serde_json::from_str(&mem_json).unwrap_or(Value::Null);
1351
1352 let total_kb = mem_val["TotalVisibleMemorySize"].as_u64().unwrap_or(1);
1353 let free_kb = mem_val["FreePhysicalMemory"].as_u64().unwrap_or(0);
1354 let used_kb = total_kb.saturating_sub(free_kb);
1355 let mem_percent = if total_kb > 0 {
1356 (used_kb * 100) / total_kb
1357 } else {
1358 0
1359 };
1360
1361 let mut out = String::from("Host inspection: resource_load\n\n");
1362 out.push_str("**System Performance Summary:**\n");
1363 out.push_str(&format!("- CPU Load: {}%\n", cpu_load));
1364 out.push_str(&format!(
1365 "- Memory Usage: {} / {} ({}%)\n",
1366 human_bytes(used_kb * 1024),
1367 human_bytes(total_kb * 1024),
1368 mem_percent
1369 ));
1370
1371 if cpu_load > 85 {
1372 out.push_str("\n[Warning] CPU load is extremely high. System may be unresponsive.\n");
1373 }
1374 if mem_percent > 90 {
1375 out.push_str("\n[Warning] Memory usage is near capacity. Swap activity may slow down the machine.\n");
1376 }
1377
1378 Ok(out)
1379 }
1380 #[cfg(not(target_os = "windows"))]
1381 {
1382 Ok("Resource load inspection is not yet implemented for this platform.".to_string())
1383 }
1384}
1385
1386#[derive(Debug)]
1387enum EndpointProbe {
1388 Reachable(u16),
1389 Unreachable(String),
1390}
1391
1392async fn probe_http_endpoint(url: &str) -> EndpointProbe {
1393 let client = match reqwest::Client::builder()
1394 .timeout(std::time::Duration::from_secs(3))
1395 .build()
1396 {
1397 Ok(client) => client,
1398 Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1399 };
1400
1401 match client.get(url).send().await {
1402 Ok(resp) => EndpointProbe::Reachable(resp.status().as_u16()),
1403 Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1404 }
1405}
1406
1407async fn detect_loaded_embed_model(configured_api: &str) -> Option<String> {
1408 let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1409 let url = format!("{}/api/v0/models", base);
1410 let client = reqwest::Client::builder()
1411 .timeout(std::time::Duration::from_secs(3))
1412 .build()
1413 .ok()?;
1414
1415 #[derive(serde::Deserialize)]
1416 struct ModelList {
1417 data: Vec<ModelEntry>,
1418 }
1419 #[derive(serde::Deserialize)]
1420 struct ModelEntry {
1421 id: String,
1422 #[serde(rename = "type", default)]
1423 model_type: String,
1424 #[serde(default)]
1425 state: String,
1426 }
1427
1428 let response = client.get(url).send().await.ok()?;
1429 let models = response.json::<ModelList>().await.ok()?;
1430 models
1431 .data
1432 .into_iter()
1433 .find(|model| model.model_type == "embeddings" && model.state == "loaded")
1434 .map(|model| model.id)
1435}
1436
1437fn first_port_in_text(text: &str) -> Option<u16> {
1438 text.split(|c: char| !c.is_ascii_digit())
1439 .find(|fragment| !fragment.is_empty())
1440 .and_then(|fragment| fragment.parse::<u16>().ok())
1441}
1442
1443fn inspect_processes(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1444 let mut processes = collect_processes()?;
1445 if let Some(filter) = name_filter.as_deref() {
1446 let lowered = filter.to_ascii_lowercase();
1447 processes.retain(|entry| entry.name.to_ascii_lowercase().contains(&lowered));
1448 }
1449 processes.sort_by(|a, b| {
1450 b.memory_bytes
1451 .cmp(&a.memory_bytes)
1452 .then_with(|| a.name.cmp(&b.name))
1453 .then_with(|| a.pid.cmp(&b.pid))
1454 });
1455
1456 let total_memory: u64 = processes.iter().map(|entry| entry.memory_bytes).sum();
1457
1458 let mut out = String::from("Host inspection: processes\n\n");
1459 if let Some(filter) = name_filter.as_deref() {
1460 out.push_str(&format!("- Filter name: {}\n", filter));
1461 }
1462 out.push_str(&format!("- Processes found: {}\n", processes.len()));
1463 out.push_str(&format!(
1464 "- Total reported working set: {}\n",
1465 human_bytes(total_memory)
1466 ));
1467
1468 if processes.is_empty() {
1469 out.push_str("\nNo running processes matched.");
1470 return Ok(out);
1471 }
1472
1473 out.push_str("\nTop processes by resource usage:\n");
1474 for entry in processes.iter().take(max_entries) {
1475 let cpu_str = entry
1476 .cpu_seconds
1477 .map(|s| format!(" [CPU: {:.1}s]", s))
1478 .unwrap_or_default();
1479 let io_str = if let (Some(r), Some(w)) = (entry.read_ops, entry.write_ops) {
1480 format!(" [I/O R:{}/W:{}]", r, w)
1481 } else {
1482 " [I/O unknown]".to_string()
1483 };
1484 out.push_str(&format!(
1485 "- {} (pid {}) - {}{}{}{}\n",
1486 entry.name,
1487 entry.pid,
1488 human_bytes(entry.memory_bytes),
1489 cpu_str,
1490 io_str,
1491 entry
1492 .detail
1493 .as_deref()
1494 .map(|detail| format!(" [{}]", detail))
1495 .unwrap_or_default()
1496 ));
1497 }
1498 if processes.len() > max_entries {
1499 out.push_str(&format!(
1500 "- ... {} more processes omitted\n",
1501 processes.len() - max_entries
1502 ));
1503 }
1504
1505 Ok(out.trim_end().to_string())
1506}
1507
1508fn inspect_network(max_entries: usize) -> Result<String, String> {
1509 let adapters = collect_network_adapters()?;
1510 let active_count = adapters
1511 .iter()
1512 .filter(|adapter| adapter.is_active())
1513 .count();
1514 let exposure = listener_exposure_summary(collect_listening_ports().ok().unwrap_or_default());
1515
1516 let mut out = String::from("Host inspection: network\n\n");
1517 out.push_str(&format!("- Adapters found: {}\n", adapters.len()));
1518 out.push_str(&format!("- Active adapters: {}\n", active_count));
1519 out.push_str(&format!(
1520 "- Listener exposure: {} loopback-only, {} wildcard/public, {} specific-bind\n",
1521 exposure.loopback_only, exposure.wildcard_public, exposure.specific_bind
1522 ));
1523
1524 if adapters.is_empty() {
1525 out.push_str("\nNo adapter details were detected.");
1526 return Ok(out);
1527 }
1528
1529 out.push_str("\nAdapter summary:\n");
1530 for adapter in adapters.iter().take(max_entries) {
1531 let status = if adapter.is_active() {
1532 "active"
1533 } else if adapter.disconnected {
1534 "disconnected"
1535 } else {
1536 "idle"
1537 };
1538 let mut details = vec![status.to_string()];
1539 if !adapter.ipv4.is_empty() {
1540 details.push(format!("ipv4 {}", adapter.ipv4.join(", ")));
1541 }
1542 if !adapter.ipv6.is_empty() {
1543 details.push(format!("ipv6 {}", adapter.ipv6.join(", ")));
1544 }
1545 if !adapter.gateways.is_empty() {
1546 details.push(format!("gateway {}", adapter.gateways.join(", ")));
1547 }
1548 if !adapter.dns_servers.is_empty() {
1549 details.push(format!("dns {}", adapter.dns_servers.join(", ")));
1550 }
1551 out.push_str(&format!("- {} - {}\n", adapter.name, details.join(" | ")));
1552 }
1553 if adapters.len() > max_entries {
1554 out.push_str(&format!(
1555 "- ... {} more adapters omitted\n",
1556 adapters.len() - max_entries
1557 ));
1558 }
1559
1560 Ok(out.trim_end().to_string())
1561}
1562
1563fn inspect_services(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1564 let mut services = collect_services()?;
1565 if let Some(filter) = name_filter.as_deref() {
1566 let lowered = filter.to_ascii_lowercase();
1567 services.retain(|entry| {
1568 entry.name.to_ascii_lowercase().contains(&lowered)
1569 || entry
1570 .display_name
1571 .as_deref()
1572 .unwrap_or("")
1573 .to_ascii_lowercase()
1574 .contains(&lowered)
1575 });
1576 }
1577
1578 services.sort_by(|a, b| {
1579 service_status_rank(&a.status)
1580 .cmp(&service_status_rank(&b.status))
1581 .then_with(|| a.name.cmp(&b.name))
1582 });
1583
1584 let running = services
1585 .iter()
1586 .filter(|entry| {
1587 entry.status.eq_ignore_ascii_case("running")
1588 || entry.status.eq_ignore_ascii_case("active")
1589 })
1590 .count();
1591 let failed = services
1592 .iter()
1593 .filter(|entry| {
1594 entry.status.eq_ignore_ascii_case("failed")
1595 || entry.status.eq_ignore_ascii_case("error")
1596 || entry.status.eq_ignore_ascii_case("stopped")
1597 })
1598 .count();
1599
1600 let mut out = String::from("Host inspection: services\n\n");
1601 if let Some(filter) = name_filter.as_deref() {
1602 out.push_str(&format!("- Filter name: {}\n", filter));
1603 }
1604 out.push_str(&format!("- Services found: {}\n", services.len()));
1605 out.push_str(&format!("- Running/active: {}\n", running));
1606 out.push_str(&format!("- Failed/stopped: {}\n", failed));
1607
1608 if services.is_empty() {
1609 out.push_str("\nNo services matched.");
1610 return Ok(out);
1611 }
1612
1613 let per_section = (max_entries / 2).max(5);
1615
1616 let running_services: Vec<_> = services
1617 .iter()
1618 .filter(|e| {
1619 e.status.eq_ignore_ascii_case("running") || e.status.eq_ignore_ascii_case("active")
1620 })
1621 .collect();
1622 let stopped_services: Vec<_> = services
1623 .iter()
1624 .filter(|e| {
1625 e.status.eq_ignore_ascii_case("stopped")
1626 || e.status.eq_ignore_ascii_case("failed")
1627 || e.status.eq_ignore_ascii_case("error")
1628 })
1629 .collect();
1630
1631 let fmt_entry = |entry: &&ServiceEntry| {
1632 let startup = entry
1633 .startup
1634 .as_deref()
1635 .map(|v| format!(" | startup {}", v))
1636 .unwrap_or_default();
1637 let display = entry
1638 .display_name
1639 .as_deref()
1640 .filter(|v| *v != &entry.name)
1641 .map(|v| format!(" [{}]", v))
1642 .unwrap_or_default();
1643 format!("- {}{} - {}{}\n", entry.name, display, entry.status, startup)
1644 };
1645
1646 out.push_str(&format!(
1647 "\nRunning services ({} total, showing up to {}):\n",
1648 running_services.len(),
1649 per_section
1650 ));
1651 for entry in running_services.iter().take(per_section) {
1652 out.push_str(&fmt_entry(entry));
1653 }
1654 if running_services.len() > per_section {
1655 out.push_str(&format!(
1656 "- ... {} more running services omitted\n",
1657 running_services.len() - per_section
1658 ));
1659 }
1660
1661 out.push_str(&format!(
1662 "\nStopped/failed services ({} total, showing up to {}):\n",
1663 stopped_services.len(),
1664 per_section
1665 ));
1666 for entry in stopped_services.iter().take(per_section) {
1667 out.push_str(&fmt_entry(entry));
1668 }
1669 if stopped_services.len() > per_section {
1670 out.push_str(&format!(
1671 "- ... {} more stopped services omitted\n",
1672 stopped_services.len() - per_section
1673 ));
1674 }
1675
1676 Ok(out.trim_end().to_string())
1677}
1678
1679async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
1680 inspect_directory("Disk", path, max_entries).await
1681}
1682
1683fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
1684 let mut listeners = collect_listening_ports()?;
1685 if let Some(port) = port_filter {
1686 listeners.retain(|entry| entry.port == port);
1687 }
1688 listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
1689
1690 let mut out = String::from("Host inspection: ports\n\n");
1691 if let Some(port) = port_filter {
1692 out.push_str(&format!("- Filter port: {}\n", port));
1693 }
1694 out.push_str(&format!(
1695 "- Listening endpoints found: {}\n",
1696 listeners.len()
1697 ));
1698
1699 if listeners.is_empty() {
1700 out.push_str("\nNo listening endpoints matched.");
1701 return Ok(out);
1702 }
1703
1704 out.push_str("\nListening endpoints:\n");
1705 for entry in listeners.iter().take(max_entries) {
1706 let pid_str = entry
1707 .pid
1708 .as_deref()
1709 .map(|p| format!(" pid {}", p))
1710 .unwrap_or_default();
1711 let name_str = entry
1712 .process_name
1713 .as_deref()
1714 .map(|n| format!(" [{}]", n))
1715 .unwrap_or_default();
1716 out.push_str(&format!(
1717 "- {} {} ({}){}{}\n",
1718 entry.protocol, entry.local, entry.state, pid_str, name_str
1719 ));
1720 }
1721 if listeners.len() > max_entries {
1722 out.push_str(&format!(
1723 "- ... {} more listening endpoints omitted\n",
1724 listeners.len() - max_entries
1725 ));
1726 }
1727
1728 Ok(out.trim_end().to_string())
1729}
1730
1731fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
1732 if !path.exists() {
1733 return Err(format!("Path does not exist: {}", path.display()));
1734 }
1735 if !path.is_dir() {
1736 return Err(format!("Path is not a directory: {}", path.display()));
1737 }
1738
1739 let markers = collect_project_markers(&path);
1740 let hematite_state = collect_hematite_state(&path);
1741 let git_state = inspect_git_state(&path);
1742 let release_state = inspect_release_artifacts(&path);
1743
1744 let mut out = String::from("Host inspection: repo_doctor\n\n");
1745 out.push_str(&format!("- Path: {}\n", path.display()));
1746 out.push_str(&format!(
1747 "- Workspace mode: {}\n",
1748 workspace_mode_for_path(&path)
1749 ));
1750
1751 if markers.is_empty() {
1752 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");
1753 } else {
1754 out.push_str("- Project markers:\n");
1755 for marker in markers.iter().take(max_entries) {
1756 out.push_str(&format!(" - {}\n", marker));
1757 }
1758 }
1759
1760 match git_state {
1761 Some(git) => {
1762 out.push_str(&format!("- Git root: {}\n", git.root.display()));
1763 out.push_str(&format!("- Git branch: {}\n", git.branch));
1764 out.push_str(&format!("- Git status: {}\n", git.status_label()));
1765 }
1766 None => out.push_str("- Git: not inside a detected work tree\n"),
1767 }
1768
1769 out.push_str(&format!(
1770 "- Hematite docs/imports/reports: {}/{}/{}\n",
1771 hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
1772 ));
1773 if hematite_state.workspace_profile {
1774 out.push_str("- Workspace profile: present\n");
1775 } else {
1776 out.push_str("- Workspace profile: absent\n");
1777 }
1778
1779 if let Some(release) = release_state {
1780 out.push_str(&format!("- Cargo version: {}\n", release.version));
1781 out.push_str(&format!(
1782 "- Windows artifacts for current version: {}/{}/{}\n",
1783 bool_label(release.portable_dir),
1784 bool_label(release.portable_zip),
1785 bool_label(release.setup_exe)
1786 ));
1787 }
1788
1789 Ok(out.trim_end().to_string())
1790}
1791
1792async fn inspect_known_directory(
1793 label: &str,
1794 path: Option<PathBuf>,
1795 max_entries: usize,
1796) -> Result<String, String> {
1797 let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
1798 inspect_directory(label, path, max_entries).await
1799}
1800
1801async fn inspect_directory(
1802 label: &str,
1803 path: PathBuf,
1804 max_entries: usize,
1805) -> Result<String, String> {
1806 let label = label.to_string();
1807 tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
1808 .await
1809 .map_err(|e| format!("inspect_host task failed: {e}"))?
1810}
1811
1812fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
1813 if !path.exists() {
1814 return Err(format!("Path does not exist: {}", path.display()));
1815 }
1816 if !path.is_dir() {
1817 return Err(format!("Path is not a directory: {}", path.display()));
1818 }
1819
1820 let mut top_level_entries = Vec::new();
1821 for entry in fs::read_dir(path)
1822 .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
1823 {
1824 match entry {
1825 Ok(entry) => top_level_entries.push(entry),
1826 Err(_) => continue,
1827 }
1828 }
1829 top_level_entries.sort_by_key(|entry| entry.file_name());
1830
1831 let top_level_count = top_level_entries.len();
1832 let mut sample_names = Vec::new();
1833 let mut largest_entries = Vec::new();
1834 let mut aggregate = PathAggregate::default();
1835 let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
1836
1837 for entry in top_level_entries {
1838 let name = entry.file_name().to_string_lossy().to_string();
1839 if sample_names.len() < max_entries {
1840 sample_names.push(name.clone());
1841 }
1842 let kind = match entry.file_type() {
1843 Ok(ft) if ft.is_dir() => "dir",
1844 Ok(ft) if ft.is_symlink() => "symlink",
1845 _ => "file",
1846 };
1847 let stats = measure_path(&entry.path(), &mut budget);
1848 aggregate.merge(&stats);
1849 largest_entries.push(LargestEntry {
1850 name,
1851 kind,
1852 bytes: stats.total_bytes,
1853 });
1854 }
1855
1856 largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
1857
1858 let mut out = format!("Directory inspection: {}\n\n", label);
1859 out.push_str(&format!("- Path: {}\n", path.display()));
1860 out.push_str(&format!("- Top-level items: {}\n", top_level_count));
1861 out.push_str(&format!("- Recursive files: {}\n", aggregate.file_count));
1862 out.push_str(&format!(
1863 "- Recursive directories: {}\n",
1864 aggregate.dir_count
1865 ));
1866 out.push_str(&format!(
1867 "- Total size: {}{}\n",
1868 human_bytes(aggregate.total_bytes),
1869 if aggregate.partial {
1870 " (partial scan)"
1871 } else {
1872 ""
1873 }
1874 ));
1875 if aggregate.skipped_entries > 0 {
1876 out.push_str(&format!(
1877 "- Skipped entries: {} (permissions, symlinks, or scan budget)\n",
1878 aggregate.skipped_entries
1879 ));
1880 }
1881
1882 if !largest_entries.is_empty() {
1883 out.push_str("\nLargest top-level entries:\n");
1884 for entry in largest_entries.iter().take(max_entries) {
1885 out.push_str(&format!(
1886 "- {} [{}] - {}\n",
1887 entry.name,
1888 entry.kind,
1889 human_bytes(entry.bytes)
1890 ));
1891 }
1892 }
1893
1894 if !sample_names.is_empty() {
1895 out.push_str("\nSample names:\n");
1896 for name in sample_names {
1897 out.push_str(&format!("- {}\n", name));
1898 }
1899 }
1900
1901 Ok(out.trim_end().to_string())
1902}
1903
1904fn resolve_path(raw: &str) -> Result<PathBuf, String> {
1905 let trimmed = raw.trim();
1906 if trimmed.is_empty() {
1907 return Err("Path must not be empty.".to_string());
1908 }
1909
1910 if let Some(rest) = trimmed
1911 .strip_prefix("~/")
1912 .or_else(|| trimmed.strip_prefix("~\\"))
1913 {
1914 let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
1915 return Ok(home.join(rest));
1916 }
1917
1918 let path = PathBuf::from(trimmed);
1919 if path.is_absolute() {
1920 Ok(path)
1921 } else {
1922 let cwd =
1923 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
1924 let full_path = cwd.join(&path);
1925
1926 if !full_path.exists()
1929 && (trimmed.starts_with(".hematite") || trimmed.starts_with("hematite.exe"))
1930 {
1931 if let Some(home) = home::home_dir() {
1932 let home_path = home.join(trimmed);
1933 if home_path.exists() {
1934 return Ok(home_path);
1935 }
1936 }
1937 }
1938
1939 Ok(full_path)
1940 }
1941}
1942
1943fn workspace_mode_label(workspace_root: &Path) -> &'static str {
1944 workspace_mode_for_path(workspace_root)
1945}
1946
1947fn workspace_mode_for_path(path: &Path) -> &'static str {
1948 if is_project_marker_path(path) {
1949 "project"
1950 } else if path.join(".hematite").join("docs").exists()
1951 || path.join(".hematite").join("imports").exists()
1952 || path.join(".hematite").join("reports").exists()
1953 {
1954 "docs-only"
1955 } else {
1956 "general directory"
1957 }
1958}
1959
1960fn is_project_marker_path(path: &Path) -> bool {
1961 [
1962 "Cargo.toml",
1963 "package.json",
1964 "pyproject.toml",
1965 "go.mod",
1966 "composer.json",
1967 "requirements.txt",
1968 "Makefile",
1969 "justfile",
1970 ]
1971 .iter()
1972 .any(|name| path.join(name).exists())
1973 || path.join(".git").exists()
1974}
1975
1976fn preferred_shell_label() -> &'static str {
1977 #[cfg(target_os = "windows")]
1978 {
1979 "PowerShell"
1980 }
1981 #[cfg(not(target_os = "windows"))]
1982 {
1983 "sh"
1984 }
1985}
1986
1987fn desktop_dir() -> Option<PathBuf> {
1988 home::home_dir().map(|home| home.join("Desktop"))
1989}
1990
1991fn downloads_dir() -> Option<PathBuf> {
1992 home::home_dir().map(|home| home.join("Downloads"))
1993}
1994
1995fn count_top_level_items(path: &Path) -> Result<usize, String> {
1996 let mut count = 0usize;
1997 for entry in
1998 fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
1999 {
2000 if entry.is_ok() {
2001 count += 1;
2002 }
2003 }
2004 Ok(count)
2005}
2006
2007#[derive(Default)]
2008struct PathAggregate {
2009 total_bytes: u64,
2010 file_count: u64,
2011 dir_count: u64,
2012 skipped_entries: u64,
2013 partial: bool,
2014}
2015
2016impl PathAggregate {
2017 fn merge(&mut self, other: &PathAggregate) {
2018 self.total_bytes += other.total_bytes;
2019 self.file_count += other.file_count;
2020 self.dir_count += other.dir_count;
2021 self.skipped_entries += other.skipped_entries;
2022 self.partial |= other.partial;
2023 }
2024}
2025
2026struct LargestEntry {
2027 name: String,
2028 kind: &'static str,
2029 bytes: u64,
2030}
2031
2032fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
2033 if *budget == 0 {
2034 return PathAggregate {
2035 partial: true,
2036 skipped_entries: 1,
2037 ..PathAggregate::default()
2038 };
2039 }
2040 *budget -= 1;
2041
2042 let metadata = match fs::symlink_metadata(path) {
2043 Ok(metadata) => metadata,
2044 Err(_) => {
2045 return PathAggregate {
2046 skipped_entries: 1,
2047 ..PathAggregate::default()
2048 }
2049 }
2050 };
2051
2052 let file_type = metadata.file_type();
2053 if file_type.is_symlink() {
2054 return PathAggregate {
2055 skipped_entries: 1,
2056 ..PathAggregate::default()
2057 };
2058 }
2059
2060 if metadata.is_file() {
2061 return PathAggregate {
2062 total_bytes: metadata.len(),
2063 file_count: 1,
2064 ..PathAggregate::default()
2065 };
2066 }
2067
2068 if !metadata.is_dir() {
2069 return PathAggregate::default();
2070 }
2071
2072 let mut aggregate = PathAggregate {
2073 dir_count: 1,
2074 ..PathAggregate::default()
2075 };
2076
2077 let read_dir = match fs::read_dir(path) {
2078 Ok(read_dir) => read_dir,
2079 Err(_) => {
2080 aggregate.skipped_entries += 1;
2081 return aggregate;
2082 }
2083 };
2084
2085 for child in read_dir {
2086 match child {
2087 Ok(child) => {
2088 let child_stats = measure_path(&child.path(), budget);
2089 aggregate.merge(&child_stats);
2090 }
2091 Err(_) => aggregate.skipped_entries += 1,
2092 }
2093 }
2094
2095 aggregate
2096}
2097
2098struct PathAnalysis {
2099 total_entries: usize,
2100 unique_entries: usize,
2101 entries: Vec<String>,
2102 duplicate_entries: Vec<String>,
2103 missing_entries: Vec<String>,
2104}
2105
2106fn analyze_path_env() -> PathAnalysis {
2107 let mut entries = Vec::new();
2108 let mut duplicate_entries = Vec::new();
2109 let mut missing_entries = Vec::new();
2110 let mut seen = HashSet::new();
2111
2112 let raw_path = std::env::var_os("PATH").unwrap_or_default();
2113 for path in std::env::split_paths(&raw_path) {
2114 let display = path.display().to_string();
2115 if display.trim().is_empty() {
2116 continue;
2117 }
2118
2119 let normalized = normalize_path_entry(&display);
2120 if !seen.insert(normalized) {
2121 duplicate_entries.push(display.clone());
2122 }
2123 if !path.exists() {
2124 missing_entries.push(display.clone());
2125 }
2126 entries.push(display);
2127 }
2128
2129 let total_entries = entries.len();
2130 let unique_entries = seen.len();
2131
2132 PathAnalysis {
2133 total_entries,
2134 unique_entries,
2135 entries,
2136 duplicate_entries,
2137 missing_entries,
2138 }
2139}
2140
2141fn normalize_path_entry(value: &str) -> String {
2142 #[cfg(target_os = "windows")]
2143 {
2144 value
2145 .replace('/', "\\")
2146 .trim_end_matches(['\\', '/'])
2147 .to_ascii_lowercase()
2148 }
2149 #[cfg(not(target_os = "windows"))]
2150 {
2151 value.trim_end_matches('/').to_string()
2152 }
2153}
2154
2155struct ToolchainReport {
2156 found: Vec<(String, String)>,
2157 missing: Vec<String>,
2158}
2159
2160struct PackageManagerReport {
2161 found: Vec<(String, String)>,
2162}
2163
2164#[derive(Debug, Clone)]
2165struct ProcessEntry {
2166 name: String,
2167 pid: u32,
2168 memory_bytes: u64,
2169 cpu_seconds: Option<f64>,
2170 read_ops: Option<u64>,
2171 write_ops: Option<u64>,
2172 detail: Option<String>,
2173}
2174
2175#[derive(Debug, Clone)]
2176struct ServiceEntry {
2177 name: String,
2178 status: String,
2179 startup: Option<String>,
2180 display_name: Option<String>,
2181}
2182
2183#[derive(Debug, Clone, Default)]
2184struct NetworkAdapter {
2185 name: String,
2186 ipv4: Vec<String>,
2187 ipv6: Vec<String>,
2188 gateways: Vec<String>,
2189 dns_servers: Vec<String>,
2190 disconnected: bool,
2191}
2192
2193impl NetworkAdapter {
2194 fn is_active(&self) -> bool {
2195 !self.disconnected
2196 && (!self.ipv4.is_empty() || !self.ipv6.is_empty() || !self.gateways.is_empty())
2197 }
2198}
2199
2200#[derive(Debug, Clone, Copy, Default)]
2201struct ListenerExposureSummary {
2202 loopback_only: usize,
2203 wildcard_public: usize,
2204 specific_bind: usize,
2205}
2206
2207#[derive(Debug, Clone)]
2208struct ListeningPort {
2209 protocol: String,
2210 local: String,
2211 port: u16,
2212 state: String,
2213 pid: Option<String>,
2214 process_name: Option<String>,
2215}
2216
2217fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
2218 #[cfg(target_os = "windows")]
2219 {
2220 collect_windows_listening_ports()
2221 }
2222 #[cfg(not(target_os = "windows"))]
2223 {
2224 collect_unix_listening_ports()
2225 }
2226}
2227
2228fn collect_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2229 #[cfg(target_os = "windows")]
2230 {
2231 collect_windows_network_adapters()
2232 }
2233 #[cfg(not(target_os = "windows"))]
2234 {
2235 collect_unix_network_adapters()
2236 }
2237}
2238
2239fn collect_services() -> Result<Vec<ServiceEntry>, String> {
2240 #[cfg(target_os = "windows")]
2241 {
2242 collect_windows_services()
2243 }
2244 #[cfg(not(target_os = "windows"))]
2245 {
2246 collect_unix_services()
2247 }
2248}
2249
2250#[cfg(target_os = "windows")]
2251fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
2252 let output = Command::new("netstat")
2253 .args(["-ano", "-p", "tcp"])
2254 .output()
2255 .map_err(|e| format!("Failed to run netstat: {e}"))?;
2256 if !output.status.success() {
2257 return Err("netstat returned a non-success status.".to_string());
2258 }
2259
2260 let text = String::from_utf8_lossy(&output.stdout);
2261 let mut listeners = Vec::new();
2262 for line in text.lines() {
2263 let trimmed = line.trim();
2264 if !trimmed.starts_with("TCP") {
2265 continue;
2266 }
2267 let cols: Vec<&str> = trimmed.split_whitespace().collect();
2268 if cols.len() < 5 || cols[3] != "LISTENING" {
2269 continue;
2270 }
2271 let Some(port) = extract_port_from_socket(cols[1]) else {
2272 continue;
2273 };
2274 listeners.push(ListeningPort {
2275 protocol: cols[0].to_string(),
2276 local: cols[1].to_string(),
2277 port,
2278 state: cols[3].to_string(),
2279 pid: Some(cols[4].to_string()),
2280 process_name: None,
2281 });
2282 }
2283
2284 let unique_pids: Vec<String> = listeners
2287 .iter()
2288 .filter_map(|l| l.pid.clone())
2289 .collect::<HashSet<_>>()
2290 .into_iter()
2291 .collect();
2292
2293 if !unique_pids.is_empty() {
2294 let pid_list = unique_pids.join(",");
2295 let ps_cmd = format!(
2296 "Get-Process -Id {} -ErrorAction SilentlyContinue | Select-Object Id,Name | Format-Table -HideTableHeaders",
2297 pid_list
2298 );
2299 if let Ok(ps_out) = Command::new("powershell")
2300 .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
2301 .output()
2302 {
2303 let mut pid_map = std::collections::HashMap::<String, String>::new();
2304 let ps_text = String::from_utf8_lossy(&ps_out.stdout);
2305 for line in ps_text.lines() {
2306 let parts: Vec<&str> = line.split_whitespace().collect();
2307 if parts.len() >= 2 {
2308 pid_map.insert(parts[0].to_string(), parts[1].to_string());
2309 }
2310 }
2311 for listener in &mut listeners {
2312 if let Some(pid) = &listener.pid {
2313 listener.process_name = pid_map.get(pid).cloned();
2314 }
2315 }
2316 }
2317 }
2318
2319 Ok(listeners)
2320}
2321
2322#[cfg(not(target_os = "windows"))]
2323fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
2324 let output = Command::new("ss")
2325 .args(["-ltn"])
2326 .output()
2327 .map_err(|e| format!("Failed to run ss: {e}"))?;
2328 if !output.status.success() {
2329 return Err("ss returned a non-success status.".to_string());
2330 }
2331
2332 let text = String::from_utf8_lossy(&output.stdout);
2333 let mut listeners = Vec::new();
2334 for line in text.lines().skip(1) {
2335 let cols: Vec<&str> = line.split_whitespace().collect();
2336 if cols.len() < 4 {
2337 continue;
2338 }
2339 let Some(port) = extract_port_from_socket(cols[3]) else {
2340 continue;
2341 };
2342 listeners.push(ListeningPort {
2343 protocol: "tcp".to_string(),
2344 local: cols[3].to_string(),
2345 port,
2346 state: cols[0].to_string(),
2347 pid: None,
2348 process_name: None,
2349 });
2350 }
2351
2352 Ok(listeners)
2353}
2354
2355fn collect_processes() -> Result<Vec<ProcessEntry>, String> {
2356 #[cfg(target_os = "windows")]
2357 {
2358 collect_windows_processes()
2359 }
2360 #[cfg(not(target_os = "windows"))]
2361 {
2362 collect_unix_processes()
2363 }
2364}
2365
2366#[cfg(target_os = "windows")]
2367fn collect_windows_services() -> Result<Vec<ServiceEntry>, String> {
2368 let command = "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,DisplayName | ConvertTo-Json -Compress";
2369 let output = Command::new("powershell")
2370 .args(["-NoProfile", "-Command", command])
2371 .output()
2372 .map_err(|e| format!("Failed to run PowerShell service inspection: {e}"))?;
2373 if !output.status.success() {
2374 return Err("PowerShell service inspection returned a non-success status.".to_string());
2375 }
2376
2377 parse_windows_services_json(&String::from_utf8_lossy(&output.stdout))
2378}
2379
2380#[cfg(not(target_os = "windows"))]
2381fn collect_unix_services() -> Result<Vec<ServiceEntry>, String> {
2382 let status_output = Command::new("systemctl")
2383 .args([
2384 "list-units",
2385 "--type=service",
2386 "--all",
2387 "--no-pager",
2388 "--no-legend",
2389 "--plain",
2390 ])
2391 .output()
2392 .map_err(|e| format!("Failed to run systemctl list-units: {e}"))?;
2393 if !status_output.status.success() {
2394 return Err("systemctl list-units returned a non-success status.".to_string());
2395 }
2396
2397 let startup_output = Command::new("systemctl")
2398 .args([
2399 "list-unit-files",
2400 "--type=service",
2401 "--no-legend",
2402 "--no-pager",
2403 "--plain",
2404 ])
2405 .output()
2406 .map_err(|e| format!("Failed to run systemctl list-unit-files: {e}"))?;
2407 if !startup_output.status.success() {
2408 return Err("systemctl list-unit-files returned a non-success status.".to_string());
2409 }
2410
2411 Ok(parse_unix_services(
2412 &String::from_utf8_lossy(&status_output.stdout),
2413 &String::from_utf8_lossy(&startup_output.stdout),
2414 ))
2415}
2416
2417#[cfg(target_os = "windows")]
2418fn collect_windows_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2419 let output = Command::new("ipconfig")
2420 .args(["/all"])
2421 .output()
2422 .map_err(|e| format!("Failed to run ipconfig: {e}"))?;
2423 if !output.status.success() {
2424 return Err("ipconfig returned a non-success status.".to_string());
2425 }
2426
2427 Ok(parse_windows_ipconfig_all(&String::from_utf8_lossy(
2428 &output.stdout,
2429 )))
2430}
2431
2432#[cfg(not(target_os = "windows"))]
2433fn collect_unix_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2434 let addr_output = Command::new("ip")
2435 .args(["-o", "addr", "show", "up"])
2436 .output()
2437 .map_err(|e| format!("Failed to run ip addr: {e}"))?;
2438 if !addr_output.status.success() {
2439 return Err("ip addr returned a non-success status.".to_string());
2440 }
2441
2442 let route_output = Command::new("ip")
2443 .args(["route", "show", "default"])
2444 .output()
2445 .map_err(|e| format!("Failed to run ip route: {e}"))?;
2446 if !route_output.status.success() {
2447 return Err("ip route returned a non-success status.".to_string());
2448 }
2449
2450 let mut adapters = parse_unix_ip_addr(&String::from_utf8_lossy(&addr_output.stdout));
2451 apply_unix_default_routes(
2452 &mut adapters,
2453 &String::from_utf8_lossy(&route_output.stdout),
2454 );
2455 apply_unix_dns_servers(&mut adapters);
2456 Ok(adapters)
2457}
2458
2459#[cfg(target_os = "windows")]
2460fn collect_windows_processes() -> Result<Vec<ProcessEntry>, String> {
2461 let output = Command::new("powershell")
2462 .args([
2463 "-NoProfile",
2464 "-Command",
2465 "Get-Process | Select-Object Name, Id, WorkingSet64, CPU, ReadOperationCount, WriteOperationCount | ConvertTo-Json -Compress",
2466 ])
2467 .output()
2468 .map_err(|e| format!("Failed to run powershell Get-Process: {e}"))?;
2469
2470 if !output.status.success() {
2471 return Err("powershell Get-Process returned a non-success status.".to_string());
2472 }
2473
2474 let json_text = String::from_utf8_lossy(&output.stdout);
2475 let values: Value = serde_json::from_str(&json_text)
2476 .map_err(|e| format!("Failed to parse process JSON: {e}"))?;
2477
2478 let mut out = Vec::new();
2479 if let Some(arr) = values.as_array() {
2480 for v in arr {
2481 let name = v["Name"].as_str().unwrap_or("unknown").to_string();
2482 let pid = v["Id"].as_u64().unwrap_or(0) as u32;
2483 let memory_bytes = v["WorkingSet64"].as_u64().unwrap_or(0);
2484 let cpu_seconds = v["CPU"].as_f64();
2485 let read_ops = v["ReadOperationCount"].as_u64();
2486 let write_ops = v["WriteOperationCount"].as_u64();
2487 out.push(ProcessEntry {
2488 name,
2489 pid,
2490 memory_bytes,
2491 cpu_seconds,
2492 read_ops,
2493 write_ops,
2494 detail: None,
2495 });
2496 }
2497 } else if let Some(v) = values.as_object() {
2498 let name = v["Name"].as_str().unwrap_or("unknown").to_string();
2499 let pid = v["Id"].as_u64().unwrap_or(0) as u32;
2500 let memory_bytes = v["WorkingSet64"].as_u64().unwrap_or(0);
2501 let cpu_seconds = v["CPU"].as_f64();
2502 let read_ops = v["ReadOperationCount"].as_u64();
2503 let write_ops = v["WriteOperationCount"].as_u64();
2504 out.push(ProcessEntry {
2505 name,
2506 pid,
2507 memory_bytes,
2508 cpu_seconds,
2509 read_ops,
2510 write_ops,
2511 detail: None,
2512 });
2513 }
2514
2515 Ok(out)
2516}
2517
2518#[cfg(not(target_os = "windows"))]
2519fn collect_unix_processes() -> Result<Vec<ProcessEntry>, String> {
2520 let output = Command::new("ps")
2521 .args(["-eo", "pid=,rss=,comm="])
2522 .output()
2523 .map_err(|e| format!("Failed to run ps: {e}"))?;
2524 if !output.status.success() {
2525 return Err("ps returned a non-success status.".to_string());
2526 }
2527
2528 let text = String::from_utf8_lossy(&output.stdout);
2529 let mut processes = Vec::new();
2530 for line in text.lines() {
2531 let cols: Vec<&str> = line.split_whitespace().collect();
2532 if cols.len() < 3 {
2533 continue;
2534 }
2535 let (Some(pid), Some(rss_kib)) = (cols[0].parse::<u32>().ok(), cols[1].parse::<u64>().ok())
2536 else {
2537 continue;
2538 };
2539 processes.push(ProcessEntry {
2540 name: cols[2..].join(" "),
2541 pid,
2542 memory_bytes: rss_kib * 1024,
2543 cpu_seconds: None,
2544 read_ops: None,
2545 write_ops: None,
2546 detail: None,
2547 });
2548 }
2549
2550 Ok(processes)
2551}
2552
2553fn extract_port_from_socket(value: &str) -> Option<u16> {
2554 let cleaned = value.trim().trim_matches(['[', ']']);
2555 let port_str = cleaned.rsplit(':').next()?;
2556 port_str.parse::<u16>().ok()
2557}
2558
2559fn listener_exposure_summary(listeners: Vec<ListeningPort>) -> ListenerExposureSummary {
2560 let mut summary = ListenerExposureSummary::default();
2561 for entry in listeners {
2562 let local = entry.local.to_ascii_lowercase();
2563 if is_loopback_listener(&local) {
2564 summary.loopback_only += 1;
2565 } else if is_wildcard_listener(&local) {
2566 summary.wildcard_public += 1;
2567 } else {
2568 summary.specific_bind += 1;
2569 }
2570 }
2571 summary
2572}
2573
2574fn service_status_rank(status: &str) -> u8 {
2575 let lower = status.to_ascii_lowercase();
2576 if lower == "failed" || lower == "error" {
2577 0
2578 } else if lower == "running" || lower == "active" {
2579 1
2580 } else if lower == "starting" || lower == "activating" {
2581 2
2582 } else {
2583 3
2584 }
2585}
2586
2587fn is_loopback_listener(local: &str) -> bool {
2588 local.starts_with("127.")
2589 || local.starts_with("[::1]")
2590 || local.starts_with("::1")
2591 || local.starts_with("localhost:")
2592}
2593
2594fn is_wildcard_listener(local: &str) -> bool {
2595 local.starts_with("0.0.0.0:")
2596 || local.starts_with("[::]:")
2597 || local.starts_with(":::")
2598 || local == "*:*"
2599}
2600
2601struct GitState {
2602 root: PathBuf,
2603 branch: String,
2604 dirty_entries: usize,
2605}
2606
2607impl GitState {
2608 fn status_label(&self) -> String {
2609 if self.dirty_entries == 0 {
2610 "clean".to_string()
2611 } else {
2612 format!("dirty ({} changed path(s))", self.dirty_entries)
2613 }
2614 }
2615}
2616
2617fn inspect_git_state(path: &Path) -> Option<GitState> {
2618 let root = capture_first_line(
2619 "git",
2620 &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
2621 )?;
2622 let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
2623 .unwrap_or_else(|| "detached".to_string());
2624 let output = Command::new("git")
2625 .args(["-C", path.to_str()?, "status", "--short"])
2626 .output()
2627 .ok()?;
2628 if !output.status.success() {
2629 return None;
2630 }
2631 let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
2632 Some(GitState {
2633 root: PathBuf::from(root),
2634 branch,
2635 dirty_entries,
2636 })
2637}
2638
2639struct HematiteState {
2640 docs_count: usize,
2641 import_count: usize,
2642 report_count: usize,
2643 workspace_profile: bool,
2644}
2645
2646fn collect_hematite_state(path: &Path) -> HematiteState {
2647 let root = path.join(".hematite");
2648 HematiteState {
2649 docs_count: count_entries_if_exists(&root.join("docs")),
2650 import_count: count_entries_if_exists(&root.join("imports")),
2651 report_count: count_entries_if_exists(&root.join("reports")),
2652 workspace_profile: root.join("workspace_profile.json").exists(),
2653 }
2654}
2655
2656fn count_entries_if_exists(path: &Path) -> usize {
2657 if !path.exists() || !path.is_dir() {
2658 return 0;
2659 }
2660 fs::read_dir(path)
2661 .ok()
2662 .map(|iter| iter.filter(|entry| entry.is_ok()).count())
2663 .unwrap_or(0)
2664}
2665
2666fn collect_project_markers(path: &Path) -> Vec<String> {
2667 [
2668 "Cargo.toml",
2669 "package.json",
2670 "pyproject.toml",
2671 "go.mod",
2672 "justfile",
2673 "Makefile",
2674 ".git",
2675 ]
2676 .iter()
2677 .filter_map(|name| path.join(name).exists().then(|| (*name).to_string()))
2678 .collect()
2679}
2680
2681struct ReleaseArtifactState {
2682 version: String,
2683 portable_dir: bool,
2684 portable_zip: bool,
2685 setup_exe: bool,
2686}
2687
2688fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
2689 let cargo_toml = path.join("Cargo.toml");
2690 if !cargo_toml.exists() {
2691 return None;
2692 }
2693 let cargo_text = fs::read_to_string(cargo_toml).ok()?;
2694 let version = [regex_line_capture(
2695 &cargo_text,
2696 r#"(?m)^version\s*=\s*"([^"]+)""#,
2697 )?]
2698 .concat();
2699 let dist_windows = path.join("dist").join("windows");
2700 let prefix = format!("Hematite-{}", version);
2701 Some(ReleaseArtifactState {
2702 version,
2703 portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
2704 portable_zip: dist_windows
2705 .join(format!("{}-portable.zip", prefix))
2706 .exists(),
2707 setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
2708 })
2709}
2710
2711fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
2712 let regex = regex::Regex::new(pattern).ok()?;
2713 let captures = regex.captures(text)?;
2714 captures.get(1).map(|m| m.as_str().to_string())
2715}
2716
2717fn bool_label(value: bool) -> &'static str {
2718 if value {
2719 "yes"
2720 } else {
2721 "no"
2722 }
2723}
2724
2725fn collect_toolchains() -> ToolchainReport {
2726 let checks = [
2727 ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
2728 ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
2729 ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
2730 ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
2731 ToolCheck::new(
2732 "npm",
2733 &[
2734 CommandProbe::new("npm", &["--version"]),
2735 CommandProbe::new("npm.cmd", &["--version"]),
2736 ],
2737 ),
2738 ToolCheck::new(
2739 "pnpm",
2740 &[
2741 CommandProbe::new("pnpm", &["--version"]),
2742 CommandProbe::new("pnpm.cmd", &["--version"]),
2743 ],
2744 ),
2745 ToolCheck::new(
2746 "python",
2747 &[
2748 CommandProbe::new("python", &["--version"]),
2749 CommandProbe::new("python3", &["--version"]),
2750 CommandProbe::new("py", &["-3", "--version"]),
2751 CommandProbe::new("py", &["--version"]),
2752 ],
2753 ),
2754 ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
2755 ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
2756 ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
2757 ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
2758 ];
2759
2760 let mut found = Vec::new();
2761 let mut missing = Vec::new();
2762
2763 for check in checks {
2764 match check.detect() {
2765 Some(version) => found.push((check.label.to_string(), version)),
2766 None => missing.push(check.label.to_string()),
2767 }
2768 }
2769
2770 ToolchainReport { found, missing }
2771}
2772
2773fn collect_package_managers() -> PackageManagerReport {
2774 let checks = [
2775 ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
2776 ToolCheck::new(
2777 "npm",
2778 &[
2779 CommandProbe::new("npm", &["--version"]),
2780 CommandProbe::new("npm.cmd", &["--version"]),
2781 ],
2782 ),
2783 ToolCheck::new(
2784 "pnpm",
2785 &[
2786 CommandProbe::new("pnpm", &["--version"]),
2787 CommandProbe::new("pnpm.cmd", &["--version"]),
2788 ],
2789 ),
2790 ToolCheck::new(
2791 "pip",
2792 &[
2793 CommandProbe::new("python", &["-m", "pip", "--version"]),
2794 CommandProbe::new("python3", &["-m", "pip", "--version"]),
2795 CommandProbe::new("py", &["-3", "-m", "pip", "--version"]),
2796 CommandProbe::new("py", &["-m", "pip", "--version"]),
2797 CommandProbe::new("pip", &["--version"]),
2798 ],
2799 ),
2800 ToolCheck::new("pipx", &[CommandProbe::new("pipx", &["--version"])]),
2801 ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
2802 ToolCheck::new("winget", &[CommandProbe::new("winget", &["--version"])]),
2803 ToolCheck::new(
2804 "choco",
2805 &[
2806 CommandProbe::new("choco", &["--version"]),
2807 CommandProbe::new("choco.exe", &["--version"]),
2808 ],
2809 ),
2810 ToolCheck::new("scoop", &[CommandProbe::new("scoop", &["--version"])]),
2811 ];
2812
2813 let mut found = Vec::new();
2814 for check in checks {
2815 match check.detect() {
2816 Some(version) => found.push((check.label.to_string(), version)),
2817 None => {}
2818 }
2819 }
2820
2821 PackageManagerReport { found }
2822}
2823
2824#[derive(Clone)]
2825struct ToolCheck {
2826 label: &'static str,
2827 probes: Vec<CommandProbe>,
2828}
2829
2830impl ToolCheck {
2831 fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
2832 Self {
2833 label,
2834 probes: probes.to_vec(),
2835 }
2836 }
2837
2838 fn detect(&self) -> Option<String> {
2839 for probe in &self.probes {
2840 if let Some(output) = capture_first_line(probe.program, probe.args) {
2841 return Some(output);
2842 }
2843 }
2844 None
2845 }
2846}
2847
2848#[derive(Clone, Copy)]
2849struct CommandProbe {
2850 program: &'static str,
2851 args: &'static [&'static str],
2852}
2853
2854impl CommandProbe {
2855 const fn new(program: &'static str, args: &'static [&'static str]) -> Self {
2856 Self { program, args }
2857 }
2858}
2859
2860fn build_env_doctor_findings(
2861 toolchains: &ToolchainReport,
2862 package_managers: &PackageManagerReport,
2863 path_stats: &PathAnalysis,
2864) -> Vec<String> {
2865 let found_tools = toolchains
2866 .found
2867 .iter()
2868 .map(|(label, _)| label.as_str())
2869 .collect::<HashSet<_>>();
2870 let found_managers = package_managers
2871 .found
2872 .iter()
2873 .map(|(label, _)| label.as_str())
2874 .collect::<HashSet<_>>();
2875
2876 let mut findings = Vec::new();
2877
2878 if path_stats.duplicate_entries.len() > 0 {
2879 findings.push(format!(
2880 "PATH contains {} duplicate entries. That is usually harmless but worth cleaning up.",
2881 path_stats.duplicate_entries.len()
2882 ));
2883 }
2884 if path_stats.missing_entries.len() > 0 {
2885 findings.push(format!(
2886 "PATH contains {} entries that do not exist on disk.",
2887 path_stats.missing_entries.len()
2888 ));
2889 }
2890 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
2891 findings.push(
2892 "Rust is present but Cargo was not detected. That is an incomplete Rust toolchain."
2893 .to_string(),
2894 );
2895 }
2896 if found_tools.contains("node")
2897 && !found_managers.contains("npm")
2898 && !found_managers.contains("pnpm")
2899 {
2900 findings.push(
2901 "Node is present but no JavaScript package manager was detected (npm or pnpm)."
2902 .to_string(),
2903 );
2904 }
2905 if found_tools.contains("python")
2906 && !found_managers.contains("pip")
2907 && !found_managers.contains("uv")
2908 && !found_managers.contains("pipx")
2909 {
2910 findings.push(
2911 "Python is present but no Python package manager was detected (pip, uv, or pipx)."
2912 .to_string(),
2913 );
2914 }
2915 let windows_manager_count = ["winget", "choco", "scoop"]
2916 .iter()
2917 .filter(|label| found_managers.contains(**label))
2918 .count();
2919 if windows_manager_count > 1 {
2920 findings.push(
2921 "Multiple Windows package managers are installed. That is workable, but it can create overlap in update paths."
2922 .to_string(),
2923 );
2924 }
2925 if findings.is_empty() && !found_managers.is_empty() {
2926 findings.push(
2927 "Core package-manager coverage looks healthy for a normal developer workstation."
2928 .to_string(),
2929 );
2930 }
2931
2932 findings
2933}
2934
2935fn capture_first_line(program: &str, args: &[&str]) -> Option<String> {
2936 let output = std::process::Command::new(program)
2937 .args(args)
2938 .output()
2939 .ok()?;
2940 if !output.status.success() {
2941 return None;
2942 }
2943
2944 let stdout = if output.stdout.is_empty() {
2945 String::from_utf8_lossy(&output.stderr).into_owned()
2946 } else {
2947 String::from_utf8_lossy(&output.stdout).into_owned()
2948 };
2949
2950 stdout
2951 .lines()
2952 .map(str::trim)
2953 .find(|line| !line.is_empty())
2954 .map(|line| line.to_string())
2955}
2956
2957fn human_bytes(bytes: u64) -> String {
2958 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
2959 let mut value = bytes as f64;
2960 let mut unit_index = 0usize;
2961
2962 while value >= 1024.0 && unit_index < UNITS.len() - 1 {
2963 value /= 1024.0;
2964 unit_index += 1;
2965 }
2966
2967 if unit_index == 0 {
2968 format!("{} {}", bytes, UNITS[unit_index])
2969 } else {
2970 format!("{value:.1} {}", UNITS[unit_index])
2971 }
2972}
2973
2974#[cfg(target_os = "windows")]
2975fn parse_windows_ipconfig_all(text: &str) -> Vec<NetworkAdapter> {
2976 let mut adapters = Vec::new();
2977 let mut current: Option<NetworkAdapter> = None;
2978 let mut pending_dns = false;
2979
2980 for raw_line in text.lines() {
2981 let line = raw_line.trim_end();
2982 let trimmed = line.trim();
2983 if trimmed.is_empty() {
2984 pending_dns = false;
2985 continue;
2986 }
2987
2988 if !line.starts_with(' ') && trimmed.ends_with(':') && trimmed.contains("adapter") {
2989 if let Some(adapter) = current.take() {
2990 adapters.push(adapter);
2991 }
2992 current = Some(NetworkAdapter {
2993 name: trimmed.trim_end_matches(':').to_string(),
2994 ..NetworkAdapter::default()
2995 });
2996 pending_dns = false;
2997 continue;
2998 }
2999
3000 let Some(adapter) = current.as_mut() else {
3001 continue;
3002 };
3003
3004 if trimmed.contains("Media State") && trimmed.contains("disconnected") {
3005 adapter.disconnected = true;
3006 }
3007
3008 if let Some(value) = value_after_colon(trimmed) {
3009 let normalized = normalize_ipconfig_value(value);
3010 if trimmed.starts_with("IPv4 Address") && !normalized.is_empty() {
3011 adapter.ipv4.push(normalized);
3012 pending_dns = false;
3013 } else if trimmed.starts_with("IPv6 Address")
3014 || trimmed.starts_with("Temporary IPv6 Address")
3015 || trimmed.starts_with("Link-local IPv6 Address")
3016 {
3017 if !normalized.is_empty() {
3018 adapter.ipv6.push(normalized);
3019 }
3020 pending_dns = false;
3021 } else if trimmed.starts_with("Default Gateway") {
3022 if !normalized.is_empty() {
3023 adapter.gateways.push(normalized);
3024 }
3025 pending_dns = false;
3026 } else if trimmed.starts_with("DNS Servers") {
3027 if !normalized.is_empty() {
3028 adapter.dns_servers.push(normalized);
3029 }
3030 pending_dns = true;
3031 } else {
3032 pending_dns = false;
3033 }
3034 } else if pending_dns {
3035 let normalized = normalize_ipconfig_value(trimmed);
3036 if !normalized.is_empty() {
3037 adapter.dns_servers.push(normalized);
3038 }
3039 }
3040 }
3041
3042 if let Some(adapter) = current.take() {
3043 adapters.push(adapter);
3044 }
3045
3046 for adapter in &mut adapters {
3047 dedup_vec(&mut adapter.ipv4);
3048 dedup_vec(&mut adapter.ipv6);
3049 dedup_vec(&mut adapter.gateways);
3050 dedup_vec(&mut adapter.dns_servers);
3051 }
3052
3053 adapters
3054}
3055
3056#[cfg(not(target_os = "windows"))]
3057fn parse_unix_ip_addr(text: &str) -> Vec<NetworkAdapter> {
3058 let mut adapters = std::collections::BTreeMap::<String, NetworkAdapter>::new();
3059
3060 for line in text.lines() {
3061 let cols: Vec<&str> = line.split_whitespace().collect();
3062 if cols.len() < 4 {
3063 continue;
3064 }
3065 let name = cols[1].trim_end_matches(':').to_string();
3066 let family = cols[2];
3067 let addr = cols[3].split('/').next().unwrap_or("").to_string();
3068 let entry = adapters
3069 .entry(name.clone())
3070 .or_insert_with(|| NetworkAdapter {
3071 name,
3072 ..NetworkAdapter::default()
3073 });
3074 match family {
3075 "inet" if !addr.is_empty() => entry.ipv4.push(addr),
3076 "inet6" if !addr.is_empty() => entry.ipv6.push(addr),
3077 _ => {}
3078 }
3079 }
3080
3081 adapters.into_values().collect()
3082}
3083
3084#[cfg(not(target_os = "windows"))]
3085fn apply_unix_default_routes(adapters: &mut [NetworkAdapter], text: &str) {
3086 for line in text.lines() {
3087 let cols: Vec<&str> = line.split_whitespace().collect();
3088 if cols.len() < 5 {
3089 continue;
3090 }
3091 let gateway = cols
3092 .windows(2)
3093 .find(|pair| pair[0] == "via")
3094 .map(|pair| pair[1].to_string());
3095 let dev = cols
3096 .windows(2)
3097 .find(|pair| pair[0] == "dev")
3098 .map(|pair| pair[1]);
3099 if let (Some(gateway), Some(dev)) = (gateway, dev) {
3100 if let Some(adapter) = adapters.iter_mut().find(|adapter| adapter.name == dev) {
3101 adapter.gateways.push(gateway);
3102 }
3103 }
3104 }
3105
3106 for adapter in adapters {
3107 dedup_vec(&mut adapter.gateways);
3108 }
3109}
3110
3111#[cfg(not(target_os = "windows"))]
3112fn apply_unix_dns_servers(adapters: &mut [NetworkAdapter]) {
3113 let Ok(text) = fs::read_to_string("/etc/resolv.conf") else {
3114 return;
3115 };
3116 let mut dns_servers = text
3117 .lines()
3118 .filter_map(|line| line.strip_prefix("nameserver "))
3119 .map(str::trim)
3120 .filter(|value| !value.is_empty())
3121 .map(|value| value.to_string())
3122 .collect::<Vec<_>>();
3123 dedup_vec(&mut dns_servers);
3124 if dns_servers.is_empty() {
3125 return;
3126 }
3127 for adapter in adapters.iter_mut().filter(|adapter| adapter.is_active()) {
3128 adapter.dns_servers = dns_servers.clone();
3129 }
3130}
3131
3132#[cfg(target_os = "windows")]
3133fn value_after_colon(line: &str) -> Option<&str> {
3134 line.split_once(':').map(|(_, value)| value.trim())
3135}
3136
3137#[cfg(target_os = "windows")]
3138fn normalize_ipconfig_value(value: &str) -> String {
3139 value
3140 .trim()
3141 .trim_matches(['(', ')'])
3142 .trim_end_matches("(Preferred)")
3143 .trim()
3144 .to_string()
3145}
3146
3147fn dedup_vec(values: &mut Vec<String>) {
3148 let mut seen = HashSet::new();
3149 values.retain(|value| seen.insert(value.clone()));
3150}
3151
3152#[cfg(target_os = "windows")]
3153fn parse_windows_services_json(text: &str) -> Result<Vec<ServiceEntry>, String> {
3154 let trimmed = text.trim();
3155 if trimmed.is_empty() {
3156 return Ok(Vec::new());
3157 }
3158
3159 let value: Value = serde_json::from_str(trimmed)
3160 .map_err(|e| format!("Failed to parse PowerShell service JSON: {e}"))?;
3161 let entries = match value {
3162 Value::Array(items) => items,
3163 other => vec![other],
3164 };
3165
3166 let mut services = Vec::new();
3167 for entry in entries {
3168 let Some(name) = entry.get("Name").and_then(|v| v.as_str()) else {
3169 continue;
3170 };
3171 services.push(ServiceEntry {
3172 name: name.to_string(),
3173 status: entry
3174 .get("State")
3175 .and_then(|v| v.as_str())
3176 .unwrap_or("unknown")
3177 .to_string(),
3178 startup: entry
3179 .get("StartMode")
3180 .and_then(|v| v.as_str())
3181 .map(|value| value.to_string()),
3182 display_name: entry
3183 .get("DisplayName")
3184 .and_then(|v| v.as_str())
3185 .map(|value| value.to_string()),
3186 });
3187 }
3188
3189 Ok(services)
3190}
3191
3192#[cfg(not(target_os = "windows"))]
3193fn parse_unix_services(status_text: &str, startup_text: &str) -> Vec<ServiceEntry> {
3194 let mut startup_modes = std::collections::HashMap::<String, String>::new();
3195 for line in startup_text.lines() {
3196 let cols: Vec<&str> = line.split_whitespace().collect();
3197 if cols.len() < 2 {
3198 continue;
3199 }
3200 startup_modes.insert(cols[0].to_string(), cols[1].to_string());
3201 }
3202
3203 let mut services = Vec::new();
3204 for line in status_text.lines() {
3205 let cols: Vec<&str> = line.split_whitespace().collect();
3206 if cols.len() < 4 {
3207 continue;
3208 }
3209 let unit = cols[0];
3210 let load = cols[1];
3211 let active = cols[2];
3212 let sub = cols[3];
3213 let description = if cols.len() > 4 {
3214 Some(cols[4..].join(" "))
3215 } else {
3216 None
3217 };
3218 services.push(ServiceEntry {
3219 name: unit.to_string(),
3220 status: format!("{}/{}", active, sub),
3221 startup: startup_modes
3222 .get(unit)
3223 .cloned()
3224 .or_else(|| Some(load.to_string())),
3225 display_name: description,
3226 });
3227 }
3228
3229 services
3230}
3231
3232fn inspect_health_report() -> Result<String, String> {
3238 let mut needs_fix: Vec<String> = Vec::new();
3239 let mut watch: Vec<String> = Vec::new();
3240 let mut good: Vec<String> = Vec::new();
3241 let mut tips: Vec<String> = Vec::new();
3242
3243 health_check_disk(&mut needs_fix, &mut watch, &mut good);
3244 health_check_memory(&mut watch, &mut good);
3245 health_check_tools(&mut watch, &mut good, &mut tips);
3246 health_check_recent_errors(&mut watch, &mut tips);
3247
3248 let overall = if !needs_fix.is_empty() {
3249 "ACTION REQUIRED"
3250 } else if !watch.is_empty() {
3251 "WORTH A LOOK"
3252 } else {
3253 "ALL GOOD"
3254 };
3255
3256 let mut out = format!("System Health Report — {overall}\n\n");
3257
3258 if !needs_fix.is_empty() {
3259 out.push_str("Needs fixing:\n");
3260 for item in &needs_fix {
3261 out.push_str(&format!(" [!] {item}\n"));
3262 }
3263 out.push('\n');
3264 }
3265 if !watch.is_empty() {
3266 out.push_str("Worth watching:\n");
3267 for item in &watch {
3268 out.push_str(&format!(" [-] {item}\n"));
3269 }
3270 out.push('\n');
3271 }
3272 if !good.is_empty() {
3273 out.push_str("Looking good:\n");
3274 for item in &good {
3275 out.push_str(&format!(" [+] {item}\n"));
3276 }
3277 out.push('\n');
3278 }
3279 if !tips.is_empty() {
3280 out.push_str("To dig deeper:\n");
3281 for tip in &tips {
3282 out.push_str(&format!(" {tip}\n"));
3283 }
3284 }
3285
3286 Ok(out.trim_end().to_string())
3287}
3288
3289fn health_check_disk(needs_fix: &mut Vec<String>, watch: &mut Vec<String>, good: &mut Vec<String>) {
3290 #[cfg(target_os = "windows")]
3291 {
3292 let script = r#"try {
3293 $d = Get-PSDrive C -ErrorAction Stop
3294 "$($d.Free)|$($d.Used)"
3295} catch { "ERR" }"#;
3296 if let Ok(out) = Command::new("powershell")
3297 .args(["-NoProfile", "-Command", script])
3298 .output()
3299 {
3300 let text = String::from_utf8_lossy(&out.stdout);
3301 let text = text.trim();
3302 if !text.starts_with("ERR") {
3303 let parts: Vec<&str> = text.split('|').collect();
3304 if parts.len() == 2 {
3305 let free_bytes: u64 = parts[0].trim().parse().unwrap_or(0);
3306 let used_bytes: u64 = parts[1].trim().parse().unwrap_or(0);
3307 let total = free_bytes + used_bytes;
3308 let free_gb = free_bytes / 1_073_741_824;
3309 let pct_free = if total > 0 {
3310 (free_bytes as f64 / total as f64 * 100.0) as u64
3311 } else {
3312 0
3313 };
3314 let msg = format!("Disk: {free_gb} GB free on C: ({pct_free}% available)");
3315 if free_gb < 5 {
3316 needs_fix.push(format!(
3317 "{msg} — very low. Free up space or your system may slow down or stop working."
3318 ));
3319 } else if free_gb < 15 {
3320 watch.push(format!("{msg} — getting low, consider cleaning up."));
3321 } else {
3322 good.push(msg);
3323 }
3324 return;
3325 }
3326 }
3327 }
3328 watch.push("Disk: could not read free space from C: drive.".to_string());
3329 }
3330
3331 #[cfg(not(target_os = "windows"))]
3332 {
3333 if let Ok(out) = Command::new("df").args(["-BG", "/"]).output() {
3334 let text = String::from_utf8_lossy(&out.stdout);
3335 for line in text.lines().skip(1) {
3336 let cols: Vec<&str> = line.split_whitespace().collect();
3337 if cols.len() >= 5 {
3338 let avail_str = cols[3].trim_end_matches('G');
3339 let use_pct = cols[4].trim_end_matches('%');
3340 let avail_gb: u64 = avail_str.parse().unwrap_or(0);
3341 let used_pct: u64 = use_pct.parse().unwrap_or(0);
3342 let msg = format!("Disk: {avail_gb} GB free on / ({used_pct}% used)");
3343 if avail_gb < 5 {
3344 needs_fix.push(format!(
3345 "{msg} — very low. Free up space to prevent system issues."
3346 ));
3347 } else if avail_gb < 15 {
3348 watch.push(format!("{msg} — getting low."));
3349 } else {
3350 good.push(msg);
3351 }
3352 return;
3353 }
3354 }
3355 }
3356 watch.push("Disk: could not determine free space.".to_string());
3357 }
3358}
3359
3360fn health_check_memory(watch: &mut Vec<String>, good: &mut Vec<String>) {
3361 #[cfg(target_os = "windows")]
3362 {
3363 let script = r#"try {
3364 $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
3365 "$($os.FreePhysicalMemory)|$($os.TotalVisibleMemorySize)"
3366} catch { "ERR" }"#;
3367 if let Ok(out) = Command::new("powershell")
3368 .args(["-NoProfile", "-Command", script])
3369 .output()
3370 {
3371 let text = String::from_utf8_lossy(&out.stdout);
3372 let text = text.trim();
3373 if !text.starts_with("ERR") {
3374 let parts: Vec<&str> = text.split('|').collect();
3375 if parts.len() == 2 {
3376 let free_kb: u64 = parts[0].trim().parse().unwrap_or(0);
3377 let total_kb: u64 = parts[1].trim().parse().unwrap_or(0);
3378 if total_kb > 0 {
3379 let free_gb = free_kb / 1_048_576;
3380 let total_gb = total_kb / 1_048_576;
3381 let free_pct = free_kb * 100 / total_kb;
3382 let msg = format!(
3383 "RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)"
3384 );
3385 if free_pct < 10 {
3386 watch.push(format!(
3387 "{msg} — very low. Close unused apps to free up memory."
3388 ));
3389 } else if free_pct < 25 {
3390 watch.push(format!("{msg} — running a bit low."));
3391 } else {
3392 good.push(msg);
3393 }
3394 return;
3395 }
3396 }
3397 }
3398 }
3399 }
3400
3401 #[cfg(not(target_os = "windows"))]
3402 {
3403 if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
3404 let mut total_kb = 0u64;
3405 let mut avail_kb = 0u64;
3406 for line in content.lines() {
3407 if line.starts_with("MemTotal:") {
3408 total_kb = line
3409 .split_whitespace()
3410 .nth(1)
3411 .and_then(|v| v.parse().ok())
3412 .unwrap_or(0);
3413 } else if line.starts_with("MemAvailable:") {
3414 avail_kb = line
3415 .split_whitespace()
3416 .nth(1)
3417 .and_then(|v| v.parse().ok())
3418 .unwrap_or(0);
3419 }
3420 }
3421 if total_kb > 0 {
3422 let free_gb = avail_kb / 1_048_576;
3423 let total_gb = total_kb / 1_048_576;
3424 let free_pct = avail_kb * 100 / total_kb;
3425 let msg =
3426 format!("RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)");
3427 if free_pct < 10 {
3428 watch.push(format!("{msg} — very low. Close unused apps."));
3429 } else if free_pct < 25 {
3430 watch.push(format!("{msg} — running a bit low."));
3431 } else {
3432 good.push(msg);
3433 }
3434 }
3435 }
3436 }
3437}
3438
3439fn health_check_tools(watch: &mut Vec<String>, good: &mut Vec<String>, tips: &mut Vec<String>) {
3440 let tool_checks: &[(&str, &str, &str)] = &[
3441 ("git", "--version", "Git"),
3442 ("cargo", "--version", "Rust / Cargo"),
3443 ("node", "--version", "Node.js"),
3444 ("python", "--version", "Python"),
3445 ("python3", "--version", "Python 3"),
3446 ("npm", "--version", "npm"),
3447 ];
3448
3449 let mut found: Vec<String> = Vec::new();
3450 let mut missing: Vec<String> = Vec::new();
3451 let mut python_found = false;
3452
3453 for (cmd, arg, label) in tool_checks {
3454 if cmd.starts_with("python") && python_found {
3455 continue;
3456 }
3457 let ok = Command::new(cmd)
3458 .arg(arg)
3459 .stdout(std::process::Stdio::null())
3460 .stderr(std::process::Stdio::null())
3461 .status()
3462 .map(|s| s.success())
3463 .unwrap_or(false);
3464 if ok {
3465 found.push((*label).to_string());
3466 if cmd.starts_with("python") {
3467 python_found = true;
3468 }
3469 } else if !cmd.starts_with("python") || !python_found {
3470 missing.push((*label).to_string());
3471 }
3472 }
3473
3474 if !found.is_empty() {
3475 good.push(format!("Dev tools found: {}", found.join(", ")));
3476 }
3477 if !missing.is_empty() {
3478 watch.push(format!(
3479 "Not installed (or not on PATH): {} — only matters if you need them",
3480 missing.join(", ")
3481 ));
3482 tips.push(
3483 "Run inspect_host(topic=\"toolchains\") for exact version details on all dev tools."
3484 .to_string(),
3485 );
3486 }
3487}
3488
3489fn health_check_recent_errors(watch: &mut Vec<String>, tips: &mut Vec<String>) {
3490 #[cfg(target_os = "windows")]
3491 {
3492 let script = r#"try {
3493 $cutoff = (Get-Date).AddHours(-24)
3494 $count = (Get-WinEvent -FilterHashtable @{LogName='Application','System'; Level=1,2,3; StartTime=$cutoff} -MaxEvents 200 -ErrorAction SilentlyContinue | Measure-Object).Count
3495 $count
3496} catch { "0" }"#;
3497 if let Ok(out) = Command::new("powershell")
3498 .args(["-NoProfile", "-Command", script])
3499 .output()
3500 {
3501 let text = String::from_utf8_lossy(&out.stdout);
3502 let count: u64 = text.trim().parse().unwrap_or(0);
3503 if count > 0 {
3504 watch.push(format!(
3505 "{count} critical/error event{} in Windows event logs in the last 24 hours.",
3506 if count == 1 { "" } else { "s" }
3507 ));
3508 tips.push(
3509 "Run inspect_host(topic=\"log_check\") to see the actual error messages."
3510 .to_string(),
3511 );
3512 }
3513 }
3514 }
3515
3516 #[cfg(not(target_os = "windows"))]
3517 {
3518 if let Ok(out) = Command::new("journalctl")
3519 .args(["-p", "3", "-n", "1", "--no-pager", "--quiet"])
3520 .output()
3521 {
3522 let text = String::from_utf8_lossy(&out.stdout);
3523 if !text.trim().is_empty() {
3524 watch.push("Critical/error entries found in the system journal.".to_string());
3525 tips.push(
3526 "Run inspect_host(topic=\"log_check\") to see recent errors.".to_string(),
3527 );
3528 }
3529 }
3530 }
3531}
3532
3533fn inspect_log_check(max_entries: usize) -> Result<String, String> {
3536 let mut out = String::from("Host inspection: log_check\n\n");
3537
3538 #[cfg(target_os = "windows")]
3539 {
3540 let n = max_entries.clamp(1, 50);
3542 let script = format!(
3543 r#"try {{
3544 $events = Get-WinEvent -FilterHashtable @{{LogName='Application','System'; Level=1,2,3}} -MaxEvents 100 -ErrorAction SilentlyContinue
3545 if (-not $events) {{ "NO_EVENTS"; exit }}
3546 $events | Select-Object -First {n} | ForEach-Object {{
3547 $line = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + '|' + $_.LevelDisplayName + '|' + $_.ProviderName + '|' + (($_.Message -split '[\r\n]')[0].Trim())
3548 $line
3549 }}
3550}} catch {{ "ERROR:" + $_.Exception.Message }}"#,
3551 n = n
3552 );
3553 let output = Command::new("powershell")
3554 .args(["-NoProfile", "-Command", &script])
3555 .output()
3556 .map_err(|e| format!("log_check: failed to run PowerShell: {e}"))?;
3557
3558 let raw = String::from_utf8_lossy(&output.stdout);
3559 let text = raw.trim();
3560
3561 if text.is_empty() || text == "NO_EVENTS" {
3562 out.push_str("No critical or error events found in Application/System logs.\n");
3563 return Ok(out.trim_end().to_string());
3564 }
3565 if text.starts_with("ERROR:") {
3566 out.push_str(&format!("Warning: event log query returned: {text}\n"));
3567 return Ok(out.trim_end().to_string());
3568 }
3569
3570 let mut count = 0usize;
3571 for line in text.lines() {
3572 let parts: Vec<&str> = line.splitn(4, '|').collect();
3573 if parts.len() == 4 {
3574 let (time, level, source, msg) = (parts[0], parts[1], parts[2], parts[3]);
3575 out.push_str(&format!("[{time}] [{level}] {source}: {msg}\n"));
3576 count += 1;
3577 }
3578 }
3579 out.push_str(&format!(
3580 "\nEvents shown: {count} (critical/error from Application + System logs)\n"
3581 ));
3582 }
3583
3584 #[cfg(not(target_os = "windows"))]
3585 {
3586 let n = max_entries.clamp(1, 50).to_string();
3588 let output = Command::new("journalctl")
3589 .args(["-p", "3", "-n", &n, "--no-pager", "--output=short-precise"])
3590 .output();
3591
3592 match output {
3593 Ok(o) if o.status.success() => {
3594 let text = String::from_utf8_lossy(&o.stdout);
3595 let trimmed = text.trim();
3596 if trimmed.is_empty() || trimmed.contains("No entries") {
3597 out.push_str("No critical or error entries found in the system journal.\n");
3598 } else {
3599 out.push_str(trimmed);
3600 out.push('\n');
3601 out.push_str("\n(source: journalctl -p 3 = critical/alert/emergency/error)\n");
3602 }
3603 }
3604 _ => {
3605 let log_paths = ["/var/log/syslog", "/var/log/messages"];
3607 let mut found = false;
3608 for log_path in &log_paths {
3609 if let Ok(content) = std::fs::read_to_string(log_path) {
3610 let lines: Vec<&str> = content.lines().collect();
3611 let tail: Vec<&str> = lines
3612 .iter()
3613 .rev()
3614 .filter(|l| {
3615 let l_lower = l.to_ascii_lowercase();
3616 l_lower.contains("error") || l_lower.contains("crit")
3617 })
3618 .take(max_entries)
3619 .copied()
3620 .collect::<Vec<_>>()
3621 .into_iter()
3622 .rev()
3623 .collect();
3624 if !tail.is_empty() {
3625 out.push_str(&format!("Source: {log_path}\n"));
3626 for l in &tail {
3627 out.push_str(l);
3628 out.push('\n');
3629 }
3630 found = true;
3631 break;
3632 }
3633 }
3634 }
3635 if !found {
3636 out.push_str(
3637 "journalctl not found and no readable syslog detected on this system.\n",
3638 );
3639 }
3640 }
3641 }
3642 }
3643
3644 Ok(out.trim_end().to_string())
3645}
3646
3647fn inspect_startup_items(max_entries: usize) -> Result<String, String> {
3650 let mut out = String::from("Host inspection: startup_items\n\n");
3651
3652 #[cfg(target_os = "windows")]
3653 {
3654 let script = r#"
3656$hives = @(
3657 @{Hive='HKLM'; Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
3658 @{Hive='HKCU'; Path='HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
3659 @{Hive='HKLM (32-bit)'; Path='HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'}
3660)
3661foreach ($h in $hives) {
3662 try {
3663 $props = Get-ItemProperty -Path $h.Path -ErrorAction Stop
3664 $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
3665 "$($h.Hive)|$($_.Name)|$($_.Value)"
3666 }
3667 } catch {}
3668}
3669"#;
3670 let output = Command::new("powershell")
3671 .args(["-NoProfile", "-Command", script])
3672 .output()
3673 .map_err(|e| format!("startup_items: failed to run PowerShell: {e}"))?;
3674
3675 let raw = String::from_utf8_lossy(&output.stdout);
3676 let text = raw.trim();
3677
3678 let entries: Vec<(String, String, String)> = text
3679 .lines()
3680 .filter_map(|l| {
3681 let parts: Vec<&str> = l.splitn(3, '|').collect();
3682 if parts.len() == 3 {
3683 Some((
3684 parts[0].to_string(),
3685 parts[1].to_string(),
3686 parts[2].to_string(),
3687 ))
3688 } else {
3689 None
3690 }
3691 })
3692 .take(max_entries)
3693 .collect();
3694
3695 if entries.is_empty() {
3696 out.push_str("No startup entries found in the Windows Run registry keys.\n");
3697 } else {
3698 out.push_str("Registry run keys (programs that start with Windows):\n\n");
3699 let mut last_hive = String::new();
3700 for (hive, name, value) in &entries {
3701 if *hive != last_hive {
3702 out.push_str(&format!("[{}]\n", hive));
3703 last_hive = hive.clone();
3704 }
3705 let display = if value.len() > 100 {
3707 format!("{}…", &value[..100])
3708 } else {
3709 value.clone()
3710 };
3711 out.push_str(&format!(" {name}: {display}\n"));
3712 }
3713 out.push_str(&format!("\nTotal startup entries: {}\n", entries.len()));
3714 }
3715
3716 let unified_script = r#"Get-CimInstance Win32_StartupCommand | ForEach-Object { " $($_.Name): $($_.Command) ($($_.Location))" }"#;
3718 if let Ok(unified_out) = Command::new("powershell")
3719 .args(["-NoProfile", "-Command", unified_script])
3720 .output()
3721 {
3722 let unified_text = String::from_utf8_lossy(&unified_out.stdout);
3723 let trimmed = unified_text.trim();
3724 if !trimmed.is_empty() {
3725 out.push_str("\n=== Unified Startup Commands (WMI) ===\n");
3726 out.push_str(trimmed);
3727 out.push('\n');
3728 }
3729 }
3730 }
3731
3732 #[cfg(not(target_os = "windows"))]
3733 {
3734 let output = Command::new("systemctl")
3736 .args([
3737 "list-unit-files",
3738 "--type=service",
3739 "--state=enabled",
3740 "--no-legend",
3741 "--no-pager",
3742 "--plain",
3743 ])
3744 .output();
3745
3746 match output {
3747 Ok(o) if o.status.success() => {
3748 let text = String::from_utf8_lossy(&o.stdout);
3749 let services: Vec<&str> = text
3750 .lines()
3751 .filter(|l| !l.trim().is_empty())
3752 .take(max_entries)
3753 .collect();
3754 if services.is_empty() {
3755 out.push_str("No enabled systemd services found.\n");
3756 } else {
3757 out.push_str("Enabled systemd services (run at boot):\n\n");
3758 for s in &services {
3759 out.push_str(&format!(" {s}\n"));
3760 }
3761 out.push_str(&format!(
3762 "\nShowing {} of enabled services.\n",
3763 services.len()
3764 ));
3765 }
3766 }
3767 _ => {
3768 out.push_str(
3769 "systemctl not found on this system. Cannot enumerate startup services.\n",
3770 );
3771 }
3772 }
3773
3774 if let Ok(cron_out) = Command::new("crontab").args(["-l"]).output() {
3776 let cron_text = String::from_utf8_lossy(&cron_out.stdout);
3777 let reboot_entries: Vec<&str> = cron_text
3778 .lines()
3779 .filter(|l| l.trim_start().starts_with("@reboot"))
3780 .collect();
3781 if !reboot_entries.is_empty() {
3782 out.push_str("\nCron @reboot entries:\n");
3783 for e in reboot_entries {
3784 out.push_str(&format!(" {e}\n"));
3785 }
3786 }
3787 }
3788 }
3789
3790 Ok(out.trim_end().to_string())
3791}
3792
3793fn inspect_os_config() -> Result<String, String> {
3794 let mut out = String::from("Host inspection: OS Configuration\n\n");
3795
3796 #[cfg(target_os = "windows")]
3797 {
3798 if let Ok(power_out) = Command::new("powercfg").args(["/getactivescheme"]).output() {
3800 let power_str = String::from_utf8_lossy(&power_out.stdout);
3801 out.push_str("=== Power Plan ===\n");
3802 out.push_str(power_str.trim());
3803 out.push_str("\n\n");
3804 }
3805
3806 let fw_script =
3808 "Get-NetFirewallProfile | Format-Table -Property Name, Enabled -AutoSize | Out-String";
3809 if let Ok(fw_out) = Command::new("powershell")
3810 .args(["-NoProfile", "-Command", fw_script])
3811 .output()
3812 {
3813 let fw_str = String::from_utf8_lossy(&fw_out.stdout);
3814 out.push_str("=== Firewall Profiles ===\n");
3815 out.push_str(fw_str.trim());
3816 out.push_str("\n\n");
3817 }
3818
3819 let uptime_script =
3821 "(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString()";
3822 if let Ok(uptime_out) = Command::new("powershell")
3823 .args(["-NoProfile", "-Command", uptime_script])
3824 .output()
3825 {
3826 let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
3827 out.push_str("=== System Uptime (Last Boot) ===\n");
3828 out.push_str(uptime_str.trim());
3829 out.push_str("\n\n");
3830 }
3831 }
3832
3833 #[cfg(not(target_os = "windows"))]
3834 {
3835 if let Ok(uptime_out) = Command::new("uptime").args(["-p"]).output() {
3837 let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
3838 out.push_str("=== System Uptime ===\n");
3839 out.push_str(uptime_str.trim());
3840 out.push_str("\n\n");
3841 }
3842
3843 if let Ok(ufw_out) = Command::new("ufw").arg("status").output() {
3845 let ufw_str = String::from_utf8_lossy(&ufw_out.stdout);
3846 if !ufw_str.trim().is_empty() {
3847 out.push_str("=== Firewall (UFW) ===\n");
3848 out.push_str(ufw_str.trim());
3849 out.push_str("\n\n");
3850 }
3851 }
3852 }
3853 Ok(out.trim_end().to_string())
3854}
3855
3856pub async fn resolve_host_issue(args: &Value) -> Result<String, String> {
3857 let action = args
3858 .get("action")
3859 .and_then(|v| v.as_str())
3860 .ok_or_else(|| "Missing required argument: 'action'".to_string())?;
3861
3862 let target = args
3863 .get("target")
3864 .and_then(|v| v.as_str())
3865 .unwrap_or("")
3866 .trim();
3867
3868 if target.is_empty() && action != "clear_temp" {
3869 return Err("Missing required argument: 'target' for this action".to_string());
3870 }
3871
3872 match action {
3873 "install_package" => {
3874 #[cfg(target_os = "windows")]
3875 {
3876 let cmd = format!("winget install --id {} -e --accept-package-agreements --accept-source-agreements", target);
3877 match Command::new("powershell")
3878 .args(["-NoProfile", "-Command", &cmd])
3879 .output()
3880 {
3881 Ok(out) => Ok(format!(
3882 "Executed remediation (winget install):\n{}",
3883 String::from_utf8_lossy(&out.stdout)
3884 )),
3885 Err(e) => Err(format!("Failed to run winget: {}", e)),
3886 }
3887 }
3888 #[cfg(not(target_os = "windows"))]
3889 {
3890 Err(
3891 "install_package via wrapper is only supported on Windows currently (winget)"
3892 .to_string(),
3893 )
3894 }
3895 }
3896 "restart_service" => {
3897 #[cfg(target_os = "windows")]
3898 {
3899 let cmd = format!("Restart-Service -Name {} -Force", target);
3900 match Command::new("powershell")
3901 .args(["-NoProfile", "-Command", &cmd])
3902 .output()
3903 {
3904 Ok(out) => {
3905 let err_str = String::from_utf8_lossy(&out.stderr);
3906 if !err_str.is_empty() {
3907 return Err(format!("Error restarting service:\n{}", err_str));
3908 }
3909 Ok(format!("Successfully restarted service: {}", target))
3910 }
3911 Err(e) => Err(format!("Failed to restart service: {}", e)),
3912 }
3913 }
3914 #[cfg(not(target_os = "windows"))]
3915 {
3916 Err(
3917 "restart_service via wrapper is only supported on Windows currently"
3918 .to_string(),
3919 )
3920 }
3921 }
3922 "clear_temp" => {
3923 #[cfg(target_os = "windows")]
3924 {
3925 let cmd = "Remove-Item -Path \"$env:TEMP\\*\" -Recurse -Force -ErrorAction SilentlyContinue";
3926 match Command::new("powershell")
3927 .args(["-NoProfile", "-Command", cmd])
3928 .output()
3929 {
3930 Ok(_) => Ok("Successfully cleared temporary files".to_string()),
3931 Err(e) => Err(format!("Failed to clear temp: {}", e)),
3932 }
3933 }
3934 #[cfg(not(target_os = "windows"))]
3935 {
3936 Err("clear_temp via wrapper is only supported on Windows currently".to_string())
3937 }
3938 }
3939 other => Err(format!("Unknown remediation action: {}", other)),
3940 }
3941}
3942
3943fn inspect_storage(max_entries: usize) -> Result<String, String> {
3946 let mut out = String::from("Host inspection: storage\n\n");
3947 let _ = max_entries; out.push_str("Drives:\n");
3951
3952 #[cfg(target_os = "windows")]
3953 {
3954 let script = r#"Get-PSDrive -PSProvider 'FileSystem' | ForEach-Object {
3955 $free = $_.Free
3956 $used = $_.Used
3957 if ($free -eq $null) { $free = 0 }
3958 if ($used -eq $null) { $used = 0 }
3959 $total = $free + $used
3960 "$($_.Name)|$free|$used|$total"
3961}"#;
3962 match Command::new("powershell")
3963 .args(["-NoProfile", "-Command", script])
3964 .output()
3965 {
3966 Ok(o) => {
3967 let text = String::from_utf8_lossy(&o.stdout);
3968 let mut drive_count = 0usize;
3969 for line in text.lines() {
3970 let parts: Vec<&str> = line.trim().split('|').collect();
3971 if parts.len() == 4 {
3972 let name = parts[0];
3973 let free: u64 = parts[1].parse().unwrap_or(0);
3974 let total: u64 = parts[3].parse().unwrap_or(0);
3975 if total == 0 {
3976 continue;
3977 }
3978 let free_gb = free / 1_073_741_824;
3979 let total_gb = total / 1_073_741_824;
3980 let used_pct = ((total - free) as f64 / total as f64 * 100.0) as u64;
3981 let bar_len = 20usize;
3982 let filled = (used_pct as usize * bar_len / 100).min(bar_len);
3983 let bar: String = "#".repeat(filled) + &".".repeat(bar_len - filled);
3984 let warn = if free_gb < 5 {
3985 " [!] CRITICALLY LOW"
3986 } else if free_gb < 15 {
3987 " [-] LOW"
3988 } else {
3989 ""
3990 };
3991 out.push_str(&format!(
3992 " {name}: [{bar}] {used_pct}% used — {free_gb} GB free of {total_gb} GB{warn}\n"
3993 ));
3994 drive_count += 1;
3995 }
3996 }
3997 if drive_count == 0 {
3998 out.push_str(" (could not enumerate drives)\n");
3999 }
4000 }
4001 Err(e) => out.push_str(&format!(" (drive scan failed: {e})\n")),
4002 }
4003
4004 let latency_script = "Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter \"Name='_Total'\" | Select-Object -ExpandProperty AvgDiskQueueLength";
4006 match Command::new("powershell")
4007 .args(["-NoProfile", "-Command", latency_script])
4008 .output()
4009 {
4010 Ok(o) => {
4011 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
4012 if !text.is_empty() {
4013 out.push_str("\nReal-time Disk Intensity:\n");
4014 out.push_str(&format!(" Average Disk Queue Length: {text}\n"));
4015 if let Ok(q) = text.parse::<f64>() {
4016 if q > 2.0 {
4017 out.push_str(
4018 " [!] WARNING: High disk latency detected (Queue Length > 2.0)\n",
4019 );
4020 } else {
4021 out.push_str(" [~] Disk latency is within healthy bounds.\n");
4022 }
4023 }
4024 }
4025 }
4026 Err(_) => {}
4027 }
4028 }
4029
4030 #[cfg(not(target_os = "windows"))]
4031 {
4032 match Command::new("df")
4033 .args(["-h", "--output=target,size,avail,pcent"])
4034 .output()
4035 {
4036 Ok(o) => {
4037 let text = String::from_utf8_lossy(&o.stdout);
4038 let mut count = 0usize;
4039 for line in text.lines().skip(1) {
4040 let cols: Vec<&str> = line.split_whitespace().collect();
4041 if cols.len() >= 4 && !cols[0].starts_with("tmpfs") {
4042 out.push_str(&format!(
4043 " {} size: {} avail: {} used: {}\n",
4044 cols[0], cols[1], cols[2], cols[3]
4045 ));
4046 count += 1;
4047 if count >= max_entries {
4048 break;
4049 }
4050 }
4051 }
4052 }
4053 Err(e) => out.push_str(&format!(" (df failed: {e})\n")),
4054 }
4055 }
4056
4057 out.push_str("\nLarge developer cache directories (if present):\n");
4059
4060 #[cfg(target_os = "windows")]
4061 {
4062 let home = std::env::var("USERPROFILE").unwrap_or_default();
4063 let check_dirs: &[(&str, &str)] = &[
4064 ("Temp", r"AppData\Local\Temp"),
4065 ("npm cache", r"AppData\Roaming\npm-cache"),
4066 ("Cargo registry", r".cargo\registry"),
4067 ("Cargo git", r".cargo\git"),
4068 ("pip cache", r"AppData\Local\pip\cache"),
4069 ("Yarn cache", r"AppData\Local\Yarn\Cache"),
4070 (".rustup toolchains", r".rustup\toolchains"),
4071 ("node_modules (home)", r"node_modules"),
4072 ];
4073
4074 let mut found_any = false;
4075 for (label, rel) in check_dirs {
4076 let full = format!(r"{}\{}", home, rel);
4077 let path = std::path::Path::new(&full);
4078 if path.exists() {
4079 let size_script = format!(
4081 r#"try {{ $s = (Get-ChildItem -Path '{}' -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum; [math]::Round($s/1MB,0) }} catch {{ '?' }}"#,
4082 full.replace('\'', "''")
4083 );
4084 let size_mb = Command::new("powershell")
4085 .args(["-NoProfile", "-Command", &size_script])
4086 .output()
4087 .ok()
4088 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
4089 .unwrap_or_else(|| "?".to_string());
4090 out.push_str(&format!(" {label}: {size_mb} MB ({full})\n"));
4091 found_any = true;
4092 }
4093 }
4094 if !found_any {
4095 out.push_str(" (none of the common cache directories found)\n");
4096 }
4097
4098 out.push_str("\nTip: to reclaim space, run inspect_host(topic=\"fix_plan\", issue=\"free up disk space\")\n");
4099 }
4100
4101 #[cfg(not(target_os = "windows"))]
4102 {
4103 let home = std::env::var("HOME").unwrap_or_default();
4104 let check_dirs: &[(&str, &str)] = &[
4105 ("npm cache", ".npm"),
4106 ("Cargo registry", ".cargo/registry"),
4107 ("pip cache", ".cache/pip"),
4108 (".rustup toolchains", ".rustup/toolchains"),
4109 ("Yarn cache", ".cache/yarn"),
4110 ];
4111 let mut found_any = false;
4112 for (label, rel) in check_dirs {
4113 let full = format!("{}/{}", home, rel);
4114 if std::path::Path::new(&full).exists() {
4115 let size = Command::new("du")
4116 .args(["-sh", &full])
4117 .output()
4118 .ok()
4119 .map(|o| {
4120 let s = String::from_utf8_lossy(&o.stdout);
4121 s.split_whitespace().next().unwrap_or("?").to_string()
4122 })
4123 .unwrap_or_else(|| "?".to_string());
4124 out.push_str(&format!(" {label}: {size} ({full})\n"));
4125 found_any = true;
4126 }
4127 }
4128 if !found_any {
4129 out.push_str(" (none of the common cache directories found)\n");
4130 }
4131 }
4132
4133 Ok(out.trim_end().to_string())
4134}
4135
4136fn inspect_hardware() -> Result<String, String> {
4139 let mut out = String::from("Host inspection: hardware\n\n");
4140
4141 #[cfg(target_os = "windows")]
4142 {
4143 let cpu_script = r#"Get-CimInstance Win32_Processor | ForEach-Object {
4145 "$($_.Name.Trim())|$($_.NumberOfCores)|$($_.NumberOfLogicalProcessors)|$([math]::Round($_.MaxClockSpeed/1000,1))"
4146} | Select-Object -First 1"#;
4147 if let Ok(o) = Command::new("powershell")
4148 .args(["-NoProfile", "-Command", cpu_script])
4149 .output()
4150 {
4151 let text = String::from_utf8_lossy(&o.stdout);
4152 let text = text.trim();
4153 let parts: Vec<&str> = text.split('|').collect();
4154 if parts.len() == 4 {
4155 out.push_str(&format!(
4156 "CPU: {}\n {} physical cores, {} logical processors, {:.1} GHz\n\n",
4157 parts[0],
4158 parts[1],
4159 parts[2],
4160 parts[3].parse::<f32>().unwrap_or(0.0)
4161 ));
4162 } else {
4163 out.push_str(&format!("CPU: {text}\n\n"));
4164 }
4165 }
4166
4167 let ram_script = r#"$sticks = Get-CimInstance Win32_PhysicalMemory
4169$total = ($sticks | Measure-Object Capacity -Sum).Sum / 1GB
4170$speed = ($sticks | Select-Object -First 1).Speed
4171"$([math]::Round($total,0)) GB @ $($speed) MHz ($($sticks.Count) stick(s))""#;
4172 if let Ok(o) = Command::new("powershell")
4173 .args(["-NoProfile", "-Command", ram_script])
4174 .output()
4175 {
4176 let text = String::from_utf8_lossy(&o.stdout);
4177 out.push_str(&format!("RAM: {}\n\n", text.trim().trim_matches('"')));
4178 }
4179
4180 let gpu_script = r#"Get-CimInstance Win32_VideoController | ForEach-Object {
4182 "$($_.Name)|$($_.DriverVersion)|$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
4183}"#;
4184 if let Ok(o) = Command::new("powershell")
4185 .args(["-NoProfile", "-Command", gpu_script])
4186 .output()
4187 {
4188 let text = String::from_utf8_lossy(&o.stdout);
4189 let lines: Vec<&str> = text.lines().collect();
4190 if !lines.is_empty() {
4191 out.push_str("GPU(s):\n");
4192 for line in lines.iter().filter(|l| !l.trim().is_empty()) {
4193 let parts: Vec<&str> = line.trim().split('|').collect();
4194 if parts.len() == 3 {
4195 let res = if parts[2] == "x" || parts[2].starts_with('0') {
4196 String::new()
4197 } else {
4198 format!(" — {}@display", parts[2])
4199 };
4200 out.push_str(&format!(
4201 " {}\n Driver: {}{}\n",
4202 parts[0], parts[1], res
4203 ));
4204 } else {
4205 out.push_str(&format!(" {}\n", line.trim()));
4206 }
4207 }
4208 out.push('\n');
4209 }
4210 }
4211
4212 let mb_script = r#"$mb = Get-CimInstance Win32_BaseBoard
4214$bios = Get-CimInstance Win32_BIOS
4215$cs = Get-CimInstance Win32_ComputerSystem
4216$proc = Get-CimInstance Win32_Processor | Select-Object -First 1
4217$virt = "Hypervisor: $($cs.HypervisorPresent)|SLAT: $($proc.SecondLevelAddressTranslationExtensions)"
4218"$($mb.Manufacturer.Trim()) $($mb.Product.Trim())|BIOS: $($bios.Manufacturer.Trim()) $($bios.SMBIOSBIOSVersion.Trim()) ($($bios.ReleaseDate))|$virt""#;
4219 if let Ok(o) = Command::new("powershell")
4220 .args(["-NoProfile", "-Command", mb_script])
4221 .output()
4222 {
4223 let text = String::from_utf8_lossy(&o.stdout);
4224 let text = text.trim().trim_matches('"');
4225 let parts: Vec<&str> = text.split('|').collect();
4226 if parts.len() == 4 {
4227 out.push_str(&format!(
4228 "Motherboard: {}\n{}\nVirtualization: {}, {}\n\n",
4229 parts[0].trim(),
4230 parts[1].trim(),
4231 parts[2].trim(),
4232 parts[3].trim()
4233 ));
4234 }
4235 }
4236
4237 let disp_script = r#"Get-CimInstance Win32_DesktopMonitor | Where-Object {$_.ScreenWidth -gt 0} | ForEach-Object {
4239 "$($_.Name)|$($_.ScreenWidth)x$($_.ScreenHeight)"
4240}"#;
4241 if let Ok(o) = Command::new("powershell")
4242 .args(["-NoProfile", "-Command", disp_script])
4243 .output()
4244 {
4245 let text = String::from_utf8_lossy(&o.stdout);
4246 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
4247 if !lines.is_empty() {
4248 out.push_str("Display(s):\n");
4249 for line in &lines {
4250 let parts: Vec<&str> = line.trim().split('|').collect();
4251 if parts.len() == 2 {
4252 out.push_str(&format!(" {} — {}\n", parts[0].trim(), parts[1]));
4253 }
4254 }
4255 }
4256 }
4257 }
4258
4259 #[cfg(not(target_os = "windows"))]
4260 {
4261 if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
4263 let model = content
4264 .lines()
4265 .find(|l| l.starts_with("model name"))
4266 .and_then(|l| l.split(':').nth(1))
4267 .map(str::trim)
4268 .unwrap_or("unknown");
4269 let cores = content
4270 .lines()
4271 .filter(|l| l.starts_with("processor"))
4272 .count();
4273 out.push_str(&format!("CPU: {model}\n {cores} logical processors\n\n"));
4274 }
4275
4276 if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4278 let total_kb: u64 = content
4279 .lines()
4280 .find(|l| l.starts_with("MemTotal:"))
4281 .and_then(|l| l.split_whitespace().nth(1))
4282 .and_then(|v| v.parse().ok())
4283 .unwrap_or(0);
4284 let total_gb = total_kb / 1_048_576;
4285 out.push_str(&format!("RAM: {total_gb} GB total\n\n"));
4286 }
4287
4288 if let Ok(o) = Command::new("lspci").args(["-vmm"]).output() {
4290 let text = String::from_utf8_lossy(&o.stdout);
4291 let gpu_lines: Vec<&str> = text
4292 .lines()
4293 .filter(|l| l.contains("VGA") || l.contains("Display") || l.contains("3D"))
4294 .collect();
4295 if !gpu_lines.is_empty() {
4296 out.push_str("GPU(s):\n");
4297 for l in gpu_lines {
4298 out.push_str(&format!(" {l}\n"));
4299 }
4300 out.push('\n');
4301 }
4302 }
4303
4304 if let Ok(o) = Command::new("dmidecode")
4306 .args(["-t", "baseboard", "-t", "bios"])
4307 .output()
4308 {
4309 let text = String::from_utf8_lossy(&o.stdout);
4310 out.push_str("Motherboard/BIOS:\n");
4311 for line in text
4312 .lines()
4313 .filter(|l| {
4314 l.contains("Manufacturer:")
4315 || l.contains("Product Name:")
4316 || l.contains("Version:")
4317 })
4318 .take(6)
4319 {
4320 out.push_str(&format!(" {}\n", line.trim()));
4321 }
4322 }
4323 }
4324
4325 Ok(out.trim_end().to_string())
4326}
4327
4328fn inspect_updates() -> Result<String, String> {
4331 let mut out = String::from("Host inspection: updates\n\n");
4332
4333 #[cfg(target_os = "windows")]
4334 {
4335 let script = r#"
4337try {
4338 $sess = New-Object -ComObject Microsoft.Update.Session
4339 $searcher = $sess.CreateUpdateSearcher()
4340 $count = $searcher.GetTotalHistoryCount()
4341 if ($count -gt 0) {
4342 $latest = $searcher.QueryHistory(0, 1) | Select-Object -First 1
4343 $latest.Date.ToString("yyyy-MM-dd HH:mm") + "|LAST_INSTALL"
4344 } else { "NONE|LAST_INSTALL" }
4345} catch { "ERROR:" + $_.Exception.Message + "|LAST_INSTALL" }
4346"#;
4347 if let Ok(o) = Command::new("powershell")
4348 .args(["-NoProfile", "-Command", script])
4349 .output()
4350 {
4351 let raw = String::from_utf8_lossy(&o.stdout);
4352 let text = raw.trim();
4353 if text.starts_with("ERROR:") {
4354 out.push_str("Last update install: (unable to query)\n");
4355 } else if text.contains("NONE") {
4356 out.push_str("Last update install: No update history found\n");
4357 } else {
4358 let date = text.replace("|LAST_INSTALL", "");
4359 out.push_str(&format!("Last update install: {date}\n"));
4360 }
4361 }
4362
4363 let pending_script = r#"
4365try {
4366 $sess = New-Object -ComObject Microsoft.Update.Session
4367 $searcher = $sess.CreateUpdateSearcher()
4368 $results = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
4369 $results.Updates.Count.ToString() + "|PENDING"
4370} catch { "ERROR:" + $_.Exception.Message + "|PENDING" }
4371"#;
4372 if let Ok(o) = Command::new("powershell")
4373 .args(["-NoProfile", "-Command", pending_script])
4374 .output()
4375 {
4376 let raw = String::from_utf8_lossy(&o.stdout);
4377 let text = raw.trim();
4378 if text.starts_with("ERROR:") {
4379 out.push_str("Pending updates: (unable to query via COM — try opening Windows Update manually)\n");
4380 } else {
4381 let count: i64 = text.replace("|PENDING", "").trim().parse().unwrap_or(-1);
4382 if count == 0 {
4383 out.push_str("Pending updates: Up to date — no updates waiting\n");
4384 } else if count > 0 {
4385 out.push_str(&format!("Pending updates: {count} update(s) available\n"));
4386 out.push_str(
4387 " → Open Windows Update (Settings > Windows Update) to install\n",
4388 );
4389 }
4390 }
4391 }
4392
4393 let svc_script = r#"
4395$svc = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
4396if ($svc) { $svc.Status.ToString() } else { "NOT_FOUND" }
4397"#;
4398 if let Ok(o) = Command::new("powershell")
4399 .args(["-NoProfile", "-Command", svc_script])
4400 .output()
4401 {
4402 let raw = String::from_utf8_lossy(&o.stdout);
4403 let status = raw.trim();
4404 out.push_str(&format!("Windows Update service: {status}\n"));
4405 }
4406 }
4407
4408 #[cfg(not(target_os = "windows"))]
4409 {
4410 let apt_out = Command::new("apt").args(["list", "--upgradable"]).output();
4411 let mut found = false;
4412 if let Ok(o) = apt_out {
4413 let text = String::from_utf8_lossy(&o.stdout);
4414 let lines: Vec<&str> = text
4415 .lines()
4416 .filter(|l| l.contains('/') && !l.contains("Listing"))
4417 .collect();
4418 if !lines.is_empty() {
4419 out.push_str(&format!(
4420 "{} package(s) can be upgraded (apt)\n",
4421 lines.len()
4422 ));
4423 out.push_str(" → Run: sudo apt upgrade\n");
4424 found = true;
4425 }
4426 }
4427 if !found {
4428 if let Ok(o) = Command::new("dnf")
4429 .args(["check-update", "--quiet"])
4430 .output()
4431 {
4432 let text = String::from_utf8_lossy(&o.stdout);
4433 let count = text
4434 .lines()
4435 .filter(|l| !l.is_empty() && !l.starts_with('!'))
4436 .count();
4437 if count > 0 {
4438 out.push_str(&format!("{count} package(s) can be upgraded (dnf)\n"));
4439 out.push_str(" → Run: sudo dnf upgrade\n");
4440 } else {
4441 out.push_str("System is up to date.\n");
4442 }
4443 } else {
4444 out.push_str("Could not query package manager for updates.\n");
4445 }
4446 }
4447 }
4448
4449 Ok(out.trim_end().to_string())
4450}
4451
4452fn inspect_security() -> Result<String, String> {
4455 let mut out = String::from("Host inspection: security\n\n");
4456
4457 #[cfg(target_os = "windows")]
4458 {
4459 let defender_script = r#"
4461try {
4462 $status = Get-MpComputerStatus -ErrorAction Stop
4463 "RTP:" + $status.RealTimeProtectionEnabled + "|SCAN:" + $status.QuickScanEndTime.ToString("yyyy-MM-dd HH:mm") + "|VER:" + $status.AntivirusSignatureVersion + "|AGE:" + $status.AntivirusSignatureAge
4464} catch { "ERROR:" + $_.Exception.Message }
4465"#;
4466 if let Ok(o) = Command::new("powershell")
4467 .args(["-NoProfile", "-Command", defender_script])
4468 .output()
4469 {
4470 let raw = String::from_utf8_lossy(&o.stdout);
4471 let text = raw.trim();
4472 if text.starts_with("ERROR:") {
4473 out.push_str(&format!("Windows Defender: unable to query — {text}\n"));
4474 } else {
4475 let get = |key: &str| -> String {
4476 text.split('|')
4477 .find(|s| s.starts_with(key))
4478 .and_then(|s| s.splitn(2, ':').nth(1))
4479 .unwrap_or("unknown")
4480 .to_string()
4481 };
4482 let rtp = get("RTP");
4483 let last_scan = {
4484 text.split('|')
4486 .find(|s| s.starts_with("SCAN:"))
4487 .and_then(|s| s.get(5..))
4488 .unwrap_or("unknown")
4489 .to_string()
4490 };
4491 let def_ver = get("VER");
4492 let age_days: i64 = get("AGE").parse().unwrap_or(-1);
4493
4494 let rtp_label = if rtp == "True" {
4495 "ENABLED"
4496 } else {
4497 "DISABLED [!]"
4498 };
4499 out.push_str(&format!(
4500 "Windows Defender real-time protection: {rtp_label}\n"
4501 ));
4502 out.push_str(&format!("Last quick scan: {last_scan}\n"));
4503 out.push_str(&format!("Signature version: {def_ver}\n"));
4504 if age_days >= 0 {
4505 let freshness = if age_days == 0 {
4506 "up to date".to_string()
4507 } else if age_days <= 3 {
4508 format!("{age_days} day(s) old — OK")
4509 } else if age_days <= 7 {
4510 format!("{age_days} day(s) old — consider updating")
4511 } else {
4512 format!("{age_days} day(s) old — [!] STALE, run Windows Update")
4513 };
4514 out.push_str(&format!("Signature age: {freshness}\n"));
4515 }
4516 if rtp != "True" {
4517 out.push_str(
4518 "\n[!] Real-time protection is OFF — your PC is not actively protected.\n",
4519 );
4520 out.push_str(
4521 " → Open Windows Security > Virus & threat protection to re-enable.\n",
4522 );
4523 }
4524 }
4525 }
4526
4527 out.push('\n');
4528
4529 let fw_script = r#"
4531try {
4532 Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object { $_.Name + ":" + $_.Enabled }
4533} catch { "ERROR:" + $_.Exception.Message }
4534"#;
4535 if let Ok(o) = Command::new("powershell")
4536 .args(["-NoProfile", "-Command", fw_script])
4537 .output()
4538 {
4539 let raw = String::from_utf8_lossy(&o.stdout);
4540 let text = raw.trim();
4541 if !text.starts_with("ERROR:") && !text.is_empty() {
4542 out.push_str("Windows Firewall:\n");
4543 for line in text.lines() {
4544 if let Some((name, enabled)) = line.split_once(':') {
4545 let state = if enabled.trim() == "True" {
4546 "ON"
4547 } else {
4548 "OFF [!]"
4549 };
4550 out.push_str(&format!(" {name}: {state}\n"));
4551 }
4552 }
4553 out.push('\n');
4554 }
4555 }
4556
4557 let act_script = r#"
4559try {
4560 $lic = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%' and LicenseStatus=1" -ErrorAction Stop | Select-Object -First 1
4561 if ($lic) { "ACTIVATED" } else { "NOT_ACTIVATED" }
4562} catch { "UNKNOWN" }
4563"#;
4564 if let Ok(o) = Command::new("powershell")
4565 .args(["-NoProfile", "-Command", act_script])
4566 .output()
4567 {
4568 let raw = String::from_utf8_lossy(&o.stdout);
4569 match raw.trim() {
4570 "ACTIVATED" => out.push_str("Windows activation: Activated\n"),
4571 "NOT_ACTIVATED" => out.push_str("Windows activation: [!] NOT ACTIVATED\n"),
4572 _ => out.push_str("Windows activation: Unable to determine\n"),
4573 }
4574 }
4575
4576 let uac_script = r#"
4578$val = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue
4579if ($val -eq 1) { "ON" } else { "OFF" }
4580"#;
4581 if let Ok(o) = Command::new("powershell")
4582 .args(["-NoProfile", "-Command", uac_script])
4583 .output()
4584 {
4585 let raw = String::from_utf8_lossy(&o.stdout);
4586 let state = raw.trim();
4587 let label = if state == "ON" {
4588 "Enabled"
4589 } else {
4590 "DISABLED [!] — recommended to re-enable via secpol.msc"
4591 };
4592 out.push_str(&format!("UAC (User Account Control): {label}\n"));
4593 }
4594 }
4595
4596 #[cfg(not(target_os = "windows"))]
4597 {
4598 if let Ok(o) = Command::new("ufw").arg("status").output() {
4599 let text = String::from_utf8_lossy(&o.stdout);
4600 out.push_str(&format!(
4601 "UFW: {}\n",
4602 text.lines().next().unwrap_or("unknown")
4603 ));
4604 }
4605 if let Ok(cfg) = std::fs::read_to_string("/etc/selinux/config") {
4606 if let Some(line) = cfg.lines().find(|l| l.starts_with("SELINUX=")) {
4607 out.push_str(&format!("{line}\n"));
4608 }
4609 }
4610 }
4611
4612 Ok(out.trim_end().to_string())
4613}
4614
4615fn inspect_pending_reboot() -> Result<String, String> {
4618 let mut out = String::from("Host inspection: pending_reboot\n\n");
4619
4620 #[cfg(target_os = "windows")]
4621 {
4622 let script = r#"
4623$reasons = @()
4624if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
4625 $reasons += "Windows Update requires a restart"
4626}
4627if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
4628 $reasons += "Windows component install/update requires a restart"
4629}
4630$pfro = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
4631if ($pfro -and $pfro.PendingFileRenameOperations) {
4632 $reasons += "Pending file rename operations (driver or system file replacement)"
4633}
4634if ($reasons.Count -eq 0) { "NO_REBOOT_NEEDED" } else { $reasons -join "|REASON|" }
4635"#;
4636 let output = Command::new("powershell")
4637 .args(["-NoProfile", "-Command", script])
4638 .output()
4639 .map_err(|e| format!("pending_reboot: {e}"))?;
4640
4641 let raw = String::from_utf8_lossy(&output.stdout);
4642 let text = raw.trim();
4643
4644 if text == "NO_REBOOT_NEEDED" {
4645 out.push_str("No restart required — system is up to date and stable.\n");
4646 } else if text.is_empty() {
4647 out.push_str("Could not determine reboot status.\n");
4648 } else {
4649 out.push_str("[!] A system restart is pending:\n\n");
4650 for reason in text.split("|REASON|") {
4651 out.push_str(&format!(" • {}\n", reason.trim()));
4652 }
4653 out.push_str("\nRecommendation: Save your work and restart when convenient.\n");
4654 }
4655 }
4656
4657 #[cfg(not(target_os = "windows"))]
4658 {
4659 if std::path::Path::new("/var/run/reboot-required").exists() {
4660 out.push_str("[!] A restart is required (see /var/run/reboot-required)\n");
4661 if let Ok(pkgs) = std::fs::read_to_string("/var/run/reboot-required.pkgs") {
4662 out.push_str("Packages requiring restart:\n");
4663 for p in pkgs.lines().take(10) {
4664 out.push_str(&format!(" • {p}\n"));
4665 }
4666 }
4667 } else {
4668 out.push_str("No restart required.\n");
4669 }
4670 }
4671
4672 Ok(out.trim_end().to_string())
4673}
4674
4675fn inspect_disk_health() -> Result<String, String> {
4678 let mut out = String::from("Host inspection: disk_health\n\n");
4679
4680 #[cfg(target_os = "windows")]
4681 {
4682 let script = r#"
4683try {
4684 $disks = Get-PhysicalDisk -ErrorAction Stop
4685 foreach ($d in $disks) {
4686 $size_gb = [math]::Round($d.Size / 1GB, 0)
4687 $d.FriendlyName + "|" + $d.MediaType + "|" + $size_gb + "GB|" + $d.HealthStatus + "|" + $d.OperationalStatus
4688 }
4689} catch { "ERROR:" + $_.Exception.Message }
4690"#;
4691 let output = Command::new("powershell")
4692 .args(["-NoProfile", "-Command", script])
4693 .output()
4694 .map_err(|e| format!("disk_health: {e}"))?;
4695
4696 let raw = String::from_utf8_lossy(&output.stdout);
4697 let text = raw.trim();
4698
4699 if text.starts_with("ERROR:") {
4700 out.push_str(&format!("Unable to query disk health: {text}\n"));
4701 out.push_str("This may require running as administrator.\n");
4702 } else if text.is_empty() {
4703 out.push_str("No physical disks found.\n");
4704 } else {
4705 out.push_str("Physical Drive Health:\n\n");
4706 for line in text.lines() {
4707 let parts: Vec<&str> = line.splitn(5, '|').collect();
4708 if parts.len() >= 4 {
4709 let name = parts[0];
4710 let media = parts[1];
4711 let size = parts[2];
4712 let health = parts[3];
4713 let op_status = parts.get(4).unwrap_or(&"");
4714 let health_label = match health.trim() {
4715 "Healthy" => "OK",
4716 "Warning" => "[!] WARNING",
4717 "Unhealthy" => "[!!] UNHEALTHY — BACK UP YOUR DATA NOW",
4718 other => other,
4719 };
4720 out.push_str(&format!(" {name}\n"));
4721 out.push_str(&format!(" Type: {media} | Size: {size}\n"));
4722 out.push_str(&format!(" Health: {health_label}\n"));
4723 if !op_status.is_empty() {
4724 out.push_str(&format!(" Status: {op_status}\n"));
4725 }
4726 out.push('\n');
4727 }
4728 }
4729 }
4730
4731 let smart_script = r#"
4733try {
4734 Get-WmiObject -Class MSStorageDriver_FailurePredictStatus -Namespace root\wmi -ErrorAction Stop |
4735 ForEach-Object { $_.InstanceName + "|" + $_.PredictFailure }
4736} catch { "" }
4737"#;
4738 if let Ok(o) = Command::new("powershell")
4739 .args(["-NoProfile", "-Command", smart_script])
4740 .output()
4741 {
4742 let raw2 = String::from_utf8_lossy(&o.stdout);
4743 let text2 = raw2.trim();
4744 if !text2.is_empty() {
4745 let failures: Vec<&str> = text2.lines().filter(|l| l.contains("|True")).collect();
4746 if failures.is_empty() {
4747 out.push_str("SMART failure prediction: No failures predicted\n");
4748 } else {
4749 out.push_str("[!!] SMART failure predicted on one or more drives:\n");
4750 for f in failures {
4751 let name = f.split('|').next().unwrap_or(f);
4752 out.push_str(&format!(" • {name}\n"));
4753 }
4754 out.push_str(
4755 "\nBack up your data immediately and replace the failing drive.\n",
4756 );
4757 }
4758 }
4759 }
4760 }
4761
4762 #[cfg(not(target_os = "windows"))]
4763 {
4764 if let Ok(o) = Command::new("lsblk")
4765 .args(["-d", "-o", "NAME,SIZE,TYPE,ROTA,MODEL"])
4766 .output()
4767 {
4768 let text = String::from_utf8_lossy(&o.stdout);
4769 out.push_str("Block devices:\n");
4770 out.push_str(text.trim());
4771 out.push('\n');
4772 }
4773 if let Ok(scan) = Command::new("smartctl").args(["--scan"]).output() {
4774 let devices = String::from_utf8_lossy(&scan.stdout);
4775 for dev_line in devices.lines().take(4) {
4776 let dev = dev_line.split_whitespace().next().unwrap_or("");
4777 if dev.is_empty() {
4778 continue;
4779 }
4780 if let Ok(o) = Command::new("smartctl").args(["-H", dev]).output() {
4781 let health = String::from_utf8_lossy(&o.stdout);
4782 if let Some(line) = health.lines().find(|l| l.contains("SMART overall-health"))
4783 {
4784 out.push_str(&format!("{dev}: {}\n", line.trim()));
4785 }
4786 }
4787 }
4788 } else {
4789 out.push_str("(install smartmontools for SMART health data)\n");
4790 }
4791 }
4792
4793 Ok(out.trim_end().to_string())
4794}
4795
4796fn inspect_battery() -> Result<String, String> {
4799 let mut out = String::from("Host inspection: battery\n\n");
4800
4801 #[cfg(target_os = "windows")]
4802 {
4803 let script = r#"
4804try {
4805 $bats = Get-CimInstance -ClassName Win32_Battery -ErrorAction Stop
4806 if (-not $bats) { "NO_BATTERY"; exit }
4807 foreach ($b in $bats) {
4808 $status = switch ($b.BatteryStatus) {
4809 1 { "Discharging (on battery)" }
4810 2 { "AC power - fully charged" }
4811 3 { "AC power - charging" }
4812 6 { "AC power - charging" }
4813 7 { "AC power - charging" }
4814 default { "Status $($b.BatteryStatus)" }
4815 }
4816 $b.Name + "|" + $b.EstimatedChargeRemaining + "|" + $status + "|" + $b.EstimatedRunTime
4817 }
4818} catch { "ERROR:" + $_.Exception.Message }
4819"#;
4820 let output = Command::new("powershell")
4821 .args(["-NoProfile", "-Command", script])
4822 .output()
4823 .map_err(|e| format!("battery: {e}"))?;
4824
4825 let raw = String::from_utf8_lossy(&output.stdout);
4826 let text = raw.trim();
4827
4828 if text == "NO_BATTERY" {
4829 out.push_str("No battery detected — desktop or AC-only system.\n");
4830 return Ok(out.trim_end().to_string());
4831 }
4832 if text.starts_with("ERROR:") {
4833 out.push_str(&format!("Unable to query battery: {text}\n"));
4834 return Ok(out.trim_end().to_string());
4835 }
4836
4837 for line in text.lines() {
4838 let parts: Vec<&str> = line.splitn(4, '|').collect();
4839 if parts.len() >= 3 {
4840 let name = parts[0];
4841 let charge: i64 = parts[1].parse().unwrap_or(-1);
4842 let status = parts[2];
4843 let time_rem: i64 = parts.get(3).and_then(|v| v.parse().ok()).unwrap_or(-1);
4844
4845 out.push_str(&format!("Battery: {name}\n"));
4846 if charge >= 0 {
4847 let bar_filled = (charge as usize * 20) / 100;
4848 out.push_str(&format!(
4849 " Charge: [{}{}] {}%\n",
4850 "#".repeat(bar_filled),
4851 ".".repeat(20 - bar_filled),
4852 charge
4853 ));
4854 }
4855 out.push_str(&format!(" Status: {status}\n"));
4856 if time_rem > 0 && time_rem < 71_582_788 {
4858 let hours = time_rem / 60;
4859 let mins = time_rem % 60;
4860 out.push_str(&format!(" Estimated time remaining: {hours}h {mins}m\n"));
4861 }
4862 out.push('\n');
4863 }
4864 }
4865
4866 let wear_script = r#"
4868try {
4869 $full = Get-CimInstance -Namespace root\cimv2 -ClassName BatteryFullChargedCapacity -ErrorAction Stop | Select-Object -First 1
4870 $static = Get-CimInstance -Namespace root\cimv2 -ClassName BatteryStaticData -ErrorAction Stop | Select-Object -First 1
4871 if ($full -and $static -and $static.DesignedCapacity -gt 0) {
4872 $pct = [math]::Round(($full.FullChargedCapacity / $static.DesignedCapacity) * 100, 1)
4873 $full.FullChargedCapacity.ToString() + "|" + $static.DesignedCapacity.ToString() + "|" + $pct.ToString()
4874 } else { "UNKNOWN" }
4875} catch { "UNKNOWN" }
4876"#;
4877 if let Ok(o) = Command::new("powershell")
4878 .args(["-NoProfile", "-Command", wear_script])
4879 .output()
4880 {
4881 let raw2 = String::from_utf8_lossy(&o.stdout);
4882 let t = raw2.trim();
4883 if t != "UNKNOWN" && !t.is_empty() {
4884 let parts: Vec<&str> = t.splitn(3, '|').collect();
4885 if parts.len() == 3 {
4886 let full: i64 = parts[0].parse().unwrap_or(0);
4887 let design: i64 = parts[1].parse().unwrap_or(0);
4888 let pct: f64 = parts[2].parse().unwrap_or(0.0);
4889 out.push_str(&format!(
4890 "Battery wear level: {pct:.1}% of original capacity\n"
4891 ));
4892 out.push_str(&format!(
4893 " Current full charge: {full} mWh / Design: {design} mWh\n"
4894 ));
4895 if pct < 50.0 {
4896 out.push_str(" [!] Significantly degraded — consider replacement\n");
4897 } else if pct < 75.0 {
4898 out.push_str(" [-] Noticeable wear\n");
4899 } else {
4900 out.push_str(" Battery health is good\n");
4901 }
4902 }
4903 }
4904 }
4905 }
4906
4907 #[cfg(not(target_os = "windows"))]
4908 {
4909 let power_path = std::path::Path::new("/sys/class/power_supply");
4910 let mut found = false;
4911 if power_path.exists() {
4912 if let Ok(entries) = std::fs::read_dir(power_path) {
4913 for entry in entries.flatten() {
4914 let p = entry.path();
4915 if let Ok(t) = std::fs::read_to_string(p.join("type")) {
4916 if t.trim() == "Battery" {
4917 found = true;
4918 let name = p
4919 .file_name()
4920 .unwrap_or_default()
4921 .to_string_lossy()
4922 .to_string();
4923 out.push_str(&format!("Battery: {name}\n"));
4924 let read = |f: &str| {
4925 std::fs::read_to_string(p.join(f))
4926 .ok()
4927 .map(|s| s.trim().to_string())
4928 };
4929 if let Some(cap) = read("capacity") {
4930 out.push_str(&format!(" Charge: {cap}%\n"));
4931 }
4932 if let Some(status) = read("status") {
4933 out.push_str(&format!(" Status: {status}\n"));
4934 }
4935 if let (Some(full), Some(design)) =
4936 (read("energy_full"), read("energy_full_design"))
4937 {
4938 if let (Ok(f), Ok(d)) = (full.parse::<f64>(), design.parse::<f64>())
4939 {
4940 if d > 0.0 {
4941 out.push_str(&format!(
4942 " Wear level: {:.1}% of design capacity\n",
4943 (f / d) * 100.0
4944 ));
4945 }
4946 }
4947 }
4948 }
4949 }
4950 }
4951 }
4952 }
4953 if !found {
4954 out.push_str("No battery found.\n");
4955 }
4956 }
4957
4958 Ok(out.trim_end().to_string())
4959}
4960
4961fn inspect_recent_crashes(max_entries: usize) -> Result<String, String> {
4964 let mut out = String::from("Host inspection: recent_crashes\n\n");
4965 let n = max_entries.clamp(1, 30);
4966
4967 #[cfg(target_os = "windows")]
4968 {
4969 let bsod_script = format!(
4971 r#"
4972try {{
4973 $events = Get-WinEvent -FilterHashtable @{{LogName='System'; Id=41,1001}} -MaxEvents {n} -ErrorAction SilentlyContinue
4974 if ($events) {{
4975 $events | ForEach-Object {{
4976 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + $_.Id + "|" + (($_.Message -split "[\r\n]")[0].Trim())
4977 }}
4978 }} else {{ "NO_BSOD" }}
4979}} catch {{ "ERROR:" + $_.Exception.Message }}"#
4980 );
4981
4982 if let Ok(o) = Command::new("powershell")
4983 .args(["-NoProfile", "-Command", &bsod_script])
4984 .output()
4985 {
4986 let raw = String::from_utf8_lossy(&o.stdout);
4987 let text = raw.trim();
4988 if text == "NO_BSOD" {
4989 out.push_str("System crashes (BSOD/kernel): None in recent history\n");
4990 } else if text.starts_with("ERROR:") {
4991 out.push_str("System crashes: unable to query\n");
4992 } else {
4993 out.push_str("System crashes / unexpected shutdowns:\n");
4994 for line in text.lines() {
4995 let parts: Vec<&str> = line.splitn(3, '|').collect();
4996 if parts.len() >= 3 {
4997 let time = parts[0];
4998 let id = parts[1];
4999 let msg = parts[2];
5000 let label = if id == "41" {
5001 "Unexpected shutdown"
5002 } else {
5003 "BSOD (BugCheck)"
5004 };
5005 out.push_str(&format!(" [{time}] {label}: {msg}\n"));
5006 }
5007 }
5008 out.push('\n');
5009 }
5010 }
5011
5012 let app_script = format!(
5014 r#"
5015try {{
5016 $crashes = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue
5017 if ($crashes) {{
5018 $crashes | ForEach-Object {{
5019 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + (($_.Message -split "[\r\n]")[0].Trim())
5020 }}
5021 }} else {{ "NO_CRASHES" }}
5022}} catch {{ "ERROR_APP:" + $_.Exception.Message }}"#
5023 );
5024
5025 if let Ok(o) = Command::new("powershell")
5026 .args(["-NoProfile", "-Command", &app_script])
5027 .output()
5028 {
5029 let raw = String::from_utf8_lossy(&o.stdout);
5030 let text = raw.trim();
5031 if text == "NO_CRASHES" {
5032 out.push_str("Application crashes: None in recent history\n");
5033 } else if text.starts_with("ERROR_APP:") {
5034 out.push_str("Application crashes: unable to query\n");
5035 } else {
5036 out.push_str("Application crashes:\n");
5037 for line in text.lines().take(n) {
5038 let parts: Vec<&str> = line.splitn(2, '|').collect();
5039 if parts.len() >= 2 {
5040 out.push_str(&format!(" [{}] {}\n", parts[0], parts[1]));
5041 }
5042 }
5043 }
5044 }
5045 }
5046
5047 #[cfg(not(target_os = "windows"))]
5048 {
5049 let n_str = n.to_string();
5050 if let Ok(o) = Command::new("journalctl")
5051 .args(["-k", "--no-pager", "-n", &n_str, "-p", "0..2"])
5052 .output()
5053 {
5054 let text = String::from_utf8_lossy(&o.stdout);
5055 let trimmed = text.trim();
5056 if trimmed.is_empty() || trimmed.contains("No entries") {
5057 out.push_str("No kernel panics or critical crashes found.\n");
5058 } else {
5059 out.push_str("Kernel critical events:\n");
5060 out.push_str(trimmed);
5061 out.push('\n');
5062 }
5063 }
5064 if let Ok(o) = Command::new("coredumpctl")
5065 .args(["list", "--no-pager"])
5066 .output()
5067 {
5068 let text = String::from_utf8_lossy(&o.stdout);
5069 let count = text
5070 .lines()
5071 .filter(|l| !l.trim().is_empty() && !l.starts_with("TIME"))
5072 .count();
5073 if count > 0 {
5074 out.push_str(&format!(
5075 "\nCore dumps on file: {count}\n → Run: coredumpctl list\n"
5076 ));
5077 }
5078 }
5079 }
5080
5081 Ok(out.trim_end().to_string())
5082}
5083
5084fn inspect_scheduled_tasks(max_entries: usize) -> Result<String, String> {
5087 let mut out = String::from("Host inspection: scheduled_tasks\n\n");
5088 let n = max_entries.clamp(1, 30);
5089
5090 #[cfg(target_os = "windows")]
5091 {
5092 let script = format!(
5093 r#"
5094try {{
5095 $tasks = Get-ScheduledTask -ErrorAction Stop |
5096 Where-Object {{ $_.State -ne 'Disabled' }} |
5097 ForEach-Object {{
5098 $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
5099 $lastRun = if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {{
5100 $info.LastRunTime.ToString("yyyy-MM-dd HH:mm")
5101 }} else {{ "never" }}
5102 $exec = ($_.Actions | Select-Object -First 1).Execute
5103 if (-not $exec) {{ $exec = "(no exec)" }}
5104 $_.TaskName + "|" + $_.TaskPath + "|" + $_.State + "|" + $lastRun + "|" + $exec
5105 }}
5106 $tasks | Select-Object -First {n}
5107}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5108 );
5109
5110 let output = Command::new("powershell")
5111 .args(["-NoProfile", "-Command", &script])
5112 .output()
5113 .map_err(|e| format!("scheduled_tasks: {e}"))?;
5114
5115 let raw = String::from_utf8_lossy(&output.stdout);
5116 let text = raw.trim();
5117
5118 if text.starts_with("ERROR:") {
5119 out.push_str(&format!("Unable to query scheduled tasks: {text}\n"));
5120 } else if text.is_empty() {
5121 out.push_str("No active scheduled tasks found.\n");
5122 } else {
5123 out.push_str(&format!("Active scheduled tasks (up to {n}):\n\n"));
5124 for line in text.lines() {
5125 let parts: Vec<&str> = line.splitn(5, '|').collect();
5126 if parts.len() >= 4 {
5127 let name = parts[0];
5128 let path = parts[1];
5129 let state = parts[2];
5130 let last = parts[3];
5131 let exec = parts.get(4).unwrap_or(&"").trim();
5132 let display_path = path.trim_matches('\\');
5133 let display_path = if display_path.is_empty() {
5134 "Root"
5135 } else {
5136 display_path
5137 };
5138 out.push_str(&format!(" {name} [{display_path}]\n"));
5139 out.push_str(&format!(" State: {state} | Last run: {last}\n"));
5140 if !exec.is_empty() && exec != "(no exec)" {
5141 let short = if exec.len() > 80 { &exec[..80] } else { exec };
5142 out.push_str(&format!(" Runs: {short}\n"));
5143 }
5144 }
5145 }
5146 }
5147 }
5148
5149 #[cfg(not(target_os = "windows"))]
5150 {
5151 if let Ok(o) = Command::new("systemctl")
5152 .args(["list-timers", "--no-pager", "--all"])
5153 .output()
5154 {
5155 let text = String::from_utf8_lossy(&o.stdout);
5156 out.push_str("Systemd timers:\n");
5157 for l in text
5158 .lines()
5159 .filter(|l| {
5160 !l.trim().is_empty() && !l.starts_with("NEXT") && !l.starts_with("timers")
5161 })
5162 .take(n)
5163 {
5164 out.push_str(&format!(" {l}\n"));
5165 }
5166 out.push('\n');
5167 }
5168 if let Ok(o) = Command::new("crontab").arg("-l").output() {
5169 let text = String::from_utf8_lossy(&o.stdout);
5170 let jobs: Vec<&str> = text
5171 .lines()
5172 .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
5173 .collect();
5174 if !jobs.is_empty() {
5175 out.push_str("User crontab:\n");
5176 for j in jobs.iter().take(n) {
5177 out.push_str(&format!(" {j}\n"));
5178 }
5179 }
5180 }
5181 }
5182
5183 Ok(out.trim_end().to_string())
5184}
5185
5186fn inspect_dev_conflicts() -> Result<String, String> {
5189 let mut out = String::from("Host inspection: dev_conflicts\n\n");
5190 let mut conflicts: Vec<String> = Vec::new();
5191 let mut notes: Vec<String> = Vec::new();
5192
5193 {
5195 let node_ver = Command::new("node")
5196 .arg("--version")
5197 .output()
5198 .ok()
5199 .and_then(|o| String::from_utf8(o.stdout).ok())
5200 .map(|s| s.trim().to_string());
5201 let nvm_active = Command::new("nvm")
5202 .arg("current")
5203 .output()
5204 .ok()
5205 .and_then(|o| String::from_utf8(o.stdout).ok())
5206 .map(|s| s.trim().to_string())
5207 .filter(|s| !s.is_empty() && !s.contains("none") && !s.contains("No current"));
5208 let fnm_active = Command::new("fnm")
5209 .arg("current")
5210 .output()
5211 .ok()
5212 .and_then(|o| String::from_utf8(o.stdout).ok())
5213 .map(|s| s.trim().to_string())
5214 .filter(|s| !s.is_empty() && !s.contains("none"));
5215 let volta_active = Command::new("volta")
5216 .args(["which", "node"])
5217 .output()
5218 .ok()
5219 .and_then(|o| String::from_utf8(o.stdout).ok())
5220 .map(|s| s.trim().to_string())
5221 .filter(|s| !s.is_empty());
5222
5223 out.push_str("Node.js:\n");
5224 if let Some(ref v) = node_ver {
5225 out.push_str(&format!(" Active: {v}\n"));
5226 } else {
5227 out.push_str(" Not installed\n");
5228 }
5229 let managers: Vec<&str> = [
5230 nvm_active.as_deref(),
5231 fnm_active.as_deref(),
5232 volta_active.as_deref(),
5233 ]
5234 .iter()
5235 .filter_map(|x| *x)
5236 .collect();
5237 if managers.len() > 1 {
5238 conflicts.push(format!(
5239 "Multiple Node.js version managers detected (nvm/fnm/volta). Only one should be active to avoid PATH conflicts."
5240 ));
5241 } else if !managers.is_empty() {
5242 out.push_str(&format!(" Version manager: {}\n", managers[0]));
5243 }
5244 out.push('\n');
5245 }
5246
5247 {
5249 let py3 = Command::new("python3")
5250 .arg("--version")
5251 .output()
5252 .ok()
5253 .and_then(|o| {
5254 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
5255 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
5256 let v = if stdout.is_empty() { stderr } else { stdout };
5257 if v.is_empty() {
5258 None
5259 } else {
5260 Some(v)
5261 }
5262 });
5263 let py = Command::new("python")
5264 .arg("--version")
5265 .output()
5266 .ok()
5267 .and_then(|o| {
5268 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
5269 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
5270 let v = if stdout.is_empty() { stderr } else { stdout };
5271 if v.is_empty() {
5272 None
5273 } else {
5274 Some(v)
5275 }
5276 });
5277 let pyenv = Command::new("pyenv")
5278 .arg("version")
5279 .output()
5280 .ok()
5281 .and_then(|o| String::from_utf8(o.stdout).ok())
5282 .map(|s| s.trim().to_string())
5283 .filter(|s| !s.is_empty());
5284 let conda_env = std::env::var("CONDA_DEFAULT_ENV").ok();
5285
5286 out.push_str("Python:\n");
5287 match (&py3, &py) {
5288 (Some(v3), Some(v)) if v3 != v => {
5289 out.push_str(&format!(" python3: {v3}\n python: {v}\n"));
5290 if v.contains("2.") {
5291 conflicts.push(
5292 "python and python3 point to different major versions (2.x vs 3.x). Scripts using 'python' may break unexpectedly.".to_string()
5293 );
5294 } else {
5295 notes.push(
5296 "python and python3 resolve to different minor versions.".to_string(),
5297 );
5298 }
5299 }
5300 (Some(v3), None) => out.push_str(&format!(" python3: {v3}\n")),
5301 (None, Some(v)) => out.push_str(&format!(" python: {v}\n")),
5302 (Some(v3), Some(_)) => out.push_str(&format!(" {v3}\n")),
5303 (None, None) => out.push_str(" Not installed\n"),
5304 }
5305 if let Some(ref pe) = pyenv {
5306 out.push_str(&format!(" pyenv: {pe}\n"));
5307 }
5308 if let Some(env) = conda_env {
5309 if env == "base" {
5310 notes.push("Conda base environment is active — may shadow system Python. Run 'conda deactivate' if unexpected.".to_string());
5311 } else {
5312 out.push_str(&format!(" conda env: {env}\n"));
5313 }
5314 }
5315 out.push('\n');
5316 }
5317
5318 {
5320 let toolchain = Command::new("rustup")
5321 .args(["show", "active-toolchain"])
5322 .output()
5323 .ok()
5324 .and_then(|o| String::from_utf8(o.stdout).ok())
5325 .map(|s| s.trim().to_string())
5326 .filter(|s| !s.is_empty());
5327 let cargo_ver = Command::new("cargo")
5328 .arg("--version")
5329 .output()
5330 .ok()
5331 .and_then(|o| String::from_utf8(o.stdout).ok())
5332 .map(|s| s.trim().to_string());
5333 let rustc_ver = Command::new("rustc")
5334 .arg("--version")
5335 .output()
5336 .ok()
5337 .and_then(|o| String::from_utf8(o.stdout).ok())
5338 .map(|s| s.trim().to_string());
5339
5340 out.push_str("Rust:\n");
5341 if let Some(ref t) = toolchain {
5342 out.push_str(&format!(" Active toolchain: {t}\n"));
5343 }
5344 if let Some(ref c) = cargo_ver {
5345 out.push_str(&format!(" {c}\n"));
5346 }
5347 if let Some(ref r) = rustc_ver {
5348 out.push_str(&format!(" {r}\n"));
5349 }
5350 if cargo_ver.is_none() && rustc_ver.is_none() {
5351 out.push_str(" Not installed\n");
5352 }
5353
5354 #[cfg(not(target_os = "windows"))]
5356 if let Ok(o) = Command::new("which").arg("rustc").output() {
5357 let path = String::from_utf8_lossy(&o.stdout).trim().to_string();
5358 if !path.is_empty() && !path.contains(".cargo") && !path.contains("rustup") {
5359 conflicts.push(format!(
5360 "rustc found at non-rustup path '{path}' — may conflict with rustup-managed toolchain"
5361 ));
5362 }
5363 }
5364 out.push('\n');
5365 }
5366
5367 {
5369 let git_ver = Command::new("git")
5370 .arg("--version")
5371 .output()
5372 .ok()
5373 .and_then(|o| String::from_utf8(o.stdout).ok())
5374 .map(|s| s.trim().to_string());
5375 out.push_str("Git:\n");
5376 if let Some(ref v) = git_ver {
5377 out.push_str(&format!(" {v}\n"));
5378 let email = Command::new("git")
5379 .args(["config", "--global", "user.email"])
5380 .output()
5381 .ok()
5382 .and_then(|o| String::from_utf8(o.stdout).ok())
5383 .map(|s| s.trim().to_string());
5384 if let Some(ref e) = email {
5385 if e.is_empty() {
5386 notes.push("Git user.email is not configured globally — commits may fail or use wrong identity.".to_string());
5387 } else {
5388 out.push_str(&format!(" user.email: {e}\n"));
5389 }
5390 }
5391 let gpg_sign = Command::new("git")
5392 .args(["config", "--global", "commit.gpgsign"])
5393 .output()
5394 .ok()
5395 .and_then(|o| String::from_utf8(o.stdout).ok())
5396 .map(|s| s.trim().to_string());
5397 if gpg_sign.as_deref() == Some("true") {
5398 let key = Command::new("git")
5399 .args(["config", "--global", "user.signingkey"])
5400 .output()
5401 .ok()
5402 .and_then(|o| String::from_utf8(o.stdout).ok())
5403 .map(|s| s.trim().to_string());
5404 if key.as_deref().map(|k| k.is_empty()).unwrap_or(true) {
5405 conflicts.push("Git commit signing is enabled but no signing key is configured — commits will fail.".to_string());
5406 }
5407 }
5408 } else {
5409 out.push_str(" Not installed\n");
5410 }
5411 out.push('\n');
5412 }
5413
5414 {
5416 let path_env = std::env::var("PATH").unwrap_or_default();
5417 let sep = if cfg!(windows) { ';' } else { ':' };
5418 let mut seen = HashSet::new();
5419 let mut dupes: Vec<String> = Vec::new();
5420 for p in path_env.split(sep) {
5421 let norm = p.trim().to_lowercase();
5422 if !norm.is_empty() && !seen.insert(norm) {
5423 dupes.push(p.to_string());
5424 }
5425 }
5426 if !dupes.is_empty() {
5427 let shown: Vec<&str> = dupes.iter().take(3).map(|s| s.as_str()).collect();
5428 notes.push(format!(
5429 "Duplicate PATH entries: {} {}",
5430 shown.join(", "),
5431 if dupes.len() > 3 {
5432 format!("+{} more", dupes.len() - 3)
5433 } else {
5434 String::new()
5435 }
5436 ));
5437 }
5438 }
5439
5440 if conflicts.is_empty() && notes.is_empty() {
5442 out.push_str("No conflicts detected — dev environment looks clean.\n");
5443 } else {
5444 if !conflicts.is_empty() {
5445 out.push_str("CONFLICTS:\n");
5446 for c in &conflicts {
5447 out.push_str(&format!(" [!] {c}\n"));
5448 }
5449 out.push('\n');
5450 }
5451 if !notes.is_empty() {
5452 out.push_str("NOTES:\n");
5453 for n in ¬es {
5454 out.push_str(&format!(" [-] {n}\n"));
5455 }
5456 }
5457 }
5458
5459 Ok(out.trim_end().to_string())
5460}
5461
5462fn inspect_connectivity() -> Result<String, String> {
5465 let mut out = String::from("Host inspection: connectivity\n\n");
5466
5467 #[cfg(target_os = "windows")]
5468 {
5469 let inet_script = r#"
5470try {
5471 $r = Test-NetConnection -ComputerName 8.8.8.8 -Port 53 -InformationLevel Quiet -WarningAction SilentlyContinue
5472 if ($r) { "REACHABLE" } else { "UNREACHABLE" }
5473} catch { "ERROR:" + $_.Exception.Message }
5474"#;
5475 if let Ok(o) = Command::new("powershell")
5476 .args(["-NoProfile", "-Command", inet_script])
5477 .output()
5478 {
5479 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5480 match text.as_str() {
5481 "REACHABLE" => out.push_str("Internet: reachable\n"),
5482 "UNREACHABLE" => out.push_str("Internet: unreachable [!]\n"),
5483 _ => out.push_str(&format!(
5484 "Internet: {}\n",
5485 text.trim_start_matches("ERROR:").trim()
5486 )),
5487 }
5488 }
5489
5490 let dns_script = r#"
5491try {
5492 Resolve-DnsName -Name "dns.google" -Type A -ErrorAction Stop | Out-Null
5493 "DNS:ok"
5494} catch { "DNS:fail:" + $_.Exception.Message }
5495"#;
5496 if let Ok(o) = Command::new("powershell")
5497 .args(["-NoProfile", "-Command", dns_script])
5498 .output()
5499 {
5500 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5501 if text == "DNS:ok" {
5502 out.push_str("DNS: resolving correctly\n");
5503 } else {
5504 let detail = text.trim_start_matches("DNS:fail:").trim();
5505 out.push_str(&format!("DNS: failed — {}\n", detail));
5506 }
5507 }
5508
5509 let gw_script = r#"
5510(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop
5511"#;
5512 if let Ok(o) = Command::new("powershell")
5513 .args(["-NoProfile", "-Command", gw_script])
5514 .output()
5515 {
5516 let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
5517 if !gw.is_empty() && gw != "0.0.0.0" {
5518 out.push_str(&format!("Default gateway: {}\n", gw));
5519 }
5520 }
5521 }
5522
5523 #[cfg(not(target_os = "windows"))]
5524 {
5525 let reachable = Command::new("ping")
5526 .args(["-c", "1", "-W", "2", "8.8.8.8"])
5527 .output()
5528 .map(|o| o.status.success())
5529 .unwrap_or(false);
5530 out.push_str(if reachable {
5531 "Internet: reachable\n"
5532 } else {
5533 "Internet: unreachable\n"
5534 });
5535 let dns_ok = Command::new("getent")
5536 .args(["hosts", "dns.google"])
5537 .output()
5538 .map(|o| o.status.success())
5539 .unwrap_or(false);
5540 out.push_str(if dns_ok {
5541 "DNS: resolving correctly\n"
5542 } else {
5543 "DNS: failed\n"
5544 });
5545 if let Ok(o) = Command::new("ip")
5546 .args(["route", "show", "default"])
5547 .output()
5548 {
5549 let text = String::from_utf8_lossy(&o.stdout);
5550 if let Some(line) = text.lines().next() {
5551 out.push_str(&format!("Default gateway: {}\n", line.trim()));
5552 }
5553 }
5554 }
5555
5556 Ok(out.trim_end().to_string())
5557}
5558
5559fn inspect_wifi() -> Result<String, String> {
5562 let mut out = String::from("Host inspection: wifi\n\n");
5563
5564 #[cfg(target_os = "windows")]
5565 {
5566 let output = Command::new("netsh")
5567 .args(["wlan", "show", "interfaces"])
5568 .output()
5569 .map_err(|e| format!("wifi: {e}"))?;
5570 let text = String::from_utf8_lossy(&output.stdout).to_string();
5571
5572 if text.contains("There is no wireless interface") || text.trim().is_empty() {
5573 out.push_str("No wireless interface detected on this machine.\n");
5574 return Ok(out.trim_end().to_string());
5575 }
5576
5577 let fields = [
5578 ("SSID", "SSID"),
5579 ("State", "State"),
5580 ("Signal", "Signal"),
5581 ("Radio type", "Radio type"),
5582 ("Channel", "Channel"),
5583 ("Receive rate (Mbps)", "Download speed (Mbps)"),
5584 ("Transmit rate (Mbps)", "Upload speed (Mbps)"),
5585 ("Authentication", "Authentication"),
5586 ("Network type", "Network type"),
5587 ];
5588
5589 let mut any = false;
5590 for line in text.lines() {
5591 let trimmed = line.trim();
5592 for (key, label) in &fields {
5593 if trimmed.starts_with(key) && trimmed.contains(':') {
5594 let val = trimmed.splitn(2, ':').nth(1).unwrap_or("").trim();
5595 if !val.is_empty() {
5596 out.push_str(&format!(" {label}: {val}\n"));
5597 any = true;
5598 }
5599 }
5600 }
5601 }
5602 if !any {
5603 out.push_str(" (Wi-Fi adapter disconnected or no active connection)\n");
5604 }
5605 }
5606
5607 #[cfg(not(target_os = "windows"))]
5608 {
5609 if let Ok(o) = Command::new("nmcli")
5610 .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
5611 .output()
5612 {
5613 let text = String::from_utf8_lossy(&o.stdout).to_string();
5614 let lines: Vec<&str> = text.lines().filter(|l| l.contains(":wifi:")).collect();
5615 if lines.is_empty() {
5616 out.push_str("No Wi-Fi devices found.\n");
5617 } else {
5618 for l in lines {
5619 out.push_str(&format!(" {l}\n"));
5620 }
5621 }
5622 } else if let Ok(o) = Command::new("iwconfig").output() {
5623 let text = String::from_utf8_lossy(&o.stdout).to_string();
5624 if !text.trim().is_empty() {
5625 out.push_str(text.trim());
5626 out.push('\n');
5627 }
5628 } else {
5629 out.push_str("No wireless tool available (install nmcli or wireless-tools).\n");
5630 }
5631 }
5632
5633 Ok(out.trim_end().to_string())
5634}
5635
5636fn inspect_connections(max_entries: usize) -> Result<String, String> {
5639 let mut out = String::from("Host inspection: connections\n\n");
5640 let n = max_entries.clamp(1, 25);
5641
5642 #[cfg(target_os = "windows")]
5643 {
5644 let script = format!(
5645 r#"
5646try {{
5647 $procs = @{{}}
5648 Get-Process -ErrorAction SilentlyContinue | ForEach-Object {{ $procs[$_.Id] = $_.Name }}
5649 $all = Get-NetTCPConnection -State Established -ErrorAction Stop |
5650 Sort-Object RemoteAddress
5651 "TOTAL:" + $all.Count
5652 $all | Select-Object -First {n} | ForEach-Object {{
5653 $pname = if ($procs.ContainsKey($_.OwningProcess)) {{ $procs[$_.OwningProcess] }} else {{ "pid:" + $_.OwningProcess }}
5654 $pname + "|" + $_.LocalAddress + ":" + $_.LocalPort + "|" + $_.RemoteAddress + ":" + $_.RemotePort
5655 }}
5656}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5657 );
5658
5659 let output = Command::new("powershell")
5660 .args(["-NoProfile", "-Command", &script])
5661 .output()
5662 .map_err(|e| format!("connections: {e}"))?;
5663
5664 let raw = String::from_utf8_lossy(&output.stdout);
5665 let text = raw.trim();
5666
5667 if text.starts_with("ERROR:") {
5668 out.push_str(&format!("Unable to query connections: {text}\n"));
5669 } else {
5670 let mut total = 0usize;
5671 let mut rows = Vec::new();
5672 for line in text.lines() {
5673 if let Some(rest) = line.strip_prefix("TOTAL:") {
5674 total = rest.trim().parse().unwrap_or(0);
5675 } else {
5676 rows.push(line);
5677 }
5678 }
5679 out.push_str(&format!("Established TCP connections: {total}\n\n"));
5680 for row in &rows {
5681 let parts: Vec<&str> = row.splitn(3, '|').collect();
5682 if parts.len() == 3 {
5683 out.push_str(&format!(" {} | {} → {}\n", parts[0], parts[1], parts[2]));
5684 }
5685 }
5686 if total > n {
5687 out.push_str(&format!(
5688 "\n ... {} more connections not shown\n",
5689 total.saturating_sub(n)
5690 ));
5691 }
5692 }
5693 }
5694
5695 #[cfg(not(target_os = "windows"))]
5696 {
5697 if let Ok(o) = Command::new("ss")
5698 .args(["-tnp", "state", "established"])
5699 .output()
5700 {
5701 let text = String::from_utf8_lossy(&o.stdout);
5702 let lines: Vec<&str> = text
5703 .lines()
5704 .skip(1)
5705 .filter(|l| !l.trim().is_empty())
5706 .collect();
5707 out.push_str(&format!("Established TCP connections: {}\n\n", lines.len()));
5708 for line in lines.iter().take(n) {
5709 out.push_str(&format!(" {}\n", line.trim()));
5710 }
5711 if lines.len() > n {
5712 out.push_str(&format!("\n ... {} more not shown\n", lines.len() - n));
5713 }
5714 } else {
5715 out.push_str("ss not available — install iproute2\n");
5716 }
5717 }
5718
5719 Ok(out.trim_end().to_string())
5720}
5721
5722fn inspect_vpn() -> Result<String, String> {
5725 let mut out = String::from("Host inspection: vpn\n\n");
5726
5727 #[cfg(target_os = "windows")]
5728 {
5729 let script = r#"
5730try {
5731 $vpn = Get-NetAdapter -ErrorAction Stop | Where-Object {
5732 $_.InterfaceDescription -match 'VPN|TAP|WireGuard|OpenVPN|Cisco|Palo Alto|GlobalProtect|Juniper|Pulse|NordVPN|ExpressVPN|Mullvad|ProtonVPN' -or
5733 $_.Name -match 'VPN|TAP|WireGuard|tun|ppp|wg\d'
5734 }
5735 if ($vpn) {
5736 foreach ($a in $vpn) {
5737 $a.Name + "|" + $a.InterfaceDescription + "|" + $a.Status + "|" + $a.MediaConnectionState
5738 }
5739 } else { "NONE" }
5740} catch { "ERROR:" + $_.Exception.Message }
5741"#;
5742 let output = Command::new("powershell")
5743 .args(["-NoProfile", "-Command", script])
5744 .output()
5745 .map_err(|e| format!("vpn: {e}"))?;
5746
5747 let raw = String::from_utf8_lossy(&output.stdout);
5748 let text = raw.trim();
5749
5750 if text == "NONE" {
5751 out.push_str("No VPN adapters detected — no active VPN connection found.\n");
5752 } else if text.starts_with("ERROR:") {
5753 out.push_str(&format!("Unable to query adapters: {text}\n"));
5754 } else {
5755 out.push_str("VPN adapters:\n\n");
5756 for line in text.lines() {
5757 let parts: Vec<&str> = line.splitn(4, '|').collect();
5758 if parts.len() >= 3 {
5759 let name = parts[0];
5760 let desc = parts[1];
5761 let status = parts[2];
5762 let media = parts.get(3).unwrap_or(&"unknown");
5763 let label = if status.trim() == "Up" {
5764 "CONNECTED"
5765 } else {
5766 "disconnected"
5767 };
5768 out.push_str(&format!(
5769 " {name} [{label}]\n {desc}\n Status: {status} | Media: {media}\n\n"
5770 ));
5771 }
5772 }
5773 }
5774
5775 let ras_script = r#"
5777try {
5778 $c = Get-VpnConnection -ErrorAction Stop
5779 if ($c) { foreach ($v in $c) { $v.Name + "|" + $v.ConnectionStatus + "|" + $v.ServerAddress } }
5780 else { "NO_RAS" }
5781} catch { "NO_RAS" }
5782"#;
5783 if let Ok(o) = Command::new("powershell")
5784 .args(["-NoProfile", "-Command", ras_script])
5785 .output()
5786 {
5787 let t = String::from_utf8_lossy(&o.stdout).trim().to_string();
5788 if t != "NO_RAS" && !t.is_empty() {
5789 out.push_str("Windows VPN connections:\n");
5790 for line in t.lines() {
5791 let parts: Vec<&str> = line.splitn(3, '|').collect();
5792 if parts.len() >= 2 {
5793 let name = parts[0];
5794 let status = parts[1];
5795 let server = parts.get(2).unwrap_or(&"");
5796 out.push_str(&format!(" {name} → {server} [{status}]\n"));
5797 }
5798 }
5799 }
5800 }
5801 }
5802
5803 #[cfg(not(target_os = "windows"))]
5804 {
5805 if let Ok(o) = Command::new("ip").args(["link", "show"]).output() {
5806 let text = String::from_utf8_lossy(&o.stdout);
5807 let vpn_ifaces: Vec<&str> = text
5808 .lines()
5809 .filter(|l| {
5810 l.contains("tun") || l.contains("tap") || l.contains(" wg") || l.contains("ppp")
5811 })
5812 .collect();
5813 if vpn_ifaces.is_empty() {
5814 out.push_str("No VPN interfaces (tun/tap/wg/ppp) detected.\n");
5815 } else {
5816 out.push_str(&format!("VPN-like interfaces ({}):\n", vpn_ifaces.len()));
5817 for l in vpn_ifaces {
5818 out.push_str(&format!(" {}\n", l.trim()));
5819 }
5820 }
5821 }
5822 }
5823
5824 Ok(out.trim_end().to_string())
5825}
5826
5827fn inspect_proxy() -> Result<String, String> {
5830 let mut out = String::from("Host inspection: proxy\n\n");
5831
5832 #[cfg(target_os = "windows")]
5833 {
5834 let script = r#"
5835$ie = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
5836if ($ie) {
5837 "ENABLE:" + $ie.ProxyEnable + "|SERVER:" + $ie.ProxyServer + "|OVERRIDE:" + $ie.ProxyOverride
5838} else { "NONE" }
5839"#;
5840 if let Ok(o) = Command::new("powershell")
5841 .args(["-NoProfile", "-Command", script])
5842 .output()
5843 {
5844 let raw = String::from_utf8_lossy(&o.stdout);
5845 let text = raw.trim();
5846 if text != "NONE" && !text.is_empty() {
5847 let get = |key: &str| -> &str {
5848 text.split('|')
5849 .find(|s| s.starts_with(key))
5850 .and_then(|s| s.splitn(2, ':').nth(1))
5851 .unwrap_or("")
5852 };
5853 let enabled = get("ENABLE");
5854 let server = get("SERVER");
5855 let overrides = get("OVERRIDE");
5856 out.push_str("WinINET / IE proxy:\n");
5857 out.push_str(&format!(
5858 " Enabled: {}\n",
5859 if enabled == "1" { "yes" } else { "no" }
5860 ));
5861 if !server.is_empty() && server != "None" {
5862 out.push_str(&format!(" Proxy server: {server}\n"));
5863 }
5864 if !overrides.is_empty() && overrides != "None" {
5865 out.push_str(&format!(" Bypass list: {overrides}\n"));
5866 }
5867 out.push('\n');
5868 }
5869 }
5870
5871 if let Ok(o) = Command::new("netsh")
5872 .args(["winhttp", "show", "proxy"])
5873 .output()
5874 {
5875 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5876 out.push_str("WinHTTP proxy:\n");
5877 for line in text.lines() {
5878 let l = line.trim();
5879 if !l.is_empty() {
5880 out.push_str(&format!(" {l}\n"));
5881 }
5882 }
5883 out.push('\n');
5884 }
5885
5886 let mut env_found = false;
5887 for var in &[
5888 "http_proxy",
5889 "https_proxy",
5890 "HTTP_PROXY",
5891 "HTTPS_PROXY",
5892 "no_proxy",
5893 "NO_PROXY",
5894 ] {
5895 if let Ok(val) = std::env::var(var) {
5896 if !env_found {
5897 out.push_str("Environment proxy variables:\n");
5898 env_found = true;
5899 }
5900 out.push_str(&format!(" {var}: {val}\n"));
5901 }
5902 }
5903 if !env_found {
5904 out.push_str("No proxy environment variables set.\n");
5905 }
5906 }
5907
5908 #[cfg(not(target_os = "windows"))]
5909 {
5910 let mut found = false;
5911 for var in &[
5912 "http_proxy",
5913 "https_proxy",
5914 "HTTP_PROXY",
5915 "HTTPS_PROXY",
5916 "no_proxy",
5917 "NO_PROXY",
5918 "ALL_PROXY",
5919 "all_proxy",
5920 ] {
5921 if let Ok(val) = std::env::var(var) {
5922 if !found {
5923 out.push_str("Proxy environment variables:\n");
5924 found = true;
5925 }
5926 out.push_str(&format!(" {var}: {val}\n"));
5927 }
5928 }
5929 if !found {
5930 out.push_str("No proxy environment variables set.\n");
5931 }
5932 if let Ok(content) = std::fs::read_to_string("/etc/environment") {
5933 let proxy_lines: Vec<&str> = content
5934 .lines()
5935 .filter(|l| l.to_lowercase().contains("proxy"))
5936 .collect();
5937 if !proxy_lines.is_empty() {
5938 out.push_str("\nSystem proxy (/etc/environment):\n");
5939 for l in proxy_lines {
5940 out.push_str(&format!(" {l}\n"));
5941 }
5942 }
5943 }
5944 }
5945
5946 Ok(out.trim_end().to_string())
5947}
5948
5949fn inspect_firewall_rules(max_entries: usize) -> Result<String, String> {
5952 let mut out = String::from("Host inspection: firewall_rules\n\n");
5953 let n = max_entries.clamp(1, 20);
5954
5955 #[cfg(target_os = "windows")]
5956 {
5957 let script = format!(
5958 r#"
5959try {{
5960 $rules = Get-NetFirewallRule -Enabled True -ErrorAction Stop |
5961 Where-Object {{
5962 $_.DisplayGroup -notmatch '^(@|Core Networking|Windows|File and Printer)' -and
5963 $_.Owner -eq $null
5964 }} | Select-Object -First {n} DisplayName, Direction, Action, Profile
5965 "TOTAL:" + $rules.Count
5966 $rules | ForEach-Object {{
5967 $dir = switch ($_.Direction) {{ 1 {{ "Inbound" }}; 2 {{ "Outbound" }}; default {{ "?" }} }}
5968 $act = switch ($_.Action) {{ 2 {{ "Allow" }}; 4 {{ "Block" }}; default {{ "?" }} }}
5969 $_.DisplayName + "|" + $dir + "|" + $act + "|" + $_.Profile
5970 }}
5971}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5972 );
5973
5974 let output = Command::new("powershell")
5975 .args(["-NoProfile", "-Command", &script])
5976 .output()
5977 .map_err(|e| format!("firewall_rules: {e}"))?;
5978
5979 let raw = String::from_utf8_lossy(&output.stdout);
5980 let text = raw.trim();
5981
5982 if text.starts_with("ERROR:") {
5983 out.push_str(&format!(
5984 "Unable to query firewall rules: {}\n",
5985 text.trim_start_matches("ERROR:").trim()
5986 ));
5987 out.push_str("This query may require running as administrator.\n");
5988 } else if text.is_empty() {
5989 out.push_str("No non-default enabled firewall rules found.\n");
5990 } else {
5991 let mut total = 0usize;
5992 for line in text.lines() {
5993 if let Some(rest) = line.strip_prefix("TOTAL:") {
5994 total = rest.trim().parse().unwrap_or(0);
5995 out.push_str(&format!(
5996 "Non-default enabled rules (showing up to {n}):\n\n"
5997 ));
5998 } else {
5999 let parts: Vec<&str> = line.splitn(4, '|').collect();
6000 if parts.len() >= 3 {
6001 let name = parts[0];
6002 let dir = parts[1];
6003 let action = parts[2];
6004 let profile = parts.get(3).unwrap_or(&"Any");
6005 let icon = if action == "Block" { "[!]" } else { " " };
6006 out.push_str(&format!(
6007 " {icon} [{dir}] {action}: {name} (profile: {profile})\n"
6008 ));
6009 }
6010 }
6011 }
6012 if total == 0 {
6013 out.push_str("No non-default enabled rules found.\n");
6014 }
6015 }
6016 }
6017
6018 #[cfg(not(target_os = "windows"))]
6019 {
6020 if let Ok(o) = Command::new("ufw").args(["status", "numbered"]).output() {
6021 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6022 if !text.is_empty() {
6023 out.push_str(&text);
6024 out.push('\n');
6025 }
6026 } else if let Ok(o) = Command::new("iptables")
6027 .args(["-L", "-n", "--line-numbers"])
6028 .output()
6029 {
6030 let text = String::from_utf8_lossy(&o.stdout);
6031 for l in text.lines().take(n * 2) {
6032 out.push_str(&format!(" {l}\n"));
6033 }
6034 } else {
6035 out.push_str("ufw and iptables not available or insufficient permissions.\n");
6036 }
6037 }
6038
6039 Ok(out.trim_end().to_string())
6040}
6041
6042fn inspect_traceroute(host: &str, max_entries: usize) -> Result<String, String> {
6045 let mut out = format!("Host inspection: traceroute\n\nTarget: {host}\n\n");
6046 let hops = max_entries.clamp(5, 30);
6047
6048 #[cfg(target_os = "windows")]
6049 {
6050 let output = Command::new("tracert")
6051 .args(["-d", "-h", &hops.to_string(), host])
6052 .output()
6053 .map_err(|e| format!("tracert: {e}"))?;
6054 let raw = String::from_utf8_lossy(&output.stdout);
6055 let mut hop_count = 0usize;
6056 for line in raw.lines() {
6057 let trimmed = line.trim();
6058 if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
6059 hop_count += 1;
6060 out.push_str(&format!(" {trimmed}\n"));
6061 } else if trimmed.starts_with("Tracing") || trimmed.starts_with("Trace complete") {
6062 out.push_str(&format!("{trimmed}\n"));
6063 }
6064 }
6065 if hop_count == 0 {
6066 out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
6067 }
6068 }
6069
6070 #[cfg(not(target_os = "windows"))]
6071 {
6072 let cmd = if std::path::Path::new("/usr/bin/traceroute").exists()
6073 || std::path::Path::new("/usr/sbin/traceroute").exists()
6074 {
6075 "traceroute"
6076 } else {
6077 "tracepath"
6078 };
6079 let output = Command::new(cmd)
6080 .args(["-m", &hops.to_string(), "-n", host])
6081 .output()
6082 .map_err(|e| format!("{cmd}: {e}"))?;
6083 let raw = String::from_utf8_lossy(&output.stdout);
6084 let mut hop_count = 0usize;
6085 for line in raw.lines().take(hops + 2) {
6086 let trimmed = line.trim();
6087 if !trimmed.is_empty() {
6088 hop_count += 1;
6089 out.push_str(&format!(" {trimmed}\n"));
6090 }
6091 }
6092 if hop_count == 0 {
6093 out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
6094 }
6095 }
6096
6097 Ok(out.trim_end().to_string())
6098}
6099
6100fn inspect_dns_cache(max_entries: usize) -> Result<String, String> {
6103 let mut out = String::from("Host inspection: dns_cache\n\n");
6104 let n = max_entries.clamp(10, 100);
6105
6106 #[cfg(target_os = "windows")]
6107 {
6108 let output = Command::new("powershell")
6109 .args([
6110 "-NoProfile",
6111 "-Command",
6112 "Get-DnsClientCache | Select-Object -First 200 Entry,RecordType,Data,TimeToLive | ConvertTo-Csv -NoTypeInformation",
6113 ])
6114 .output()
6115 .map_err(|e| format!("dns_cache: {e}"))?;
6116
6117 let raw = String::from_utf8_lossy(&output.stdout);
6118 let lines: Vec<&str> = raw.lines().skip(1).collect();
6119 let total = lines.len();
6120
6121 if total == 0 {
6122 out.push_str("DNS cache is empty or could not be read.\n");
6123 } else {
6124 out.push_str(&format!(
6125 "DNS cache entries (showing up to {n} of {total}):\n\n"
6126 ));
6127 let mut shown = 0usize;
6128 for line in lines.iter().take(n) {
6129 let cols: Vec<&str> = line.splitn(4, ',').collect();
6130 if cols.len() >= 3 {
6131 let entry = cols[0].trim_matches('"');
6132 let rtype = cols[1].trim_matches('"');
6133 let data = cols[2].trim_matches('"');
6134 let ttl = cols.get(3).map(|s| s.trim_matches('"')).unwrap_or("?");
6135 out.push_str(&format!(" {entry:<45} {rtype:<6} {data} (TTL {ttl}s)\n"));
6136 shown += 1;
6137 }
6138 }
6139 if total > shown {
6140 out.push_str(&format!("\n ... and {} more entries\n", total - shown));
6141 }
6142 }
6143 }
6144
6145 #[cfg(not(target_os = "windows"))]
6146 {
6147 if let Ok(o) = Command::new("resolvectl").args(["statistics"]).output() {
6148 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6149 if !text.is_empty() {
6150 out.push_str("systemd-resolved statistics:\n");
6151 for line in text.lines().take(n) {
6152 out.push_str(&format!(" {line}\n"));
6153 }
6154 out.push('\n');
6155 }
6156 }
6157 if let Ok(o) = Command::new("dscacheutil")
6158 .args(["-cachedump", "-entries", "Host"])
6159 .output()
6160 {
6161 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6162 if !text.is_empty() {
6163 out.push_str("DNS cache (macOS dscacheutil):\n");
6164 for line in text.lines().take(n) {
6165 out.push_str(&format!(" {line}\n"));
6166 }
6167 } else {
6168 out.push_str("DNS cache is empty or not accessible on this platform.\n");
6169 }
6170 } else {
6171 out.push_str(
6172 "DNS cache inspection not available (no resolvectl or dscacheutil found).\n",
6173 );
6174 }
6175 }
6176
6177 Ok(out.trim_end().to_string())
6178}
6179
6180fn inspect_arp() -> Result<String, String> {
6183 let mut out = String::from("Host inspection: arp\n\n");
6184
6185 #[cfg(target_os = "windows")]
6186 {
6187 let output = Command::new("arp")
6188 .args(["-a"])
6189 .output()
6190 .map_err(|e| format!("arp: {e}"))?;
6191 let raw = String::from_utf8_lossy(&output.stdout);
6192 let mut count = 0usize;
6193 for line in raw.lines() {
6194 let t = line.trim();
6195 if t.is_empty() {
6196 continue;
6197 }
6198 out.push_str(&format!(" {t}\n"));
6199 if t.contains("dynamic") || t.contains("static") {
6200 count += 1;
6201 }
6202 }
6203 out.push_str(&format!("\nTotal entries: {count}\n"));
6204 }
6205
6206 #[cfg(not(target_os = "windows"))]
6207 {
6208 if let Ok(o) = Command::new("arp").args(["-n"]).output() {
6209 let raw = String::from_utf8_lossy(&o.stdout);
6210 let mut count = 0usize;
6211 for line in raw.lines() {
6212 let t = line.trim();
6213 if !t.is_empty() {
6214 out.push_str(&format!(" {t}\n"));
6215 count += 1;
6216 }
6217 }
6218 out.push_str(&format!("\nTotal entries: {}\n", count.saturating_sub(1)));
6219 } else if let Ok(o) = Command::new("ip").args(["neigh"]).output() {
6220 let raw = String::from_utf8_lossy(&o.stdout);
6221 let mut count = 0usize;
6222 for line in raw.lines() {
6223 let t = line.trim();
6224 if !t.is_empty() {
6225 out.push_str(&format!(" {t}\n"));
6226 count += 1;
6227 }
6228 }
6229 out.push_str(&format!("\nTotal entries: {count}\n"));
6230 } else {
6231 out.push_str("arp and ip neigh not available.\n");
6232 }
6233 }
6234
6235 Ok(out.trim_end().to_string())
6236}
6237
6238fn inspect_route_table(max_entries: usize) -> Result<String, String> {
6241 let mut out = String::from("Host inspection: route_table\n\n");
6242 let n = max_entries.clamp(10, 50);
6243
6244 #[cfg(target_os = "windows")]
6245 {
6246 let script = r#"
6247try {
6248 $routes = Get-NetRoute -ErrorAction Stop |
6249 Where-Object { $_.RouteMetric -lt 9000 } |
6250 Sort-Object RouteMetric |
6251 Select-Object DestinationPrefix, NextHop, RouteMetric, InterfaceAlias
6252 "TOTAL:" + $routes.Count
6253 $routes | ForEach-Object {
6254 $_.DestinationPrefix + "|" + $_.NextHop + "|" + $_.RouteMetric + "|" + $_.InterfaceAlias
6255 }
6256} catch { "ERROR:" + $_.Exception.Message }
6257"#;
6258 let output = Command::new("powershell")
6259 .args(["-NoProfile", "-Command", script])
6260 .output()
6261 .map_err(|e| format!("route_table: {e}"))?;
6262 let raw = String::from_utf8_lossy(&output.stdout);
6263 let text = raw.trim();
6264
6265 if text.starts_with("ERROR:") {
6266 out.push_str(&format!(
6267 "Unable to read route table: {}\n",
6268 text.trim_start_matches("ERROR:").trim()
6269 ));
6270 } else {
6271 let mut shown = 0usize;
6272 for line in text.lines() {
6273 if let Some(rest) = line.strip_prefix("TOTAL:") {
6274 let total: usize = rest.trim().parse().unwrap_or(0);
6275 out.push_str(&format!(
6276 "Routing table (showing up to {n} of {total} routes):\n\n"
6277 ));
6278 out.push_str(&format!(
6279 " {:<22} {:<18} {:>8} Interface\n",
6280 "Destination", "Next Hop", "Metric"
6281 ));
6282 out.push_str(&format!(" {}\n", "-".repeat(70)));
6283 } else if shown < n {
6284 let parts: Vec<&str> = line.splitn(4, '|').collect();
6285 if parts.len() == 4 {
6286 let dest = parts[0];
6287 let hop =
6288 if parts[1].is_empty() || parts[1] == "0.0.0.0" || parts[1] == "::" {
6289 "on-link"
6290 } else {
6291 parts[1]
6292 };
6293 let metric = parts[2];
6294 let iface = parts[3];
6295 out.push_str(&format!(" {dest:<22} {hop:<18} {metric:>8} {iface}\n"));
6296 shown += 1;
6297 }
6298 }
6299 }
6300 }
6301 }
6302
6303 #[cfg(not(target_os = "windows"))]
6304 {
6305 if let Ok(o) = Command::new("ip").args(["route", "show"]).output() {
6306 let raw = String::from_utf8_lossy(&o.stdout);
6307 let lines: Vec<&str> = raw.lines().collect();
6308 let total = lines.len();
6309 out.push_str(&format!(
6310 "Routing table (showing up to {n} of {total} routes):\n\n"
6311 ));
6312 for line in lines.iter().take(n) {
6313 out.push_str(&format!(" {line}\n"));
6314 }
6315 if total > n {
6316 out.push_str(&format!("\n ... and {} more routes\n", total - n));
6317 }
6318 } else if let Ok(o) = Command::new("netstat").args(["-rn"]).output() {
6319 let raw = String::from_utf8_lossy(&o.stdout);
6320 for line in raw.lines().take(n) {
6321 out.push_str(&format!(" {line}\n"));
6322 }
6323 } else {
6324 out.push_str("ip route and netstat not available.\n");
6325 }
6326 }
6327
6328 Ok(out.trim_end().to_string())
6329}
6330
6331fn inspect_env(max_entries: usize) -> Result<String, String> {
6334 let mut out = String::from("Host inspection: env\n\n");
6335 let n = max_entries.clamp(10, 50);
6336
6337 fn looks_like_secret(name: &str) -> bool {
6338 let n = name.to_uppercase();
6339 n.contains("KEY")
6340 || n.contains("SECRET")
6341 || n.contains("TOKEN")
6342 || n.contains("PASSWORD")
6343 || n.contains("PASSWD")
6344 || n.contains("CREDENTIAL")
6345 || n.contains("AUTH")
6346 || n.contains("CERT")
6347 || n.contains("PRIVATE")
6348 }
6349
6350 let known_dev_vars: &[&str] = &[
6351 "CARGO_HOME",
6352 "RUSTUP_HOME",
6353 "GOPATH",
6354 "GOROOT",
6355 "GOBIN",
6356 "JAVA_HOME",
6357 "ANDROID_HOME",
6358 "ANDROID_SDK_ROOT",
6359 "PYTHONPATH",
6360 "PYTHONHOME",
6361 "VIRTUAL_ENV",
6362 "CONDA_DEFAULT_ENV",
6363 "CONDA_PREFIX",
6364 "NODE_PATH",
6365 "NVM_DIR",
6366 "NVM_BIN",
6367 "PNPM_HOME",
6368 "DENO_INSTALL",
6369 "DENO_DIR",
6370 "DOTNET_ROOT",
6371 "NUGET_PACKAGES",
6372 "CMAKE_HOME",
6373 "VCPKG_ROOT",
6374 "AWS_PROFILE",
6375 "AWS_REGION",
6376 "AWS_DEFAULT_REGION",
6377 "GCP_PROJECT",
6378 "GOOGLE_CLOUD_PROJECT",
6379 "GOOGLE_APPLICATION_CREDENTIALS",
6380 "AZURE_SUBSCRIPTION_ID",
6381 "DATABASE_URL",
6382 "REDIS_URL",
6383 "MONGO_URI",
6384 "EDITOR",
6385 "VISUAL",
6386 "SHELL",
6387 "TERM",
6388 "XDG_CONFIG_HOME",
6389 "XDG_DATA_HOME",
6390 "XDG_CACHE_HOME",
6391 "HOME",
6392 "USERPROFILE",
6393 "APPDATA",
6394 "LOCALAPPDATA",
6395 "TEMP",
6396 "TMP",
6397 "COMPUTERNAME",
6398 "USERNAME",
6399 "USERDOMAIN",
6400 "PROCESSOR_ARCHITECTURE",
6401 "NUMBER_OF_PROCESSORS",
6402 "OS",
6403 "HOMEDRIVE",
6404 "HOMEPATH",
6405 "HTTP_PROXY",
6406 "HTTPS_PROXY",
6407 "NO_PROXY",
6408 "ALL_PROXY",
6409 "http_proxy",
6410 "https_proxy",
6411 "no_proxy",
6412 "DOCKER_HOST",
6413 "DOCKER_BUILDKIT",
6414 "COMPOSE_PROJECT_NAME",
6415 "KUBECONFIG",
6416 "KUBE_CONTEXT",
6417 "CI",
6418 "GITHUB_ACTIONS",
6419 "GITLAB_CI",
6420 "LMSTUDIO_HOME",
6421 "HEMATITE_URL",
6422 ];
6423
6424 let mut all_vars: Vec<(String, String)> = std::env::vars().collect();
6425 all_vars.sort_by(|a, b| a.0.cmp(&b.0));
6426 let total = all_vars.len();
6427
6428 let mut dev_found: Vec<String> = Vec::new();
6429 let mut secret_found: Vec<String> = Vec::new();
6430
6431 for (k, v) in &all_vars {
6432 if k == "PATH" {
6433 continue;
6434 }
6435 if looks_like_secret(k) {
6436 secret_found.push(format!("{k} = [SET, {} chars]", v.len()));
6437 } else {
6438 let k_upper = k.to_uppercase();
6439 let is_known = known_dev_vars
6440 .iter()
6441 .any(|kv| k_upper.as_str() == kv.to_uppercase().as_str());
6442 if is_known {
6443 let display = if v.len() > 120 {
6444 format!("{k} = {}…", &v[..117])
6445 } else {
6446 format!("{k} = {v}")
6447 };
6448 dev_found.push(display);
6449 }
6450 }
6451 }
6452
6453 out.push_str(&format!("Total environment variables: {total}\n\n"));
6454
6455 if let Ok(p) = std::env::var("PATH") {
6456 let sep = if cfg!(target_os = "windows") {
6457 ';'
6458 } else {
6459 ':'
6460 };
6461 let count = p.split(sep).count();
6462 out.push_str(&format!(
6463 "PATH: {count} entries (use topic=path for full audit)\n\n"
6464 ));
6465 }
6466
6467 if !secret_found.is_empty() {
6468 out.push_str(&format!(
6469 "=== Secret/credential variables ({} detected, values hidden) ===\n",
6470 secret_found.len()
6471 ));
6472 for s in secret_found.iter().take(n) {
6473 out.push_str(&format!(" {s}\n"));
6474 }
6475 out.push('\n');
6476 }
6477
6478 if !dev_found.is_empty() {
6479 out.push_str(&format!(
6480 "=== Developer & tool variables ({}) ===\n",
6481 dev_found.len()
6482 ));
6483 for d in dev_found.iter().take(n) {
6484 out.push_str(&format!(" {d}\n"));
6485 }
6486 out.push('\n');
6487 }
6488
6489 let other_count = all_vars
6490 .iter()
6491 .filter(|(k, _)| {
6492 k != "PATH"
6493 && !looks_like_secret(k)
6494 && !known_dev_vars
6495 .iter()
6496 .any(|kv| k.to_uppercase().as_str() == kv.to_uppercase().as_str())
6497 })
6498 .count();
6499 if other_count > 0 {
6500 out.push_str(&format!(
6501 "Other variables: {other_count} (use 'env' in shell to see all)\n"
6502 ));
6503 }
6504
6505 Ok(out.trim_end().to_string())
6506}
6507
6508fn inspect_hosts_file() -> Result<String, String> {
6511 let mut out = String::from("Host inspection: hosts_file\n\n");
6512
6513 let hosts_path = if cfg!(target_os = "windows") {
6514 std::path::PathBuf::from(r"C:\Windows\System32\drivers\etc\hosts")
6515 } else {
6516 std::path::PathBuf::from("/etc/hosts")
6517 };
6518
6519 out.push_str(&format!("Path: {}\n\n", hosts_path.display()));
6520
6521 match fs::read_to_string(&hosts_path) {
6522 Ok(content) => {
6523 let mut active_entries: Vec<String> = Vec::new();
6524 let mut comment_lines = 0usize;
6525 let mut blank_lines = 0usize;
6526
6527 for line in content.lines() {
6528 let t = line.trim();
6529 if t.is_empty() {
6530 blank_lines += 1;
6531 } else if t.starts_with('#') {
6532 comment_lines += 1;
6533 } else {
6534 active_entries.push(line.to_string());
6535 }
6536 }
6537
6538 out.push_str(&format!(
6539 "Active entries: {} | Comment lines: {} | Blank lines: {}\n\n",
6540 active_entries.len(),
6541 comment_lines,
6542 blank_lines
6543 ));
6544
6545 if active_entries.is_empty() {
6546 out.push_str(
6547 "No active host entries (file contains only comments/blanks — standard default state).\n",
6548 );
6549 } else {
6550 out.push_str("=== Active entries ===\n");
6551 for entry in &active_entries {
6552 out.push_str(&format!(" {entry}\n"));
6553 }
6554 out.push('\n');
6555
6556 let custom: Vec<&String> = active_entries
6557 .iter()
6558 .filter(|e| {
6559 let t = e.trim_start();
6560 !t.starts_with("127.") && !t.starts_with("::1") && !t.starts_with("0.0.0.0")
6561 })
6562 .collect();
6563 if !custom.is_empty() {
6564 out.push_str(&format!(
6565 "[!] Custom (non-loopback) entries: {}\n",
6566 custom.len()
6567 ));
6568 for e in &custom {
6569 out.push_str(&format!(" {e}\n"));
6570 }
6571 } else {
6572 out.push_str("All active entries are standard loopback or block entries.\n");
6573 }
6574 }
6575
6576 out.push_str("\n=== Full file ===\n");
6577 for line in content.lines() {
6578 out.push_str(&format!(" {line}\n"));
6579 }
6580 }
6581 Err(e) => {
6582 out.push_str(&format!("Could not read hosts file: {e}\n"));
6583 if cfg!(target_os = "windows") {
6584 out.push_str(
6585 "On Windows, run Hematite as Administrator if permission is denied.\n",
6586 );
6587 }
6588 }
6589 }
6590
6591 Ok(out.trim_end().to_string())
6592}
6593
6594fn inspect_docker(max_entries: usize) -> Result<String, String> {
6597 let mut out = String::from("Host inspection: docker\n\n");
6598 let n = max_entries.clamp(5, 25);
6599
6600 let version_output = Command::new("docker")
6601 .args(["version", "--format", "{{.Server.Version}}"])
6602 .output();
6603
6604 match version_output {
6605 Err(_) => {
6606 out.push_str("Docker: not found on PATH.\n");
6607 out.push_str(
6608 "Install Docker Desktop: https://www.docker.com/products/docker-desktop\n",
6609 );
6610 return Ok(out.trim_end().to_string());
6611 }
6612 Ok(o) if !o.status.success() => {
6613 let stderr = String::from_utf8_lossy(&o.stderr);
6614 if stderr.contains("cannot connect")
6615 || stderr.contains("Is the docker daemon running")
6616 || stderr.contains("pipe")
6617 || stderr.contains("socket")
6618 {
6619 out.push_str("Docker: installed but daemon is NOT running.\n");
6620 out.push_str("Start Docker Desktop or run: sudo systemctl start docker\n");
6621 } else {
6622 out.push_str(&format!("Docker: error — {}\n", stderr.trim()));
6623 }
6624 return Ok(out.trim_end().to_string());
6625 }
6626 Ok(o) => {
6627 let version = String::from_utf8_lossy(&o.stdout).trim().to_string();
6628 out.push_str(&format!("Docker Engine: {version}\n"));
6629 }
6630 }
6631
6632 if let Ok(o) = Command::new("docker")
6633 .args([
6634 "info",
6635 "--format",
6636 "Containers: {{.Containers}} (running: {{.ContainersRunning}}, stopped: {{.ContainersStopped}})\nImages: {{.Images}}\nStorage driver: {{.Driver}}\nOS/Arch: {{.OSType}}/{{.Architecture}}\nCPUs: {{.NCPU}}",
6637 ])
6638 .output()
6639 {
6640 let info = String::from_utf8_lossy(&o.stdout);
6641 for line in info.lines() {
6642 let t = line.trim();
6643 if !t.is_empty() {
6644 out.push_str(&format!(" {t}\n"));
6645 }
6646 }
6647 out.push('\n');
6648 }
6649
6650 if let Ok(o) = Command::new("docker")
6651 .args([
6652 "ps",
6653 "--format",
6654 "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
6655 ])
6656 .output()
6657 {
6658 let raw = String::from_utf8_lossy(&o.stdout);
6659 let lines: Vec<&str> = raw.lines().collect();
6660 if lines.len() <= 1 {
6661 out.push_str("Running containers: none\n\n");
6662 } else {
6663 out.push_str(&format!(
6664 "=== Running containers ({}) ===\n",
6665 lines.len().saturating_sub(1)
6666 ));
6667 for line in lines.iter().take(n + 1) {
6668 out.push_str(&format!(" {line}\n"));
6669 }
6670 if lines.len() > n + 1 {
6671 out.push_str(&format!(" ... and {} more\n", lines.len() - n - 1));
6672 }
6673 out.push('\n');
6674 }
6675 }
6676
6677 if let Ok(o) = Command::new("docker")
6678 .args([
6679 "images",
6680 "--format",
6681 "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}",
6682 ])
6683 .output()
6684 {
6685 let raw = String::from_utf8_lossy(&o.stdout);
6686 let lines: Vec<&str> = raw.lines().collect();
6687 if lines.len() > 1 {
6688 out.push_str(&format!(
6689 "=== Local images ({}) ===\n",
6690 lines.len().saturating_sub(1)
6691 ));
6692 for line in lines.iter().take(n + 1) {
6693 out.push_str(&format!(" {line}\n"));
6694 }
6695 if lines.len() > n + 1 {
6696 out.push_str(&format!(" ... and {} more\n", lines.len() - n - 1));
6697 }
6698 out.push('\n');
6699 }
6700 }
6701
6702 if let Ok(o) = Command::new("docker")
6703 .args([
6704 "compose",
6705 "ls",
6706 "--format",
6707 "table {{.Name}}\t{{.Status}}\t{{.ConfigFiles}}",
6708 ])
6709 .output()
6710 {
6711 let raw = String::from_utf8_lossy(&o.stdout);
6712 let lines: Vec<&str> = raw.lines().collect();
6713 if lines.len() > 1 {
6714 out.push_str(&format!(
6715 "=== Compose projects ({}) ===\n",
6716 lines.len().saturating_sub(1)
6717 ));
6718 for line in lines.iter().take(n + 1) {
6719 out.push_str(&format!(" {line}\n"));
6720 }
6721 out.push('\n');
6722 }
6723 }
6724
6725 if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
6726 let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
6727 if !ctx.is_empty() {
6728 out.push_str(&format!("Active context: {ctx}\n"));
6729 }
6730 }
6731
6732 Ok(out.trim_end().to_string())
6733}
6734
6735fn inspect_wsl() -> Result<String, String> {
6738 let mut out = String::from("Host inspection: wsl\n\n");
6739
6740 #[cfg(target_os = "windows")]
6741 {
6742 if let Ok(o) = Command::new("wsl").args(["--version"]).output() {
6743 let raw = String::from_utf8_lossy(&o.stdout);
6744 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
6745 for line in cleaned.lines().take(4) {
6746 let t = line.trim();
6747 if !t.is_empty() {
6748 out.push_str(&format!(" {t}\n"));
6749 }
6750 }
6751 out.push('\n');
6752 }
6753
6754 let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
6755 match list_output {
6756 Err(e) => {
6757 out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
6758 out.push_str("WSL may not be installed. Enable with: wsl --install\n");
6759 }
6760 Ok(o) if !o.status.success() => {
6761 let stderr = String::from_utf8_lossy(&o.stderr);
6762 let cleaned: String = stderr.chars().filter(|c| *c != '\0').collect();
6763 out.push_str(&format!("WSL: error — {}\n", cleaned.trim()));
6764 out.push_str("Run: wsl --install\n");
6765 }
6766 Ok(o) => {
6767 let raw = String::from_utf8_lossy(&o.stdout);
6768 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
6769 let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
6770 let distro_lines: Vec<&str> = lines
6771 .iter()
6772 .filter(|l| {
6773 let t = l.trim();
6774 !t.is_empty()
6775 && !t.to_uppercase().starts_with("NAME")
6776 && !t.starts_with("---")
6777 })
6778 .copied()
6779 .collect();
6780
6781 if distro_lines.is_empty() {
6782 out.push_str("WSL: installed but no distributions found.\n");
6783 out.push_str("Install a distro: wsl --install -d Ubuntu\n");
6784 } else {
6785 out.push_str("=== WSL Distributions ===\n");
6786 for line in &lines {
6787 out.push_str(&format!(" {}\n", line.trim()));
6788 }
6789 out.push_str(&format!("\nTotal distributions: {}\n", distro_lines.len()));
6790 }
6791 }
6792 }
6793
6794 if let Ok(o) = Command::new("wsl").args(["--status"]).output() {
6795 let raw = String::from_utf8_lossy(&o.stdout);
6796 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
6797 let status_lines: Vec<&str> = cleaned
6798 .lines()
6799 .filter(|l| !l.trim().is_empty())
6800 .take(8)
6801 .collect();
6802 if !status_lines.is_empty() {
6803 out.push_str("\n=== WSL status ===\n");
6804 for line in status_lines {
6805 out.push_str(&format!(" {}\n", line.trim()));
6806 }
6807 }
6808 }
6809 }
6810
6811 #[cfg(not(target_os = "windows"))]
6812 {
6813 out.push_str("WSL (Windows Subsystem for Linux) is a Windows-only feature.\n");
6814 out.push_str("On Linux/macOS, use native virtualization (KVM, UTM, Parallels) instead.\n");
6815 }
6816
6817 Ok(out.trim_end().to_string())
6818}
6819
6820fn dirs_home() -> Option<PathBuf> {
6823 std::env::var("HOME")
6824 .ok()
6825 .map(PathBuf::from)
6826 .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
6827}
6828
6829fn inspect_ssh() -> Result<String, String> {
6830 let mut out = String::from("Host inspection: ssh\n\n");
6831
6832 if let Ok(o) = Command::new("ssh").args(["-V"]).output() {
6833 let ver = if o.stdout.is_empty() {
6834 String::from_utf8_lossy(&o.stderr).trim().to_string()
6835 } else {
6836 String::from_utf8_lossy(&o.stdout).trim().to_string()
6837 };
6838 if !ver.is_empty() {
6839 out.push_str(&format!("SSH client: {ver}\n"));
6840 }
6841 } else {
6842 out.push_str("SSH client: not found on PATH.\n");
6843 }
6844
6845 #[cfg(target_os = "windows")]
6846 {
6847 let script = r#"
6848$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue
6849if ($svc) { "SSHD:" + $svc.Status + " | StartType:" + $svc.StartType }
6850else { "SSHD:not_installed" }
6851"#;
6852 if let Ok(o) = Command::new("powershell")
6853 .args(["-NoProfile", "-Command", script])
6854 .output()
6855 {
6856 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6857 if text.contains("not_installed") {
6858 out.push_str("SSH server (sshd): not installed\n");
6859 } else {
6860 out.push_str(&format!(
6861 "SSH server (sshd): {}\n",
6862 text.trim_start_matches("SSHD:")
6863 ));
6864 }
6865 }
6866 }
6867
6868 #[cfg(not(target_os = "windows"))]
6869 {
6870 if let Ok(o) = Command::new("systemctl")
6871 .args(["is-active", "sshd"])
6872 .output()
6873 {
6874 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
6875 out.push_str(&format!("SSH server (sshd): {status}\n"));
6876 } else if let Ok(o) = Command::new("systemctl")
6877 .args(["is-active", "ssh"])
6878 .output()
6879 {
6880 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
6881 out.push_str(&format!("SSH server (ssh): {status}\n"));
6882 }
6883 }
6884
6885 out.push('\n');
6886
6887 if let Some(ssh_dir) = dirs_home().map(|h| h.join(".ssh")) {
6888 if ssh_dir.exists() {
6889 out.push_str(&format!("~/.ssh: {}\n", ssh_dir.display()));
6890
6891 let kh = ssh_dir.join("known_hosts");
6892 if kh.exists() {
6893 let count = fs::read_to_string(&kh)
6894 .map(|c| {
6895 c.lines()
6896 .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
6897 .count()
6898 })
6899 .unwrap_or(0);
6900 out.push_str(&format!(" known_hosts: {count} entries\n"));
6901 } else {
6902 out.push_str(" known_hosts: not present\n");
6903 }
6904
6905 let ak = ssh_dir.join("authorized_keys");
6906 if ak.exists() {
6907 let count = fs::read_to_string(&ak)
6908 .map(|c| {
6909 c.lines()
6910 .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
6911 .count()
6912 })
6913 .unwrap_or(0);
6914 out.push_str(&format!(" authorized_keys: {count} public keys\n"));
6915 } else {
6916 out.push_str(" authorized_keys: not present\n");
6917 }
6918
6919 let key_names = [
6920 "id_rsa",
6921 "id_ed25519",
6922 "id_ecdsa",
6923 "id_dsa",
6924 "id_ecdsa_sk",
6925 "id_ed25519_sk",
6926 ];
6927 let found_keys: Vec<&str> = key_names
6928 .iter()
6929 .filter(|k| ssh_dir.join(k).exists())
6930 .copied()
6931 .collect();
6932 if !found_keys.is_empty() {
6933 out.push_str(&format!(" Private keys: {}\n", found_keys.join(", ")));
6934 } else {
6935 out.push_str(" Private keys: none found\n");
6936 }
6937
6938 let config_path = ssh_dir.join("config");
6939 if config_path.exists() {
6940 out.push_str("\n=== SSH config hosts ===\n");
6941 match fs::read_to_string(&config_path) {
6942 Ok(content) => {
6943 let mut hosts: Vec<(String, Vec<String>)> = Vec::new();
6944 let mut current: Option<(String, Vec<String>)> = None;
6945 for line in content.lines() {
6946 let t = line.trim();
6947 if t.is_empty() || t.starts_with('#') {
6948 continue;
6949 }
6950 if let Some(host) = t.strip_prefix("Host ") {
6951 if let Some(prev) = current.take() {
6952 hosts.push(prev);
6953 }
6954 current = Some((host.trim().to_string(), Vec::new()));
6955 } else if let Some((_, ref mut details)) = current {
6956 let tu = t.to_uppercase();
6957 if tu.starts_with("HOSTNAME ")
6958 || tu.starts_with("USER ")
6959 || tu.starts_with("PORT ")
6960 || tu.starts_with("IDENTITYFILE ")
6961 {
6962 details.push(t.to_string());
6963 }
6964 }
6965 }
6966 if let Some(prev) = current {
6967 hosts.push(prev);
6968 }
6969
6970 if hosts.is_empty() {
6971 out.push_str(" No Host entries found.\n");
6972 } else {
6973 for (h, details) in &hosts {
6974 if details.is_empty() {
6975 out.push_str(&format!(" Host {h}\n"));
6976 } else {
6977 out.push_str(&format!(
6978 " Host {h} [{}]\n",
6979 details.join(", ")
6980 ));
6981 }
6982 }
6983 out.push_str(&format!("\n Total configured hosts: {}\n", hosts.len()));
6984 }
6985 }
6986 Err(e) => out.push_str(&format!(" Could not read config: {e}\n")),
6987 }
6988 } else {
6989 out.push_str(" SSH config: not present\n");
6990 }
6991 } else {
6992 out.push_str("~/.ssh: directory not found (no SSH keys configured).\n");
6993 }
6994 }
6995
6996 Ok(out.trim_end().to_string())
6997}
6998
6999fn inspect_installed_software(max_entries: usize) -> Result<String, String> {
7002 let mut out = String::from("Host inspection: installed_software\n\n");
7003 let n = max_entries.clamp(10, 50);
7004
7005 #[cfg(target_os = "windows")]
7006 {
7007 let winget_out = Command::new("winget")
7008 .args(["list", "--accept-source-agreements"])
7009 .output();
7010
7011 if let Ok(o) = winget_out {
7012 if o.status.success() {
7013 let raw = String::from_utf8_lossy(&o.stdout);
7014 let mut header_done = false;
7015 let mut packages: Vec<&str> = Vec::new();
7016 for line in raw.lines() {
7017 let t = line.trim();
7018 if t.starts_with("---") {
7019 header_done = true;
7020 continue;
7021 }
7022 if header_done && !t.is_empty() {
7023 packages.push(line);
7024 }
7025 }
7026 let total = packages.len();
7027 out.push_str(&format!(
7028 "=== Installed software via winget ({total} packages) ===\n\n"
7029 ));
7030 for line in packages.iter().take(n) {
7031 out.push_str(&format!(" {line}\n"));
7032 }
7033 if total > n {
7034 out.push_str(&format!("\n ... and {} more packages\n", total - n));
7035 }
7036 out.push_str("\nFor full list: winget list\n");
7037 return Ok(out.trim_end().to_string());
7038 }
7039 }
7040
7041 let script = format!(
7043 r#"
7044$apps = @()
7045$reg_paths = @(
7046 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
7047 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
7048 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
7049)
7050foreach ($p in $reg_paths) {{
7051 try {{
7052 $apps += Get-ItemProperty $p -ErrorAction SilentlyContinue |
7053 Where-Object {{ $_.DisplayName }} |
7054 Select-Object DisplayName, DisplayVersion, Publisher
7055 }} catch {{}}
7056}}
7057$sorted = $apps | Sort-Object DisplayName -Unique
7058"TOTAL:" + $sorted.Count
7059$sorted | Select-Object -First {n} | ForEach-Object {{
7060 $_.DisplayName + "|" + $_.DisplayVersion + "|" + $_.Publisher
7061}}
7062"#
7063 );
7064 if let Ok(o) = Command::new("powershell")
7065 .args(["-NoProfile", "-Command", &script])
7066 .output()
7067 {
7068 let raw = String::from_utf8_lossy(&o.stdout);
7069 out.push_str("=== Installed software (registry scan) ===\n");
7070 out.push_str(&format!(" {:<50} {:<18} Publisher\n", "Name", "Version"));
7071 out.push_str(&format!(" {}\n", "-".repeat(90)));
7072 for line in raw.lines() {
7073 if let Some(rest) = line.strip_prefix("TOTAL:") {
7074 let total: usize = rest.trim().parse().unwrap_or(0);
7075 out.push_str(&format!(" (Total: {total}, showing first {n})\n\n"));
7076 } else if !line.trim().is_empty() {
7077 let parts: Vec<&str> = line.splitn(3, '|').collect();
7078 let name = parts.first().map(|s| s.trim()).unwrap_or("");
7079 let ver = parts.get(1).map(|s| s.trim()).unwrap_or("");
7080 let pub_ = parts.get(2).map(|s| s.trim()).unwrap_or("");
7081 out.push_str(&format!(" {:<50} {:<18} {pub_}\n", name, ver));
7082 }
7083 }
7084 } else {
7085 out.push_str(
7086 "Could not query installed software (winget and registry scan both failed).\n",
7087 );
7088 }
7089 }
7090
7091 #[cfg(target_os = "linux")]
7092 {
7093 let mut found = false;
7094 if let Ok(o) = Command::new("dpkg").args(["--get-selections"]).output() {
7095 if o.status.success() {
7096 let raw = String::from_utf8_lossy(&o.stdout);
7097 let installed: Vec<&str> = raw.lines().filter(|l| l.contains("install")).collect();
7098 let total = installed.len();
7099 out.push_str(&format!("=== Installed packages via dpkg ({total}) ===\n"));
7100 for line in installed.iter().take(n) {
7101 out.push_str(&format!(" {}\n", line.trim()));
7102 }
7103 if total > n {
7104 out.push_str(&format!(" ... and {} more\n", total - n));
7105 }
7106 out.push_str("\nFor full list: dpkg --get-selections | grep install\n");
7107 found = true;
7108 }
7109 }
7110 if !found {
7111 if let Ok(o) = Command::new("rpm")
7112 .args(["-qa", "--queryformat", "%{NAME} %{VERSION}\n"])
7113 .output()
7114 {
7115 if o.status.success() {
7116 let raw = String::from_utf8_lossy(&o.stdout);
7117 let lines: Vec<&str> = raw.lines().collect();
7118 let total = lines.len();
7119 out.push_str(&format!("=== Installed packages via rpm ({total}) ===\n"));
7120 for line in lines.iter().take(n) {
7121 out.push_str(&format!(" {line}\n"));
7122 }
7123 if total > n {
7124 out.push_str(&format!(" ... and {} more\n", total - n));
7125 }
7126 found = true;
7127 }
7128 }
7129 }
7130 if !found {
7131 if let Ok(o) = Command::new("pacman").args(["-Q"]).output() {
7132 if o.status.success() {
7133 let raw = String::from_utf8_lossy(&o.stdout);
7134 let lines: Vec<&str> = raw.lines().collect();
7135 let total = lines.len();
7136 out.push_str(&format!(
7137 "=== Installed packages via pacman ({total}) ===\n"
7138 ));
7139 for line in lines.iter().take(n) {
7140 out.push_str(&format!(" {line}\n"));
7141 }
7142 if total > n {
7143 out.push_str(&format!(" ... and {} more\n", total - n));
7144 }
7145 found = true;
7146 }
7147 }
7148 }
7149 if !found {
7150 out.push_str("No package manager found (tried dpkg, rpm, pacman).\n");
7151 }
7152 }
7153
7154 #[cfg(target_os = "macos")]
7155 {
7156 if let Ok(o) = Command::new("brew").args(["list", "--versions"]).output() {
7157 if o.status.success() {
7158 let raw = String::from_utf8_lossy(&o.stdout);
7159 let lines: Vec<&str> = raw.lines().collect();
7160 let total = lines.len();
7161 out.push_str(&format!("=== Homebrew packages ({total}) ===\n"));
7162 for line in lines.iter().take(n) {
7163 out.push_str(&format!(" {line}\n"));
7164 }
7165 if total > n {
7166 out.push_str(&format!(" ... and {} more\n", total - n));
7167 }
7168 out.push_str("\nFor full list: brew list --versions\n");
7169 }
7170 } else {
7171 out.push_str("Homebrew not found.\n");
7172 }
7173 if let Ok(o) = Command::new("mas").args(["list"]).output() {
7174 if o.status.success() {
7175 let raw = String::from_utf8_lossy(&o.stdout);
7176 let lines: Vec<&str> = raw.lines().collect();
7177 out.push_str(&format!("\n=== Mac App Store apps ({}) ===\n", lines.len()));
7178 for line in lines.iter().take(n) {
7179 out.push_str(&format!(" {line}\n"));
7180 }
7181 }
7182 }
7183 }
7184
7185 Ok(out.trim_end().to_string())
7186}
7187
7188fn inspect_git_config() -> Result<String, String> {
7191 let mut out = String::from("Host inspection: git_config\n\n");
7192
7193 if let Ok(o) = Command::new("git").args(["--version"]).output() {
7194 let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
7195 out.push_str(&format!("Git: {ver}\n\n"));
7196 } else {
7197 out.push_str("Git: not found on PATH.\n");
7198 return Ok(out.trim_end().to_string());
7199 }
7200
7201 if let Ok(o) = Command::new("git")
7202 .args(["config", "--global", "--list"])
7203 .output()
7204 {
7205 if o.status.success() {
7206 let raw = String::from_utf8_lossy(&o.stdout);
7207 let mut pairs: Vec<(String, String)> = raw
7208 .lines()
7209 .filter_map(|l| {
7210 let mut parts = l.splitn(2, '=');
7211 let k = parts.next()?.trim().to_string();
7212 let v = parts.next().unwrap_or("").trim().to_string();
7213 Some((k, v))
7214 })
7215 .collect();
7216 pairs.sort_by(|a, b| a.0.cmp(&b.0));
7217
7218 out.push_str("=== Global git config ===\n");
7219
7220 let sections: &[(&str, &[&str])] = &[
7221 ("Identity", &["user.name", "user.email", "user.signingkey"]),
7222 (
7223 "Core",
7224 &[
7225 "core.editor",
7226 "core.autocrlf",
7227 "core.eol",
7228 "core.ignorecase",
7229 "core.filemode",
7230 ],
7231 ),
7232 (
7233 "Commit/Signing",
7234 &[
7235 "commit.gpgsign",
7236 "tag.gpgsign",
7237 "gpg.format",
7238 "gpg.ssh.allowedsignersfile",
7239 ],
7240 ),
7241 (
7242 "Push/Pull",
7243 &[
7244 "push.default",
7245 "push.autosetupremote",
7246 "pull.rebase",
7247 "pull.ff",
7248 ],
7249 ),
7250 ("Credential", &["credential.helper"]),
7251 ("Branch", &["init.defaultbranch", "branch.autosetuprebase"]),
7252 ];
7253
7254 let mut shown_keys: HashSet<String> = HashSet::new();
7255 for (section, keys) in sections {
7256 let mut section_lines: Vec<String> = Vec::new();
7257 for key in *keys {
7258 if let Some((k, v)) = pairs.iter().find(|(kk, _)| kk == key) {
7259 section_lines.push(format!(" {k} = {v}"));
7260 shown_keys.insert(k.clone());
7261 }
7262 }
7263 if !section_lines.is_empty() {
7264 out.push_str(&format!("\n[{section}]\n"));
7265 for line in section_lines {
7266 out.push_str(&format!("{line}\n"));
7267 }
7268 }
7269 }
7270
7271 let other: Vec<&(String, String)> = pairs
7272 .iter()
7273 .filter(|(k, _)| !shown_keys.contains(k) && !k.starts_with("alias."))
7274 .collect();
7275 if !other.is_empty() {
7276 out.push_str("\n[Other]\n");
7277 for (k, v) in other.iter().take(20) {
7278 out.push_str(&format!(" {k} = {v}\n"));
7279 }
7280 if other.len() > 20 {
7281 out.push_str(&format!(" ... and {} more\n", other.len() - 20));
7282 }
7283 }
7284
7285 out.push_str(&format!("\nTotal global config keys: {}\n", pairs.len()));
7286 } else {
7287 out.push_str("No global git config found.\n");
7288 out.push_str("Set up with:\n");
7289 out.push_str(" git config --global user.name \"Your Name\"\n");
7290 out.push_str(" git config --global user.email \"you@example.com\"\n");
7291 }
7292 }
7293
7294 if let Ok(o) = Command::new("git")
7295 .args(["config", "--local", "--list"])
7296 .output()
7297 {
7298 if o.status.success() {
7299 let raw = String::from_utf8_lossy(&o.stdout);
7300 let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
7301 if !lines.is_empty() {
7302 out.push_str(&format!(
7303 "\n=== Local repo config ({} keys) ===\n",
7304 lines.len()
7305 ));
7306 for line in lines.iter().take(15) {
7307 out.push_str(&format!(" {line}\n"));
7308 }
7309 if lines.len() > 15 {
7310 out.push_str(&format!(" ... and {} more\n", lines.len() - 15));
7311 }
7312 }
7313 }
7314 }
7315
7316 if let Ok(o) = Command::new("git")
7317 .args(["config", "--global", "--get-regexp", r"alias\."])
7318 .output()
7319 {
7320 if o.status.success() {
7321 let raw = String::from_utf8_lossy(&o.stdout);
7322 let aliases: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
7323 if !aliases.is_empty() {
7324 out.push_str(&format!("\n=== Git aliases ({}) ===\n", aliases.len()));
7325 for a in aliases.iter().take(20) {
7326 out.push_str(&format!(" {a}\n"));
7327 }
7328 if aliases.len() > 20 {
7329 out.push_str(&format!(" ... and {} more\n", aliases.len() - 20));
7330 }
7331 }
7332 }
7333 }
7334
7335 Ok(out.trim_end().to_string())
7336}
7337
7338fn inspect_databases() -> Result<String, String> {
7341 let mut out = String::from("Host inspection: databases\n\n");
7342 out.push_str("Scanning for local database engines (service state, port, version)...\n\n");
7343
7344 struct DbEngine {
7345 name: &'static str,
7346 service_names: &'static [&'static str],
7347 default_port: u16,
7348 cli_name: &'static str,
7349 cli_version_args: &'static [&'static str],
7350 }
7351
7352 let engines: &[DbEngine] = &[
7353 DbEngine {
7354 name: "PostgreSQL",
7355 service_names: &[
7356 "postgresql",
7357 "postgresql-x64-14",
7358 "postgresql-x64-15",
7359 "postgresql-x64-16",
7360 "postgresql-x64-17",
7361 ],
7362
7363 default_port: 5432,
7364 cli_name: "psql",
7365 cli_version_args: &["--version"],
7366 },
7367 DbEngine {
7368 name: "MySQL",
7369 service_names: &["mysql", "mysql80", "mysql57"],
7370
7371 default_port: 3306,
7372 cli_name: "mysql",
7373 cli_version_args: &["--version"],
7374 },
7375 DbEngine {
7376 name: "MariaDB",
7377 service_names: &["mariadb", "mariadb.exe"],
7378
7379 default_port: 3306,
7380 cli_name: "mariadb",
7381 cli_version_args: &["--version"],
7382 },
7383 DbEngine {
7384 name: "MongoDB",
7385 service_names: &["mongodb", "mongod"],
7386
7387 default_port: 27017,
7388 cli_name: "mongod",
7389 cli_version_args: &["--version"],
7390 },
7391 DbEngine {
7392 name: "Redis",
7393 service_names: &["redis", "redis-server"],
7394
7395 default_port: 6379,
7396 cli_name: "redis-server",
7397 cli_version_args: &["--version"],
7398 },
7399 DbEngine {
7400 name: "SQL Server",
7401 service_names: &["mssqlserver", "mssql$sqlexpress", "mssql$localdb"],
7402
7403 default_port: 1433,
7404 cli_name: "sqlcmd",
7405 cli_version_args: &["-?"],
7406 },
7407 DbEngine {
7408 name: "SQLite",
7409 service_names: &[], default_port: 0, cli_name: "sqlite3",
7413 cli_version_args: &["--version"],
7414 },
7415 DbEngine {
7416 name: "CouchDB",
7417 service_names: &["couchdb", "apache-couchdb"],
7418
7419 default_port: 5984,
7420 cli_name: "couchdb",
7421 cli_version_args: &["--version"],
7422 },
7423 DbEngine {
7424 name: "Cassandra",
7425 service_names: &["cassandra"],
7426
7427 default_port: 9042,
7428 cli_name: "cqlsh",
7429 cli_version_args: &["--version"],
7430 },
7431 DbEngine {
7432 name: "Elasticsearch",
7433 service_names: &["elasticsearch-service-x64", "elasticsearch"],
7434
7435 default_port: 9200,
7436 cli_name: "elasticsearch",
7437 cli_version_args: &["--version"],
7438 },
7439 ];
7440
7441 fn port_listening(port: u16) -> bool {
7443 if port == 0 {
7444 return false;
7445 }
7446 std::net::TcpStream::connect_timeout(
7448 &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
7449 std::time::Duration::from_millis(150),
7450 )
7451 .is_ok()
7452 }
7453
7454 let mut found_any = false;
7455
7456 for engine in engines {
7457 let mut status_parts: Vec<String> = Vec::new();
7458 let mut detected = false;
7459
7460 let version = Command::new(engine.cli_name)
7462 .args(engine.cli_version_args)
7463 .output()
7464 .ok()
7465 .and_then(|o| {
7466 let combined = if o.stdout.is_empty() {
7467 String::from_utf8_lossy(&o.stderr).trim().to_string()
7468 } else {
7469 String::from_utf8_lossy(&o.stdout).trim().to_string()
7470 };
7471 combined.lines().next().map(|l| l.trim().to_string())
7473 });
7474
7475 if let Some(ref ver) = version {
7476 if !ver.is_empty() {
7477 status_parts.push(format!("version: {ver}"));
7478 detected = true;
7479 }
7480 }
7481
7482 if engine.default_port > 0 && port_listening(engine.default_port) {
7484 status_parts.push(format!("listening on :{}", engine.default_port));
7485 detected = true;
7486 } else if engine.default_port > 0 && detected {
7487 status_parts.push(format!("not listening on :{}", engine.default_port));
7488 }
7489
7490 #[cfg(target_os = "windows")]
7492 {
7493 if !engine.service_names.is_empty() {
7494 let service_list = engine.service_names.join("','");
7495 let script = format!(
7496 r#"$names = @('{}'); foreach ($n in $names) {{ $s = Get-Service -Name $n -ErrorAction SilentlyContinue; if ($s) {{ $n + ':' + $s.Status; break }} }}"#,
7497 service_list
7498 );
7499 if let Ok(o) = Command::new("powershell")
7500 .args(["-NoProfile", "-Command", &script])
7501 .output()
7502 {
7503 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7504 if !text.is_empty() {
7505 let parts: Vec<&str> = text.splitn(2, ':').collect();
7506 let svc_name = parts.first().map(|s| s.trim()).unwrap_or("");
7507 let svc_state = parts.get(1).map(|s| s.trim()).unwrap_or("unknown");
7508 status_parts.push(format!("service '{svc_name}': {svc_state}"));
7509 detected = true;
7510 }
7511 }
7512 }
7513 }
7514
7515 #[cfg(not(target_os = "windows"))]
7517 {
7518 for svc in engine.service_names {
7519 if let Ok(o) = Command::new("systemctl").args(["is-active", svc]).output() {
7520 let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
7521 if !state.is_empty() && state != "inactive" {
7522 status_parts.push(format!("systemd '{svc}': {state}"));
7523 detected = true;
7524 break;
7525 }
7526 }
7527 }
7528 }
7529
7530 if detected {
7531 found_any = true;
7532 let label = if engine.default_port > 0 {
7533 format!("{} (default port: {})", engine.name, engine.default_port)
7534 } else {
7535 format!("{} (file-based, no port)", engine.name)
7536 };
7537 out.push_str(&format!("[FOUND] {label}\n"));
7538 for part in &status_parts {
7539 out.push_str(&format!(" {part}\n"));
7540 }
7541 out.push('\n');
7542 }
7543 }
7544
7545 if !found_any {
7546 out.push_str("No local database engines detected.\n");
7547 out.push_str("(Checked: PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQL Server, SQLite, CouchDB, Cassandra, Elasticsearch)\n\n");
7548 out.push_str(
7549 "Note: databases running inside Docker containers are listed under topic='docker'.\n",
7550 );
7551 } else {
7552 out.push_str("---\n");
7553 out.push_str(
7554 "Note: databases running inside Docker containers are listed under topic='docker'.\n",
7555 );
7556 out.push_str("This topic checks service state and port reachability only — no credentials or queries are used.\n");
7557 }
7558
7559 Ok(out.trim_end().to_string())
7560}
7561
7562fn inspect_user_accounts(max_entries: usize) -> Result<String, String> {
7565 let mut out = String::from("Host inspection: user_accounts\n\n");
7566
7567 #[cfg(target_os = "windows")]
7568 {
7569 let users_out = Command::new("powershell")
7570 .args([
7571 "-NoProfile", "-NonInteractive", "-Command",
7572 "Get-LocalUser | ForEach-Object { $logon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'never' }; \" $($_.Name) | Enabled: $($_.Enabled) | LastLogon: $logon | PwdRequired: $($_.PasswordRequired) | $($_.Description)\" }",
7573 ])
7574 .output()
7575 .ok()
7576 .and_then(|o| String::from_utf8(o.stdout).ok())
7577 .unwrap_or_default();
7578
7579 out.push_str("=== Local User Accounts ===\n");
7580 if users_out.trim().is_empty() {
7581 out.push_str(" (requires elevation or Get-LocalUser unavailable)\n");
7582 } else {
7583 for line in users_out.lines().take(max_entries) {
7584 if !line.trim().is_empty() {
7585 out.push_str(line);
7586 out.push('\n');
7587 }
7588 }
7589 }
7590
7591 let admins_out = Command::new("powershell")
7592 .args([
7593 "-NoProfile", "-NonInteractive", "-Command",
7594 "Get-LocalGroupMember -Group 'Administrators' 2>$null | ForEach-Object { \" $($_.ObjectClass): $($_.Name)\" }",
7595 ])
7596 .output()
7597 .ok()
7598 .and_then(|o| String::from_utf8(o.stdout).ok())
7599 .unwrap_or_default();
7600
7601 out.push_str("\n=== Administrators Group Members ===\n");
7602 if admins_out.trim().is_empty() {
7603 out.push_str(" (unable to retrieve)\n");
7604 } else {
7605 out.push_str(admins_out.trim());
7606 out.push('\n');
7607 }
7608
7609 let sessions_out = Command::new("powershell")
7610 .args([
7611 "-NoProfile",
7612 "-NonInteractive",
7613 "-Command",
7614 "query user 2>$null",
7615 ])
7616 .output()
7617 .ok()
7618 .and_then(|o| String::from_utf8(o.stdout).ok())
7619 .unwrap_or_default();
7620
7621 out.push_str("\n=== Active Logon Sessions ===\n");
7622 if sessions_out.trim().is_empty() {
7623 out.push_str(" (none or requires elevation)\n");
7624 } else {
7625 for line in sessions_out.lines().take(max_entries) {
7626 if !line.trim().is_empty() {
7627 out.push_str(&format!(" {}\n", line));
7628 }
7629 }
7630 }
7631
7632 let is_admin = Command::new("powershell")
7633 .args([
7634 "-NoProfile", "-NonInteractive", "-Command",
7635 "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
7636 ])
7637 .output()
7638 .ok()
7639 .and_then(|o| String::from_utf8(o.stdout).ok())
7640 .map(|s| s.trim().to_lowercase())
7641 .unwrap_or_default();
7642
7643 out.push_str("\n=== Current Session Elevation ===\n");
7644 out.push_str(&format!(
7645 " Running as Administrator: {}\n",
7646 if is_admin.contains("true") {
7647 "YES"
7648 } else {
7649 "no"
7650 }
7651 ));
7652 }
7653
7654 #[cfg(not(target_os = "windows"))]
7655 {
7656 let who_out = Command::new("who")
7657 .output()
7658 .ok()
7659 .and_then(|o| String::from_utf8(o.stdout).ok())
7660 .unwrap_or_default();
7661 out.push_str("=== Active Sessions ===\n");
7662 if who_out.trim().is_empty() {
7663 out.push_str(" (none)\n");
7664 } else {
7665 for line in who_out.lines().take(max_entries) {
7666 out.push_str(&format!(" {}\n", line));
7667 }
7668 }
7669 let id_out = Command::new("id")
7670 .output()
7671 .ok()
7672 .and_then(|o| String::from_utf8(o.stdout).ok())
7673 .unwrap_or_default();
7674 out.push_str(&format!("\n=== Current User ===\n {}\n", id_out.trim()));
7675 }
7676
7677 Ok(out.trim_end().to_string())
7678}
7679
7680fn inspect_audit_policy() -> Result<String, String> {
7683 let mut out = String::from("Host inspection: audit_policy\n\n");
7684
7685 #[cfg(target_os = "windows")]
7686 {
7687 let auditpol_out = Command::new("auditpol")
7688 .args(["/get", "/category:*"])
7689 .output()
7690 .ok()
7691 .and_then(|o| String::from_utf8(o.stdout).ok())
7692 .unwrap_or_default();
7693
7694 if auditpol_out.trim().is_empty()
7695 || auditpol_out.to_lowercase().contains("access is denied")
7696 {
7697 out.push_str("Audit policy requires Administrator elevation to read.\n");
7698 out.push_str(
7699 "Run Hematite as Administrator, or check manually: auditpol /get /category:*\n",
7700 );
7701 } else {
7702 out.push_str("=== Windows Audit Policy ===\n");
7703 let mut any_enabled = false;
7704 for line in auditpol_out.lines() {
7705 let trimmed = line.trim();
7706 if trimmed.is_empty() {
7707 continue;
7708 }
7709 if trimmed.contains("Success") || trimmed.contains("Failure") {
7710 out.push_str(&format!(" [ENABLED] {}\n", trimmed));
7711 any_enabled = true;
7712 } else {
7713 out.push_str(&format!(" {}\n", trimmed));
7714 }
7715 }
7716 if !any_enabled {
7717 out.push_str("\n[WARNING] No audit categories are enabled — security events will not be logged.\n");
7718 out.push_str(
7719 "Minimum recommended: enable Logon/Logoff and Account Logon success+failure.\n",
7720 );
7721 }
7722 }
7723
7724 let evtlog = Command::new("powershell")
7725 .args([
7726 "-NoProfile", "-NonInteractive", "-Command",
7727 "Get-Service EventLog -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Status",
7728 ])
7729 .output()
7730 .ok()
7731 .and_then(|o| String::from_utf8(o.stdout).ok())
7732 .map(|s| s.trim().to_string())
7733 .unwrap_or_default();
7734
7735 out.push_str(&format!(
7736 "\n=== Windows Event Log Service ===\n Status: {}\n",
7737 if evtlog.is_empty() {
7738 "unknown".to_string()
7739 } else {
7740 evtlog
7741 }
7742 ));
7743 }
7744
7745 #[cfg(not(target_os = "windows"))]
7746 {
7747 let auditd_status = Command::new("systemctl")
7748 .args(["is-active", "auditd"])
7749 .output()
7750 .ok()
7751 .and_then(|o| String::from_utf8(o.stdout).ok())
7752 .map(|s| s.trim().to_string())
7753 .unwrap_or_else(|| "not found".to_string());
7754
7755 out.push_str(&format!(
7756 "=== auditd service ===\n Status: {}\n",
7757 auditd_status
7758 ));
7759
7760 if auditd_status == "active" {
7761 let rules = Command::new("auditctl")
7762 .args(["-l"])
7763 .output()
7764 .ok()
7765 .and_then(|o| String::from_utf8(o.stdout).ok())
7766 .unwrap_or_default();
7767 out.push_str("\n=== Active Audit Rules ===\n");
7768 if rules.trim().is_empty() || rules.contains("No rules") {
7769 out.push_str(" No rules configured.\n");
7770 } else {
7771 for line in rules.lines() {
7772 out.push_str(&format!(" {}\n", line));
7773 }
7774 }
7775 }
7776 }
7777
7778 Ok(out.trim_end().to_string())
7779}
7780
7781fn inspect_shares(max_entries: usize) -> Result<String, String> {
7784 let mut out = String::from("Host inspection: shares\n\n");
7785
7786 #[cfg(target_os = "windows")]
7787 {
7788 let smb_out = Command::new("powershell")
7789 .args([
7790 "-NoProfile", "-NonInteractive", "-Command",
7791 "Get-SmbShare | ForEach-Object { \" $($_.Name) | Path: $($_.Path) | State: $($_.ShareState) | Encrypted: $($_.EncryptData) | $($_.Description)\" }",
7792 ])
7793 .output()
7794 .ok()
7795 .and_then(|o| String::from_utf8(o.stdout).ok())
7796 .unwrap_or_default();
7797
7798 out.push_str("=== SMB Shares (exposed by this machine) ===\n");
7799 let smb_lines: Vec<&str> = smb_out
7800 .lines()
7801 .filter(|l| !l.trim().is_empty())
7802 .take(max_entries)
7803 .collect();
7804 if smb_lines.is_empty() {
7805 out.push_str(" No SMB shares or unable to retrieve.\n");
7806 } else {
7807 for line in &smb_lines {
7808 let name = line.trim().split('|').next().unwrap_or("").trim();
7809 if name.ends_with('$') {
7810 out.push_str(&format!(" {}\n", line.trim()));
7811 } else {
7812 out.push_str(&format!(" [CUSTOM] {}\n", line.trim()));
7813 }
7814 }
7815 }
7816
7817 let smb_security = Command::new("powershell")
7818 .args([
7819 "-NoProfile", "-NonInteractive", "-Command",
7820 "Get-SmbServerConfiguration | ForEach-Object { \" SMB1: $($_.EnableSMB1Protocol) | SMB2: $($_.EnableSMB2Protocol) | Signing Required: $($_.RequireSecuritySignature) | Encryption: $($_.EncryptData)\" }",
7821 ])
7822 .output()
7823 .ok()
7824 .and_then(|o| String::from_utf8(o.stdout).ok())
7825 .unwrap_or_default();
7826
7827 out.push_str("\n=== SMB Server Security Settings ===\n");
7828 if smb_security.trim().is_empty() {
7829 out.push_str(" (unable to retrieve)\n");
7830 } else {
7831 out.push_str(smb_security.trim());
7832 out.push('\n');
7833 if smb_security.to_lowercase().contains("smb1: true") {
7834 out.push_str(" [WARNING] SMB1 is ENABLED — disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\n");
7835 }
7836 }
7837
7838 let drives_out = Command::new("powershell")
7839 .args([
7840 "-NoProfile", "-NonInteractive", "-Command",
7841 "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { \" $($_.Name): -> $($_.DisplayRoot)\" }",
7842 ])
7843 .output()
7844 .ok()
7845 .and_then(|o| String::from_utf8(o.stdout).ok())
7846 .unwrap_or_default();
7847
7848 out.push_str("\n=== Mapped Network Drives ===\n");
7849 if drives_out.trim().is_empty() {
7850 out.push_str(" None.\n");
7851 } else {
7852 for line in drives_out.lines().take(max_entries) {
7853 if !line.trim().is_empty() {
7854 out.push_str(line);
7855 out.push('\n');
7856 }
7857 }
7858 }
7859 }
7860
7861 #[cfg(not(target_os = "windows"))]
7862 {
7863 let smb_conf = std::fs::read_to_string("/etc/samba/smb.conf").unwrap_or_default();
7864 out.push_str("=== Samba Config (/etc/samba/smb.conf) ===\n");
7865 if smb_conf.is_empty() {
7866 out.push_str(" Not found or Samba not installed.\n");
7867 } else {
7868 for line in smb_conf.lines().take(max_entries) {
7869 out.push_str(&format!(" {}\n", line));
7870 }
7871 }
7872 let nfs_exports = std::fs::read_to_string("/etc/exports").unwrap_or_default();
7873 out.push_str("\n=== NFS Exports (/etc/exports) ===\n");
7874 if nfs_exports.is_empty() {
7875 out.push_str(" Not configured.\n");
7876 } else {
7877 for line in nfs_exports.lines().take(max_entries) {
7878 out.push_str(&format!(" {}\n", line));
7879 }
7880 }
7881 }
7882
7883 Ok(out.trim_end().to_string())
7884}
7885
7886fn inspect_dns_servers() -> Result<String, String> {
7889 let mut out = String::from("Host inspection: dns_servers\n\n");
7890
7891 #[cfg(target_os = "windows")]
7892 {
7893 let dns_out = Command::new("powershell")
7894 .args([
7895 "-NoProfile", "-NonInteractive", "-Command",
7896 "Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses.Count -gt 0 } | ForEach-Object { $addrs = $_.ServerAddresses -join ', '; \" $($_.InterfaceAlias) (AF $($_.AddressFamily)): $addrs\" }",
7897 ])
7898 .output()
7899 .ok()
7900 .and_then(|o| String::from_utf8(o.stdout).ok())
7901 .unwrap_or_default();
7902
7903 out.push_str("=== Configured DNS Resolvers (per adapter) ===\n");
7904 if dns_out.trim().is_empty() {
7905 out.push_str(" (unable to retrieve)\n");
7906 } else {
7907 for line in dns_out.lines() {
7908 if line.trim().is_empty() {
7909 continue;
7910 }
7911 let mut annotation = "";
7912 if line.contains("8.8.8.8") || line.contains("8.8.4.4") {
7913 annotation = " <- Google Public DNS";
7914 } else if line.contains("1.1.1.1") || line.contains("1.0.0.1") {
7915 annotation = " <- Cloudflare DNS";
7916 } else if line.contains("9.9.9.9") {
7917 annotation = " <- Quad9";
7918 } else if line.contains("208.67.222") || line.contains("208.67.220") {
7919 annotation = " <- OpenDNS";
7920 }
7921 out.push_str(line);
7922 out.push_str(annotation);
7923 out.push('\n');
7924 }
7925 }
7926
7927 let doh_out = Command::new("powershell")
7928 .args([
7929 "-NoProfile", "-NonInteractive", "-Command",
7930 "Get-DnsClientDohServerAddress 2>$null | ForEach-Object { \" $($_.ServerAddress): $($_.DohTemplate)\" }",
7931 ])
7932 .output()
7933 .ok()
7934 .and_then(|o| String::from_utf8(o.stdout).ok())
7935 .unwrap_or_default();
7936
7937 out.push_str("\n=== DNS over HTTPS (DoH) ===\n");
7938 if doh_out.trim().is_empty() {
7939 out.push_str(" Not configured (plain DNS).\n");
7940 } else {
7941 out.push_str(doh_out.trim());
7942 out.push('\n');
7943 }
7944
7945 let suffixes = Command::new("powershell")
7946 .args([
7947 "-NoProfile", "-NonInteractive", "-Command",
7948 "Get-DnsClientGlobalSetting | Select-Object -ExpandProperty SuffixSearchList | ForEach-Object { \" $_\" }",
7949 ])
7950 .output()
7951 .ok()
7952 .and_then(|o| String::from_utf8(o.stdout).ok())
7953 .unwrap_or_default();
7954
7955 if !suffixes.trim().is_empty() {
7956 out.push_str("\n=== DNS Search Suffix List ===\n");
7957 out.push_str(suffixes.trim());
7958 out.push('\n');
7959 }
7960 }
7961
7962 #[cfg(not(target_os = "windows"))]
7963 {
7964 let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
7965 out.push_str("=== /etc/resolv.conf ===\n");
7966 if resolv.is_empty() {
7967 out.push_str(" Not found.\n");
7968 } else {
7969 for line in resolv.lines() {
7970 if !line.trim().is_empty() && !line.starts_with('#') {
7971 out.push_str(&format!(" {}\n", line));
7972 }
7973 }
7974 }
7975 let resolved_out = Command::new("resolvectl")
7976 .args(["status", "--no-pager"])
7977 .output()
7978 .ok()
7979 .and_then(|o| String::from_utf8(o.stdout).ok())
7980 .unwrap_or_default();
7981 if !resolved_out.is_empty() {
7982 out.push_str("\n=== systemd-resolved ===\n");
7983 for line in resolved_out.lines().take(30) {
7984 out.push_str(&format!(" {}\n", line));
7985 }
7986 }
7987 }
7988
7989 Ok(out.trim_end().to_string())
7990}
7991
7992fn inspect_bitlocker() -> Result<String, String> {
7993 let mut out = String::from("Host inspection: bitlocker\n\n");
7994
7995 #[cfg(target_os = "windows")]
7996 {
7997 let ps_cmd = "Get-BitLockerVolume | Select-Object MountPoint, VolumeStatus, ProtectionStatus, EncryptionPercentage | ForEach-Object { \"$($_.MountPoint) [$($_.VolumeStatus)] Protection:$($_.ProtectionStatus) ($($_.EncryptionPercentage)%)\" }";
7998 let output = Command::new("powershell")
7999 .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
8000 .output()
8001 .map_err(|e| format!("Failed to execute PowerShell: {e}"))?;
8002
8003 let stdout = String::from_utf8(output.stdout).unwrap_or_default();
8004 let stderr = String::from_utf8(output.stderr).unwrap_or_default();
8005
8006 if !stdout.trim().is_empty() {
8007 out.push_str("=== BitLocker Volumes ===\n");
8008 for line in stdout.lines() {
8009 out.push_str(&format!(" {}\n", line));
8010 }
8011 } else if !stderr.trim().is_empty() {
8012 if stderr.contains("Access is denied") {
8013 out.push_str("Error: Access denied. BitLocker diagnostics require Administrator elevation.\n");
8014 } else {
8015 out.push_str(&format!(
8016 "Error retrieving BitLocker info: {}\n",
8017 stderr.trim()
8018 ));
8019 }
8020 } else {
8021 out.push_str("No BitLocker volumes detected or access denied.\n");
8022 }
8023 }
8024
8025 #[cfg(not(target_os = "windows"))]
8026 {
8027 out.push_str(
8028 "BitLocker is a Windows-specific technology. Checking for LUKS/dm-crypt...\n\n",
8029 );
8030 let lsblk = Command::new("lsblk")
8031 .args(["-f", "-o", "NAME,FSTYPE,MOUNTPOINT"])
8032 .output()
8033 .ok()
8034 .and_then(|o| String::from_utf8(o.stdout).ok())
8035 .unwrap_or_default();
8036 if lsblk.contains("crypto_LUKS") {
8037 out.push_str("=== LUKS Encrypted Volumes ===\n");
8038 for line in lsblk.lines().filter(|l| l.contains("crypto_LUKS")) {
8039 out.push_str(&format!(" {}\n", line));
8040 }
8041 } else {
8042 out.push_str("No LUKS encrypted volumes detected via lsblk.\n");
8043 }
8044 }
8045
8046 Ok(out.trim_end().to_string())
8047}
8048
8049fn inspect_rdp() -> Result<String, String> {
8050 let mut out = String::from("Host inspection: rdp\n\n");
8051
8052 #[cfg(target_os = "windows")]
8053 {
8054 let reg_path = "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server";
8055 let f_deny = Command::new("powershell")
8056 .args([
8057 "-NoProfile",
8058 "-Command",
8059 &format!("(Get-ItemProperty '{}').fDenyTSConnections", reg_path),
8060 ])
8061 .output()
8062 .ok()
8063 .and_then(|o| String::from_utf8(o.stdout).ok())
8064 .unwrap_or_default()
8065 .trim()
8066 .to_string();
8067
8068 let status = if f_deny == "0" { "ENABLED" } else { "DISABLED" };
8069 out.push_str(&format!("=== RDP Status: {} ===\n", status));
8070
8071 let port = Command::new("powershell").args(["-NoProfile", "-Command", "Get-ItemProperty 'HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp' -Name PortNumber | Select-Object -ExpandProperty PortNumber"])
8072 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default().trim().to_string();
8073 out.push_str(&format!(
8074 " Port: {}\n",
8075 if port.is_empty() {
8076 "3389 (default)"
8077 } else {
8078 &port
8079 }
8080 ));
8081
8082 let nla = Command::new("powershell")
8083 .args([
8084 "-NoProfile",
8085 "-Command",
8086 &format!("(Get-ItemProperty '{}').UserAuthentication", reg_path),
8087 ])
8088 .output()
8089 .ok()
8090 .and_then(|o| String::from_utf8(o.stdout).ok())
8091 .unwrap_or_default()
8092 .trim()
8093 .to_string();
8094 out.push_str(&format!(
8095 " NLA Required: {}\n",
8096 if nla == "1" { "Yes" } else { "No" }
8097 ));
8098
8099 out.push_str("\n=== Active Sessions ===\n");
8100 let qwinsta = Command::new("qwinsta")
8101 .output()
8102 .ok()
8103 .and_then(|o| String::from_utf8(o.stdout).ok())
8104 .unwrap_or_default();
8105 if qwinsta.trim().is_empty() {
8106 out.push_str(" No active sessions listed.\n");
8107 } else {
8108 for line in qwinsta.lines() {
8109 out.push_str(&format!(" {}\n", line));
8110 }
8111 }
8112
8113 out.push_str("\n=== Firewall Rule Check ===\n");
8114 let fw = Command::new("powershell").args(["-NoProfile", "-Command", "Get-NetFirewallRule -DisplayName '*Remote Desktop*' -Enabled True | Select-Object DisplayName, Action, Direction | ForEach-Object { \" $($_.DisplayName): $($_.Action) ($($_.Direction))\" }"])
8115 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8116 if fw.trim().is_empty() {
8117 out.push_str(" No enabled RDP firewall rules found.\n");
8118 } else {
8119 out.push_str(fw.trim_end());
8120 out.push('\n');
8121 }
8122 }
8123
8124 #[cfg(not(target_os = "windows"))]
8125 {
8126 out.push_str("Checking for common RDP/VNC listeners (3389, 5900-5905)...\n");
8127 let ss = Command::new("ss")
8128 .args(["-tlnp"])
8129 .output()
8130 .ok()
8131 .and_then(|o| String::from_utf8(o.stdout).ok())
8132 .unwrap_or_default();
8133 let matches: Vec<&str> = ss
8134 .lines()
8135 .filter(|l| l.contains(":3389") || l.contains(":590"))
8136 .collect();
8137 if matches.is_empty() {
8138 out.push_str(" No RDP/VNC listeners detected via 'ss'.\n");
8139 } else {
8140 for m in matches {
8141 out.push_str(&format!(" {}\n", m));
8142 }
8143 }
8144 }
8145
8146 Ok(out.trim_end().to_string())
8147}
8148
8149fn inspect_shadow_copies() -> Result<String, String> {
8150 let mut out = String::from("Host inspection: shadow_copies\n\n");
8151
8152 #[cfg(target_os = "windows")]
8153 {
8154 let output = Command::new("vssadmin")
8155 .args(["list", "shadows"])
8156 .output()
8157 .map_err(|e| format!("Failed to run vssadmin: {e}"))?;
8158 let stdout = String::from_utf8(output.stdout).unwrap_or_default();
8159
8160 if stdout.contains("No items found") || stdout.trim().is_empty() {
8161 out.push_str("No Volume Shadow Copies found.\n");
8162 } else {
8163 out.push_str("=== Volume Shadow Copies ===\n");
8164 for line in stdout.lines().take(50) {
8165 if line.contains("Creation Time:")
8166 || line.contains("Contents:")
8167 || line.contains("Volume Name:")
8168 {
8169 out.push_str(&format!(" {}\n", line.trim()));
8170 }
8171 }
8172 }
8173
8174 out.push_str("\n=== Shadow Copy Storage ===\n");
8175 let storage_out = Command::new("vssadmin")
8176 .args(["list", "shadowstorage"])
8177 .output()
8178 .ok();
8179 if let Some(o) = storage_out {
8180 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8181 for line in stdout.lines() {
8182 if line.contains("Used Shadow Copy Storage space:")
8183 || line.contains("Max Shadow Copy Storage space:")
8184 {
8185 out.push_str(&format!(" {}\n", line.trim()));
8186 }
8187 }
8188 }
8189 }
8190
8191 #[cfg(not(target_os = "windows"))]
8192 {
8193 out.push_str("Checking for LVM snapshots or Btrfs subvolumes...\n\n");
8194 let lvs = Command::new("lvs")
8195 .output()
8196 .ok()
8197 .and_then(|o| String::from_utf8(o.stdout).ok())
8198 .unwrap_or_default();
8199 if !lvs.is_empty() {
8200 out.push_str("=== LVM Volumes (checking for snapshots) ===\n");
8201 out.push_str(&lvs);
8202 } else {
8203 out.push_str("No LVM volumes detected.\n");
8204 }
8205 }
8206
8207 Ok(out.trim_end().to_string())
8208}
8209
8210fn inspect_pagefile() -> Result<String, String> {
8211 let mut out = String::from("Host inspection: pagefile\n\n");
8212
8213 #[cfg(target_os = "windows")]
8214 {
8215 let ps_cmd = "Get-CimInstance Win32_PageFileUsage | Select-Object Name, AllocatedBaseSize, CurrentUsage, PeakUsage | ForEach-Object { \" $($_.Name): $($_.AllocatedBaseSize)MB total, $($_.CurrentUsage)MB used (Peak: $($_.PeakUsage)MB)\" }";
8216 let output = Command::new("powershell")
8217 .args(["-NoProfile", "-Command", ps_cmd])
8218 .output()
8219 .ok()
8220 .and_then(|o| String::from_utf8(o.stdout).ok())
8221 .unwrap_or_default();
8222
8223 if output.trim().is_empty() {
8224 out.push_str("No page files detected (system may be running without a page file or managed differently).\n");
8225 let managed = Command::new("powershell")
8226 .args([
8227 "-NoProfile",
8228 "-Command",
8229 "(Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile",
8230 ])
8231 .output()
8232 .ok()
8233 .and_then(|o| String::from_utf8(o.stdout).ok())
8234 .unwrap_or_default()
8235 .trim()
8236 .to_string();
8237 out.push_str(&format!("Automatic Managed Pagefile: {}\n", managed));
8238 } else {
8239 out.push_str("=== Page File Usage ===\n");
8240 out.push_str(&output);
8241 }
8242 }
8243
8244 #[cfg(not(target_os = "windows"))]
8245 {
8246 out.push_str("=== Swap Usage (Linux/macOS) ===\n");
8247 let swap = Command::new("swapon")
8248 .args(["--show"])
8249 .output()
8250 .ok()
8251 .and_then(|o| String::from_utf8(o.stdout).ok())
8252 .unwrap_or_default();
8253 if swap.is_empty() {
8254 let free = Command::new("free")
8255 .args(["-h"])
8256 .output()
8257 .ok()
8258 .and_then(|o| String::from_utf8(o.stdout).ok())
8259 .unwrap_or_default();
8260 out.push_str(&free);
8261 } else {
8262 out.push_str(&swap);
8263 }
8264 }
8265
8266 Ok(out.trim_end().to_string())
8267}
8268
8269fn inspect_windows_features(max_entries: usize) -> Result<String, String> {
8270 let mut out = String::from("Host inspection: windows_features\n\n");
8271
8272 #[cfg(target_os = "windows")]
8273 {
8274 out.push_str("=== Quick Check: Notable Features ===\n");
8275 let quick_ps = "Get-WindowsOptionalFeature -Online | Where-Object { $_.FeatureName -match 'IIS|Hyper-V|VirtualMachinePlatform|Subsystem-Linux' -and $_.State -eq 'Enabled' } | Select-Object -ExpandProperty FeatureName";
8276 let output = Command::new("powershell")
8277 .args(["-NoProfile", "-Command", quick_ps])
8278 .output()
8279 .ok();
8280
8281 if let Some(o) = output {
8282 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8283 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
8284
8285 if !stdout.trim().is_empty() {
8286 for f in stdout.lines() {
8287 out.push_str(&format!(" [ENABLED] {}\n", f));
8288 }
8289 } else if stderr.contains("Access is denied") || stderr.contains("requires elevation") {
8290 out.push_str(" Error: Access denied. Listing Windows Features requires Administrator elevation.\n");
8291 } else if quick_ps.contains("-Online") && stdout.trim().is_empty() {
8292 out.push_str(
8293 " No major features (IIS, Hyper-V, WSL) appear enabled in the quick check.\n",
8294 );
8295 }
8296 }
8297
8298 out.push_str(&format!(
8299 "\n=== All Enabled Features (capped at {}) ===\n",
8300 max_entries
8301 ));
8302 let all_ps = format!("Get-WindowsOptionalFeature -Online | Where-Object {{$_.State -eq 'Enabled'}} | Select-Object -First {} -ExpandProperty FeatureName", max_entries);
8303 let all_out = Command::new("powershell")
8304 .args(["-NoProfile", "-Command", &all_ps])
8305 .output()
8306 .ok();
8307 if let Some(o) = all_out {
8308 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8309 if !stdout.trim().is_empty() {
8310 out.push_str(&stdout);
8311 }
8312 }
8313 }
8314
8315 #[cfg(not(target_os = "windows"))]
8316 {
8317 let _ = max_entries;
8318 out.push_str("Windows Optional Features are Windows-specific. On Linux, check your package manager.\n");
8319 }
8320
8321 Ok(out.trim_end().to_string())
8322}
8323
8324fn inspect_printers(max_entries: usize) -> Result<String, String> {
8325 let mut out = String::from("Host inspection: printers\n\n");
8326
8327 #[cfg(target_os = "windows")]
8328 {
8329 let list = Command::new("powershell").args(["-NoProfile", "-Command", &format!("Get-Printer | Select-Object Name, DriverName, PortName, JobCount | Select-Object -First {} | ForEach-Object {{ \" $($_.Name) [$($_.DriverName)] (Port: $($_.PortName), Jobs: $($_.JobCount))\" }}", max_entries)])
8330 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8331 if list.trim().is_empty() {
8332 out.push_str("No printers detected.\n");
8333 } else {
8334 out.push_str("=== Installed Printers ===\n");
8335 out.push_str(&list);
8336 }
8337
8338 let jobs = Command::new("powershell").args(["-NoProfile", "-Command", "Get-PrintJob | Select-Object PrinterName, ID, DocumentName, Status | ForEach-Object { \" [$($_.PrinterName)] Job $($_.ID): $($_.DocumentName) - $($_.Status)\" }"])
8339 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8340 if !jobs.trim().is_empty() {
8341 out.push_str("\n=== Active Print Jobs ===\n");
8342 out.push_str(&jobs);
8343 }
8344 }
8345
8346 #[cfg(not(target_os = "windows"))]
8347 {
8348 let _ = max_entries;
8349 out.push_str("Checking LPSTAT for printers...\n");
8350 let lpstat = Command::new("lpstat")
8351 .args(["-p", "-d"])
8352 .output()
8353 .ok()
8354 .and_then(|o| String::from_utf8(o.stdout).ok())
8355 .unwrap_or_default();
8356 if lpstat.is_empty() {
8357 out.push_str(" No CUPS/LP printers found.\n");
8358 } else {
8359 out.push_str(&lpstat);
8360 }
8361 }
8362
8363 Ok(out.trim_end().to_string())
8364}
8365
8366fn inspect_winrm() -> Result<String, String> {
8367 let mut out = String::from("Host inspection: winrm\n\n");
8368
8369 #[cfg(target_os = "windows")]
8370 {
8371 let svc = Command::new("powershell")
8372 .args(["-NoProfile", "-Command", "(Get-Service WinRM).Status"])
8373 .output()
8374 .ok()
8375 .and_then(|o| String::from_utf8(o.stdout).ok())
8376 .unwrap_or_default()
8377 .trim()
8378 .to_string();
8379 out.push_str(&format!(
8380 "WinRM Service Status: {}\n\n",
8381 if svc.is_empty() { "NOT_FOUND" } else { &svc }
8382 ));
8383
8384 out.push_str("=== WinRM Listeners ===\n");
8385 let output = Command::new("powershell")
8386 .args([
8387 "-NoProfile",
8388 "-Command",
8389 "winrm enumerate winrm/config/listener 2>$null",
8390 ])
8391 .output()
8392 .ok();
8393 if let Some(o) = output {
8394 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8395 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
8396
8397 if !stdout.trim().is_empty() {
8398 for line in stdout.lines() {
8399 if line.contains("Address =")
8400 || line.contains("Transport =")
8401 || line.contains("Port =")
8402 {
8403 out.push_str(&format!(" {}\n", line.trim()));
8404 }
8405 }
8406 } else if stderr.contains("Access is denied") {
8407 out.push_str(" Error: Access denied to WinRM configuration.\n");
8408 } else {
8409 out.push_str(" No listeners configured.\n");
8410 }
8411 }
8412
8413 out.push_str("\n=== PowerShell Remoting Test (Local) ===\n");
8414 let test_out = Command::new("powershell").args(["-NoProfile", "-Command", "Test-WSMan -ErrorAction SilentlyContinue | Select-Object ProductVersion, Stack | ForEach-Object { \" SUCCESS: OS Version $($_.ProductVersion) (Stack $($_.Stack))\" }"])
8415 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8416 if test_out.trim().is_empty() {
8417 out.push_str(" WinRM not responding to local WS-Man requests.\n");
8418 } else {
8419 out.push_str(&test_out);
8420 }
8421 }
8422
8423 #[cfg(not(target_os = "windows"))]
8424 {
8425 out.push_str(
8426 "WinRM is primarily a Windows technology. Checking for listening port 5985/5986...\n",
8427 );
8428 let ss = Command::new("ss")
8429 .args(["-tln"])
8430 .output()
8431 .ok()
8432 .and_then(|o| String::from_utf8(o.stdout).ok())
8433 .unwrap_or_default();
8434 if ss.contains(":5985") || ss.contains(":5986") {
8435 out.push_str(" WinRM ports (5985/5986) are listening.\n");
8436 } else {
8437 out.push_str(" WinRM ports not detected.\n");
8438 }
8439 }
8440
8441 Ok(out.trim_end().to_string())
8442}
8443
8444fn inspect_network_stats(max_entries: usize) -> Result<String, String> {
8445 let mut out = String::from("Host inspection: network_stats\n\n");
8446
8447 #[cfg(target_os = "windows")]
8448 {
8449 let ps_cmd = format!("Get-NetAdapterStatistics | Select-Object Name, ReceivedBytes, SentBytes, ReceivedPacketErrors, OutboundPacketErrors | Select-Object -First {} | ForEach-Object {{ \" $($_.Name): RX:$([math]::round($($_.ReceivedBytes)/1MB, 2))MB, TX:$([math]::round($($_.SentBytes)/1MB, 2))MB, Errors(RX/TX): $($_.ReceivedPacketErrors)/$($_.OutboundPacketErrors)\" }}", max_entries);
8450 let output = Command::new("powershell")
8451 .args(["-NoProfile", "-Command", &ps_cmd])
8452 .output()
8453 .ok()
8454 .and_then(|o| String::from_utf8(o.stdout).ok())
8455 .unwrap_or_default();
8456 if output.trim().is_empty() {
8457 out.push_str("No network adapter statistics available.\n");
8458 } else {
8459 out.push_str("=== Adapter Throughput & Errors ===\n");
8460 out.push_str(&output);
8461 }
8462
8463 let discards = Command::new("powershell").args(["-NoProfile", "-Command", "Get-NetAdapterStatistics | Select-Object Name, ReceivedPacketDiscards, OutboundPacketDiscards | ForEach-Object { if($_.ReceivedPacketDiscards -gt 0 -or $_.OutboundPacketDiscards -gt 0) { \" $($_.Name): Discards(RX/TX): $($_.ReceivedPacketDiscards)/$($_.OutboundPacketDiscards)\" } }"])
8464 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8465 if !discards.trim().is_empty() {
8466 out.push_str("\n=== Packet Discards (Non-Zero Only) ===\n");
8467 out.push_str(&discards);
8468 }
8469 }
8470
8471 #[cfg(not(target_os = "windows"))]
8472 {
8473 let _ = max_entries;
8474 out.push_str("=== Network Stats (ip -s link) ===\n");
8475 let ip_s = Command::new("ip")
8476 .args(["-s", "link"])
8477 .output()
8478 .ok()
8479 .and_then(|o| String::from_utf8(o.stdout).ok())
8480 .unwrap_or_default();
8481 if ip_s.is_empty() {
8482 let netstat = Command::new("netstat")
8483 .args(["-i"])
8484 .output()
8485 .ok()
8486 .and_then(|o| String::from_utf8(o.stdout).ok())
8487 .unwrap_or_default();
8488 out.push_str(&netstat);
8489 } else {
8490 out.push_str(&ip_s);
8491 }
8492 }
8493
8494 Ok(out.trim_end().to_string())
8495}
8496
8497fn inspect_udp_ports(max_entries: usize) -> Result<String, String> {
8498 let mut out = String::from("Host inspection: udp_ports\n\n");
8499
8500 #[cfg(target_os = "windows")]
8501 {
8502 let ps_cmd = format!("Get-NetUDPEndpoint | Select-Object LocalAddress, LocalPort, OwningProcess | Select-Object -First {} | ForEach-Object {{ $proc = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Name; \" $($_.LocalAddress):$($_.LocalPort) (PID: $($_.OwningProcess) - $($proc))\" }}", max_entries);
8503 let output = Command::new("powershell")
8504 .args(["-NoProfile", "-Command", &ps_cmd])
8505 .output()
8506 .ok();
8507
8508 if let Some(o) = output {
8509 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8510 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
8511
8512 if !stdout.trim().is_empty() {
8513 out.push_str("=== UDP Listeners (Local:Port) ===\n");
8514 for line in stdout.lines() {
8515 let mut note = "";
8516 if line.contains(":53 ") {
8517 note = " [DNS]";
8518 } else if line.contains(":67 ") || line.contains(":68 ") {
8519 note = " [DHCP]";
8520 } else if line.contains(":123 ") {
8521 note = " [NTP]";
8522 } else if line.contains(":161 ") {
8523 note = " [SNMP]";
8524 } else if line.contains(":1900 ") {
8525 note = " [SSDP/UPnP]";
8526 } else if line.contains(":5353 ") {
8527 note = " [mDNS]";
8528 }
8529
8530 out.push_str(&format!("{}{}\n", line, note));
8531 }
8532 } else if stderr.contains("Access is denied") {
8533 out.push_str("Error: Access denied. Full UDP listener details require Administrator elevation.\n");
8534 } else {
8535 out.push_str("No UDP listeners detected.\n");
8536 }
8537 }
8538 }
8539
8540 #[cfg(not(target_os = "windows"))]
8541 {
8542 let ss_out = Command::new("ss")
8543 .args(["-ulnp"])
8544 .output()
8545 .ok()
8546 .and_then(|o| String::from_utf8(o.stdout).ok())
8547 .unwrap_or_default();
8548 out.push_str("=== UDP Listeners (ss -ulnp) ===\n");
8549 if ss_out.is_empty() {
8550 let netstat_out = Command::new("netstat")
8551 .args(["-ulnp"])
8552 .output()
8553 .ok()
8554 .and_then(|o| String::from_utf8(o.stdout).ok())
8555 .unwrap_or_default();
8556 if netstat_out.is_empty() {
8557 out.push_str(" Neither 'ss' nor 'netstat' available.\n");
8558 } else {
8559 for line in netstat_out.lines().take(max_entries) {
8560 out.push_str(&format!(" {}\n", line));
8561 }
8562 }
8563 } else {
8564 for line in ss_out.lines().take(max_entries) {
8565 out.push_str(&format!(" {}\n", line));
8566 }
8567 }
8568 }
8569
8570 Ok(out.trim_end().to_string())
8571}
8572
8573fn inspect_gpo() -> Result<String, String> {
8574 let mut out = String::from("Host inspection: gpo\n\n");
8575
8576 #[cfg(target_os = "windows")]
8577 {
8578 let output = Command::new("gpresult")
8579 .args(["/r", "/scope", "computer"])
8580 .output()
8581 .ok();
8582
8583 if let Some(o) = output {
8584 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8585 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
8586
8587 if stdout.contains("Applied Group Policy Objects") {
8588 out.push_str("=== Applied Group Policy Objects (Computer Scope) ===\n");
8589 let mut capture = false;
8590 for line in stdout.lines() {
8591 if line.contains("Applied Group Policy Objects") {
8592 capture = true;
8593 } else if capture && line.contains("The following GPOs were not applied") {
8594 break;
8595 }
8596 if capture && !line.trim().is_empty() {
8597 out.push_str(&format!(" {}\n", line.trim()));
8598 }
8599 }
8600 } else if stderr.contains("Access is denied") || stdout.contains("Access is denied") {
8601 out.push_str("Error: Access denied. Group Policy inspection requires Administrator elevation.\n");
8602 } else {
8603 out.push_str("No applied Group Policy Objects detected or insufficient permissions to query computer scope.\n");
8604 }
8605 }
8606 }
8607
8608 #[cfg(not(target_os = "windows"))]
8609 {
8610 out.push_str("Group Policy (GPO) is a Windows-only topic.\n");
8611 }
8612
8613 Ok(out.trim_end().to_string())
8614}
8615
8616fn inspect_certificates(max_entries: usize) -> Result<String, String> {
8617 let mut out = String::from("Host inspection: certificates\n\n");
8618
8619 #[cfg(target_os = "windows")]
8620 {
8621 let ps_cmd = format!(
8622 "Get-ChildItem -Path Cert:\\LocalMachine\\My | Select-Object Subject, NotAfter, Thumbprint | Select-Object -First {} | ForEach-Object {{ \
8623 $days = ($_.NotAfter - (Get-Date)).Days; \
8624 $status = if ($days -lt 0) {{ \"[EXPIRED]\" }} else if ($days -lt 30) {{ \"[EXPIRING SOON ($days days)]\" }} else {{ \"\" }}; \
8625 \" $($_.Subject) - Expires: $($_.NotAfter.ToString('yyyy-MM-dd')) $status (Thumb: $($_.Thumbprint.Substring(0,8))...)\" \
8626 }}",
8627 max_entries
8628 );
8629 let output = Command::new("powershell")
8630 .args(["-NoProfile", "-Command", &ps_cmd])
8631 .output()
8632 .ok();
8633
8634 if let Some(o) = output {
8635 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8636 if !stdout.trim().is_empty() {
8637 out.push_str("=== Local Machine Certificates (Personal Store) ===\n");
8638 out.push_str(&stdout);
8639 } else {
8640 out.push_str("No certificates found in the Local Machine Personal store.\n");
8641 }
8642 }
8643 }
8644
8645 #[cfg(not(target_os = "windows"))]
8646 {
8647 let _ = max_entries;
8648 out.push_str("Host inspection: certificates (Linux/macOS)\n\n");
8649 for path in ["/etc/ssl/certs", "/etc/pki/tls/certs"] {
8651 if Path::new(path).exists() {
8652 out.push_str(&format!(" Cert directory found: {}\n", path));
8653 }
8654 }
8655 }
8656
8657 Ok(out.trim_end().to_string())
8658}
8659
8660fn inspect_integrity() -> Result<String, String> {
8661 let mut out = String::from("Host inspection: integrity\n\n");
8662
8663 #[cfg(target_os = "windows")]
8664 {
8665 let ps_cmd = "Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing' | Select-Object Corrupt, AutoRepairNeeded, LastRepairAttempted | ConvertTo-Json";
8666 let output = Command::new("powershell")
8667 .args(["-NoProfile", "-Command", &ps_cmd])
8668 .output()
8669 .ok();
8670
8671 if let Some(o) = output {
8672 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8673 if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
8674 out.push_str("=== Windows Component Store Health (CBS) ===\n");
8675 let corrupt = val.get("Corrupt").and_then(|v| v.as_u64()).unwrap_or(0);
8676 let repair = val
8677 .get("AutoRepairNeeded")
8678 .and_then(|v| v.as_u64())
8679 .unwrap_or(0);
8680
8681 out.push_str(&format!(
8682 " Corruption Detected: {}\n",
8683 if corrupt != 0 {
8684 "YES (SFC/DISM recommended)"
8685 } else {
8686 "No"
8687 }
8688 ));
8689 out.push_str(&format!(
8690 " Auto-Repair Needed: {}\n",
8691 if repair != 0 { "YES" } else { "No" }
8692 ));
8693
8694 if let Some(last) = val.get("LastRepairAttempted").and_then(|v| v.as_u64()) {
8695 out.push_str(&format!(" Last Repair Attempt: (Raw code: {})\n", last));
8696 }
8697 } else {
8698 out.push_str("Could not retrieve CBS health from registry. System may be healthy or state is unknown.\n");
8699 }
8700 }
8701
8702 if Path::new("C:\\Windows\\Logs\\CBS\\CBS.log").exists() {
8703 out.push_str(
8704 "\nNote: Detailed integrity logs available at C:\\Windows\\Logs\\CBS\\CBS.log\n",
8705 );
8706 }
8707 }
8708
8709 #[cfg(not(target_os = "windows"))]
8710 {
8711 out.push_str("System integrity check (Linux)\n\n");
8712 let pkg_check = Command::new("rpm")
8713 .args(["-Va"])
8714 .output()
8715 .or_else(|_| Command::new("dpkg").args(["--verify"]).output())
8716 .ok();
8717 if let Some(o) = pkg_check {
8718 out.push_str(" Package verification system active.\n");
8719 if o.status.success() {
8720 out.push_str(" No major package integrity issues detected.\n");
8721 }
8722 }
8723 }
8724
8725 Ok(out.trim_end().to_string())
8726}
8727
8728fn inspect_domain() -> Result<String, String> {
8729 let mut out = String::from("Host inspection: domain\n\n");
8730
8731 #[cfg(target_os = "windows")]
8732 {
8733 let ps_cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Name, Domain, PartOfDomain, Workgroup | ConvertTo-Json";
8734 let output = Command::new("powershell")
8735 .args(["-NoProfile", "-Command", &ps_cmd])
8736 .output()
8737 .ok();
8738
8739 if let Some(o) = output {
8740 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8741 if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
8742 let part_of_domain = val
8743 .get("PartOfDomain")
8744 .and_then(|v| v.as_bool())
8745 .unwrap_or(false);
8746 let domain = val
8747 .get("Domain")
8748 .and_then(|v| v.as_str())
8749 .unwrap_or("Unknown");
8750 let workgroup = val
8751 .get("Workgroup")
8752 .and_then(|v| v.as_str())
8753 .unwrap_or("Unknown");
8754
8755 out.push_str("=== Windows Domain / Workgroup Identity ===\n");
8756 out.push_str(&format!(
8757 " Join Status: {}\n",
8758 if part_of_domain {
8759 "DOMAIN JOINED"
8760 } else {
8761 "WORKGROUP"
8762 }
8763 ));
8764 if part_of_domain {
8765 out.push_str(&format!(" Active Directory Domain: {}\n", domain));
8766 } else {
8767 out.push_str(&format!(" Workgroup Name: {}\n", workgroup));
8768 }
8769
8770 if let Some(name) = val.get("Name").and_then(|v| v.as_str()) {
8771 out.push_str(&format!(" NetBIOS Name: {}\n", name));
8772 }
8773 }
8774 }
8775 }
8776
8777 #[cfg(not(target_os = "windows"))]
8778 {
8779 let domainname = Command::new("domainname")
8780 .output()
8781 .ok()
8782 .and_then(|o| String::from_utf8(o.stdout).ok())
8783 .unwrap_or_default();
8784 out.push_str("=== Linux Domain Identity ===\n");
8785 if !domainname.trim().is_empty() && domainname.trim() != "(none)" {
8786 out.push_str(&format!(" NIS/YP Domain: {}\n", domainname.trim()));
8787 } else {
8788 out.push_str(" No NIS domain configured.\n");
8789 }
8790 }
8791
8792 Ok(out.trim_end().to_string())
8793}
8794
8795fn inspect_device_health() -> Result<String, String> {
8796 let mut out = String::from("Host inspection: device_health\n\n");
8797
8798 #[cfg(target_os = "windows")]
8799 {
8800 let ps_cmd = "Get-CimInstance Win32_PnPEntity | Where-Object { $_.ConfigManagerErrorCode -ne 0 } | Select-Object Name, Status, ConfigManagerErrorCode, Description | ForEach-Object { \" [ERR:$($_.ConfigManagerErrorCode)] $($_.Name) ($($_.Status)) - $($_.Description)\" }";
8801 let output = Command::new("powershell")
8802 .args(["-NoProfile", "-Command", ps_cmd])
8803 .output()
8804 .ok()
8805 .and_then(|o| String::from_utf8(o.stdout).ok())
8806 .unwrap_or_default();
8807
8808 if output.trim().is_empty() {
8809 out.push_str("All PnP devices report as healthy (no ConfigManager errors detected).\n");
8810 } else {
8811 out.push_str("=== Malfunctioning Devices (Yellow Bangs) ===\n");
8812 out.push_str(&output);
8813 out.push_str(
8814 "\nTip: Error codes 10 and 28 usually indicate missing or incompatible drivers.\n",
8815 );
8816 }
8817 }
8818
8819 #[cfg(not(target_os = "windows"))]
8820 {
8821 out.push_str("Checking dmesg for hardware errors...\n");
8822 let dmesg = Command::new("dmesg")
8823 .args(["--level=err,crit,alert"])
8824 .output()
8825 .ok()
8826 .and_then(|o| String::from_utf8(o.stdout).ok())
8827 .unwrap_or_default();
8828 if dmesg.is_empty() {
8829 out.push_str(" No critical hardware errors found in dmesg.\n");
8830 } else {
8831 out.push_str(&dmesg.lines().take(20).collect::<Vec<_>>().join("\n"));
8832 }
8833 }
8834
8835 Ok(out.trim_end().to_string())
8836}
8837
8838fn inspect_drivers(max_entries: usize) -> Result<String, String> {
8839 let mut out = String::from("Host inspection: drivers\n\n");
8840
8841 #[cfg(target_os = "windows")]
8842 {
8843 let ps_cmd = format!("Get-CimInstance Win32_SystemDriver | Select-Object Name, Description, State, Status | Select-Object -First {} | ForEach-Object {{ \" $($_.Name): $($_.State) ($($_.Status)) - $($_.Description)\" }}", max_entries);
8844 let output = Command::new("powershell")
8845 .args(["-NoProfile", "-Command", &ps_cmd])
8846 .output()
8847 .ok()
8848 .and_then(|o| String::from_utf8(o.stdout).ok())
8849 .unwrap_or_default();
8850
8851 if output.trim().is_empty() {
8852 out.push_str("No drivers retrieved via WMI.\n");
8853 } else {
8854 out.push_str("=== Active System Drivers (CIM Snapshot) ===\n");
8855 out.push_str(&output);
8856 }
8857 }
8858
8859 #[cfg(not(target_os = "windows"))]
8860 {
8861 out.push_str("=== Loaded Kernel Modules (lsmod) ===\n");
8862 let lsmod = Command::new("lsmod")
8863 .output()
8864 .ok()
8865 .and_then(|o| String::from_utf8(o.stdout).ok())
8866 .unwrap_or_default();
8867 out.push_str(
8868 &lsmod
8869 .lines()
8870 .take(max_entries)
8871 .collect::<Vec<_>>()
8872 .join("\n"),
8873 );
8874 }
8875
8876 Ok(out.trim_end().to_string())
8877}
8878
8879fn inspect_peripherals(max_entries: usize) -> Result<String, String> {
8880 let mut out = String::from("Host inspection: peripherals\n\n");
8881
8882 #[cfg(target_os = "windows")]
8883 {
8884 let _ = max_entries;
8885 out.push_str("=== USB Controllers & Hubs ===\n");
8886 let usb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_USBController | ForEach-Object { \" $($_.Name) ($($_.Status))\" }"])
8887 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8888 out.push_str(if usb.is_empty() {
8889 " None detected.\n"
8890 } else {
8891 &usb
8892 });
8893
8894 out.push_str("\n=== Input Devices (Keyboard/Pointer) ===\n");
8895 let kb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_Keyboard | ForEach-Object { \" [KB] $($_.Name) ($($_.Status))\" }"])
8896 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8897 let mouse = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_PointingDevice | ForEach-Object { \" [PTR] $($_.Name) ($($_.Status))\" }"])
8898 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8899 out.push_str(&kb);
8900 out.push_str(&mouse);
8901
8902 out.push_str("\n=== Connected Monitors (WMI) ===\n");
8903 let mon = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams | ForEach-Object { \" Display ($($_.Active ? 'Active' : 'Inactive'))\" }"])
8904 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8905 out.push_str(if mon.is_empty() {
8906 " No active monitors identified via WMI.\n"
8907 } else {
8908 &mon
8909 });
8910 }
8911
8912 #[cfg(not(target_os = "windows"))]
8913 {
8914 out.push_str("=== Connected USB Devices (lsusb) ===\n");
8915 let lsusb = Command::new("lsusb")
8916 .output()
8917 .ok()
8918 .and_then(|o| String::from_utf8(o.stdout).ok())
8919 .unwrap_or_default();
8920 out.push_str(
8921 &lsusb
8922 .lines()
8923 .take(max_entries)
8924 .collect::<Vec<_>>()
8925 .join("\n"),
8926 );
8927 }
8928
8929 Ok(out.trim_end().to_string())
8930}
8931
8932fn inspect_sessions(max_entries: usize) -> Result<String, String> {
8933 let mut out = String::from("Host inspection: sessions\n\n");
8934
8935 #[cfg(target_os = "windows")]
8936 {
8937 let script = r#"Get-CimInstance Win32_LogonSession | ForEach-Object {
8938 "$($_.LogonId)|$($_.StartTime)|$($_.LogonType)|$($_.AuthenticationPackage)"
8939}"#;
8940 if let Ok(o) = Command::new("powershell")
8941 .args(["-NoProfile", "-Command", script])
8942 .output()
8943 {
8944 let text = String::from_utf8_lossy(&o.stdout);
8945 let lines: Vec<&str> = text.lines().collect();
8946 if lines.is_empty() {
8947 out.push_str("No active logon sessions enumerated via WMI.\n");
8948 } else {
8949 out.push_str("=== Active Logon Sessions (WMI Snapshot) ===\n");
8950 for line in lines
8951 .iter()
8952 .take(max_entries)
8953 .filter(|l| !l.trim().is_empty())
8954 {
8955 let parts: Vec<&str> = line.trim().split('|').collect();
8956 if parts.len() == 4 {
8957 let logon_type = match parts[2] {
8958 "2" => "Interactive",
8959 "3" => "Network",
8960 "4" => "Batch",
8961 "5" => "Service",
8962 "7" => "Unlock",
8963 "8" => "NetworkCleartext",
8964 "9" => "NewCredentials",
8965 "10" => "RemoteInteractive",
8966 "11" => "CachedInteractive",
8967 _ => "Other",
8968 };
8969 out.push_str(&format!(
8970 "- ID: {} | Type: {} | Started: {} | Auth: {}\n",
8971 parts[0], logon_type, parts[1], parts[3]
8972 ));
8973 }
8974 }
8975 }
8976 }
8977 }
8978
8979 #[cfg(not(target_os = "windows"))]
8980 {
8981 out.push_str("=== Logged-in Users (who) ===\n");
8982 let who = Command::new("who")
8983 .output()
8984 .ok()
8985 .and_then(|o| String::from_utf8(o.stdout).ok())
8986 .unwrap_or_default();
8987 out.push_str(&who.lines().take(max_entries).collect::<Vec<_>>().join("\n"));
8988 }
8989
8990 Ok(out.trim_end().to_string())
8991}
8992
8993async fn inspect_disk_benchmark(path: PathBuf) -> Result<String, String> {
8994 let mut out = String::from("Host inspection: disk_benchmark\n\n");
8995 let mut final_path = path;
8996
8997 if !final_path.exists() {
8998 if let Ok(current_exe) = std::env::current_exe() {
8999 out.push_str(&format!(
9000 "Note: Requested target '{}' not found. Falling back to current binary for silicon-aware intensity report.\n",
9001 final_path.display()
9002 ));
9003 final_path = current_exe;
9004 } else {
9005 return Err(format!("Target not found: {}", final_path.display()));
9006 }
9007 }
9008
9009 let target = if final_path.is_dir() {
9010 let mut target_file = final_path.join("Cargo.toml");
9012 if !target_file.exists() {
9013 target_file = final_path.join("README.md");
9014 }
9015 if !target_file.exists() {
9016 return Err("Target path is a directory but no representative file (Cargo.toml/README.md) found for benchmarking.".to_string());
9017 }
9018 target_file
9019 } else {
9020 final_path
9021 };
9022
9023 out.push_str(&format!("Target: {}\n", target.display()));
9024 out.push_str("Running diagnostic stress test (5s read-thrash + kernel counter trace)...\n\n");
9025
9026 #[cfg(target_os = "windows")]
9027 {
9028 let script = format!(
9029 r#"
9030$target = "{}"
9031if (-not (Test-Path $target)) {{ "ERROR:Target not found"; exit }}
9032
9033$diskQueue = @()
9034$readStats = @()
9035$startTime = Get-Date
9036$duration = 5
9037
9038# Background reader job
9039$job = Start-Job -ScriptBlock {{
9040 param($t, $d)
9041 $stop = (Get-Date).AddSeconds($d)
9042 while ((Get-Date) -lt $stop) {{
9043 try {{ [System.IO.File]::ReadAllBytes($t) | Out-Null }} catch {{ }}
9044 }}
9045}} -ArgumentList $target, $duration
9046
9047# Metrics collector loop
9048$stopTime = (Get-Date).AddSeconds($duration)
9049while ((Get-Date) -lt $stopTime) {{
9050 $q = Get-Counter '\PhysicalDisk(_Total)\Avg. Disk Queue Length' -ErrorAction SilentlyContinue
9051 if ($q) {{ $diskQueue += $q.CounterSamples[0].CookedValue }}
9052
9053 $r = Get-Counter '\PhysicalDisk(_Total)\Disk Reads/sec' -ErrorAction SilentlyContinue
9054 if ($r) {{ $readStats += $r.CounterSamples[0].CookedValue }}
9055
9056 Start-Sleep -Milliseconds 250
9057}}
9058
9059Stop-Job $job
9060Receive-Job $job | Out-Null
9061Remove-Job $job
9062
9063$avgQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Average).Average }} else {{ 0 }}
9064$maxQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Maximum).Maximum }} else {{ 0 }}
9065$avgR = if ($readStats) {{ ($readStats | Measure-Object -Average).Average }} else {{ 0 }}
9066
9067"AVG_Q:$([math]::Round($avgQ, 4))|MAX_Q:$([math]::Round($maxQ, 4))|AVG_R:$([math]::Round($avgR, 2))"
9068"#,
9069 target.display()
9070 );
9071
9072 let output = Command::new("powershell")
9073 .args(["-NoProfile", "-Command", &script])
9074 .output()
9075 .map_err(|e| format!("Benchmark failed: {e}"))?;
9076
9077 let raw = String::from_utf8_lossy(&output.stdout);
9078 let text = raw.trim();
9079
9080 if text.starts_with("ERROR") {
9081 return Err(text.to_string());
9082 }
9083
9084 let mut lines = text.lines();
9085 if let Some(metrics_line) = lines.next() {
9086 let parts: Vec<&str> = metrics_line.split('|').collect();
9087 let mut avg_q = "unknown".to_string();
9088 let mut max_q = "unknown".to_string();
9089 let mut avg_r = "unknown".to_string();
9090
9091 for p in parts {
9092 if let Some((k, v)) = p.split_once(':') {
9093 match k {
9094 "AVG_Q" => avg_q = v.to_string(),
9095 "MAX_Q" => max_q = v.to_string(),
9096 "AVG_R" => avg_r = v.to_string(),
9097 _ => {}
9098 }
9099 }
9100 }
9101
9102 out.push_str("=== WORKSTATION INTENSITY REPORT ===\n");
9103 out.push_str(&format!("- Active Disk Queue (Avg): {}\n", avg_q));
9104 out.push_str(&format!("- Active Disk Queue (Max): {}\n", max_q));
9105 out.push_str(&format!("- Disk Throughput (Avg): {} reads/sec\n", avg_r));
9106 out.push_str("\nVerdict: ");
9107 let q_num = avg_q.parse::<f64>().unwrap_or(0.0);
9108 if q_num > 1.0 {
9109 out.push_str(
9110 "HIGH INTENSITY — the disk stack is saturated. Hardware bottleneck confirmed.",
9111 );
9112 } else if q_num > 0.1 {
9113 out.push_str("MODERATE LOAD — significant I/O pressure detected.");
9114 } else {
9115 out.push_str("LIGHT LOAD — the hardware is handling this volume comfortably.");
9116 }
9117 }
9118 }
9119
9120 #[cfg(not(target_os = "windows"))]
9121 {
9122 out.push_str("Note: Native silicon benchmarking is currently optimized for Windows performance counters.\n");
9123 out.push_str("Generic disk load simulated.\n");
9124 }
9125
9126 Ok(out)
9127}