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 mut topic = args
13 .get("topic")
14 .and_then(|v| v.as_str())
15 .unwrap_or("summary")
16 .to_string();
17 let max_entries = parse_max_entries(args);
18 let filter = parse_name_filter(args).unwrap_or_default().to_lowercase();
19
20 if (topic == "processes" || topic == "network" || topic == "summary")
22 && (filter.contains("ad")
23 || filter.contains("sid")
24 || filter.contains("administrator")
25 || filter.contains("domain"))
26 {
27 topic = "ad_user".to_string();
28 }
29
30 match topic.as_str() {
31 "summary" => inspect_summary(max_entries),
32 "toolchains" => inspect_toolchains(),
33 "path" => inspect_path(max_entries),
34 "env_doctor" => inspect_env_doctor(max_entries),
35 "fix_plan" => inspect_fix_plan(parse_issue_text(args), parse_port_filter(args), max_entries).await,
36 "network" => inspect_network(max_entries),
37 "services" => inspect_services(parse_name_filter(args), max_entries),
38 "processes" => inspect_processes(parse_name_filter(args), max_entries),
39 "desktop" => inspect_known_directory("Desktop", desktop_dir(), max_entries).await,
40 "downloads" => inspect_known_directory("Downloads", downloads_dir(), max_entries).await,
41 "disk" => {
42 let path = resolve_optional_path(args)?;
43 inspect_disk(path, max_entries).await
44 }
45 "ports" => inspect_ports(parse_port_filter(args), max_entries),
46 "log_check" => inspect_log_check(parse_lookback_hours(args), max_entries),
47 "startup_items" | "startup" | "boot" | "autorun" => inspect_startup_items(max_entries),
48 "health_report" | "system_health" => inspect_health_report(),
49 "storage" => inspect_storage(max_entries),
50 "hardware" => inspect_hardware(),
51 "updates" | "windows_update" => inspect_updates(),
52 "security" | "antivirus" | "defender" => inspect_security(),
53 "pending_reboot" | "reboot_required" => inspect_pending_reboot(),
54 "disk_health" | "smart" | "drive_health" => inspect_disk_health(),
55 "battery" => inspect_battery(),
56 "recent_crashes" | "crashes" | "bsod" => inspect_recent_crashes(max_entries),
57 "scheduled_tasks" | "tasks" => inspect_scheduled_tasks(max_entries),
58 "dev_conflicts" | "dev_environment" => inspect_dev_conflicts(),
59 "connectivity" | "internet" | "internet_check" => inspect_connectivity(),
60 "wifi" | "wi-fi" | "wireless" | "wlan" => inspect_wifi(),
61 "connections" | "tcp_connections" | "active_connections" => inspect_connections(max_entries),
62 "vpn" => inspect_vpn(),
63 "proxy" | "proxy_settings" => inspect_proxy(),
64 "firewall_rules" | "firewall-rules" => inspect_firewall_rules(max_entries),
65 "traceroute" | "tracert" | "trace_route" | "trace" => {
66 let host = args
67 .get("host")
68 .and_then(|v| v.as_str())
69 .unwrap_or("8.8.8.8")
70 .to_string();
71 inspect_traceroute(&host, max_entries)
72 }
73 "dns_cache" | "dnscache" | "dns-cache" => inspect_dns_cache(max_entries),
74 "arp" | "arp_table" => inspect_arp(),
75 "route_table" | "routes" | "routing_table" => inspect_route_table(max_entries),
76 "os_config" | "system_config" => inspect_os_config(),
77 "resource_load" | "performance" | "system_load" | "performance_diagnosis" => inspect_resource_load(),
78 "env" | "environment" | "environment_variables" | "env_vars" => inspect_env(max_entries),
79 "hosts_file" | "hosts" | "etc_hosts" => inspect_hosts_file(),
80 "docker" | "containers" | "docker_status" => inspect_docker(max_entries),
81 "wsl" | "wsl_distros" | "subsystem" => inspect_wsl(),
82 "ssh" | "ssh_config" | "ssh_status" => inspect_ssh(),
83 "installed_software" | "installed" | "programs" | "software" | "packages" => inspect_installed_software(max_entries),
84 "git_config" | "git_global" => inspect_git_config(),
85 "databases" | "database" | "db_services" | "db" => inspect_databases(),
86 "user_accounts" | "users" | "local_users" | "accounts" => inspect_user_accounts(max_entries),
87 "audit_policy" | "audit" | "auditpol" => inspect_audit_policy(),
88 "shares" | "smb_shares" | "network_shares" | "mapped_drives" => inspect_shares(max_entries),
89 "dns_servers" | "dns_config" | "dns_resolver" | "nameservers" => inspect_dns_servers(),
90 "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => inspect_bitlocker(),
91 "rdp" | "remote_desktop" | "rdp_status" => inspect_rdp(),
92 "shadow_copies" | "vss" | "volume_shadow" | "backups" | "snapshots" => inspect_shadow_copies(),
93 "pagefile" | "page_file" | "virtual_memory" | "swap" => inspect_pagefile(),
94 "windows_features" | "optional_features" | "installed_features" | "features" => inspect_windows_features(max_entries),
95 "printers" | "printer" | "print_queue" | "printing" => inspect_printers(max_entries),
96 "winrm" | "remote_management" | "psremoting" => inspect_winrm(),
97 "network_stats" | "adapter_stats" | "nic_stats" | "interface_stats" => inspect_network_stats(max_entries),
98 "udp_ports" | "udp_listeners" | "udp" => inspect_udp_ports(max_entries),
99 "gpo" | "group_policy" | "applied_policies" => inspect_gpo(),
100 "certificates" | "certs" | "ssl_certs" => inspect_certificates(max_entries),
101 "integrity" | "sfc" | "dism" | "system_health_deep" => inspect_integrity(),
102 "domain" | "active_directory" | "ad_context" | "workgroup" => inspect_domain(),
103 "device_health" | "hardware_errors" | "yellow_bangs" => inspect_device_health(),
104 "drivers" | "system_drivers" | "driver_list" => inspect_drivers(max_entries),
105 "peripherals" | "usb" | "input_devices" | "connected_hardware" => inspect_peripherals(max_entries),
106 "sessions" | "logins" | "active_sessions" => inspect_sessions(max_entries),
107 "repo_doctor" => {
108 let path = resolve_optional_path(args)?;
109 inspect_repo_doctor(path, max_entries)
110 }
111 "directory" => {
112 let raw_path = args
113 .get("path")
114 .and_then(|v| v.as_str())
115 .ok_or_else(|| {
116 "Missing required argument: 'path' for inspect_host(topic: \"directory\")"
117 .to_string()
118 })?;
119 let resolved = resolve_path(raw_path)?;
120 inspect_directory("Directory", resolved, max_entries).await
121 }
122 "disk_benchmark" | "stress_test" | "io_intensity" => {
123 let path = resolve_optional_path(args)?;
124 inspect_disk_benchmark(path).await
125 }
126 "permissions" | "acl" | "access_control" => {
127 let path = resolve_optional_path(args)?;
128 inspect_permissions(path, max_entries)
129 }
130 "login_history" | "logon_history" | "user_logins" => {
131 inspect_login_history(max_entries)
132 }
133 "share_access" | "unc_access" | "remote_share" => {
134 let path = resolve_path(args.get("path").and_then(|v| v.as_str()).unwrap_or(""))?;
135 inspect_share_access(path)
136 }
137 "registry_audit" | "persistence" | "integrity_audit" => inspect_registry_audit(),
138 "thermal" | "throttling" | "overheating" => inspect_thermal(),
139 "activation" | "license_status" | "slmgr" => inspect_activation(),
140 "patch_history" | "hotfixes" | "recent_patches" => inspect_patch_history(max_entries),
141 "ad_user" | "ad" | "domain_user" => {
142 let identity = parse_name_filter(args).unwrap_or_default();
143 inspect_ad_user(&identity)
144 }
145 "dns_lookup" | "dig" | "nslookup" => {
146 let name = parse_name_filter(args).unwrap_or_default();
147 let record_type = args.get("type").and_then(|v| v.as_str()).unwrap_or("SRV");
148 inspect_dns_lookup(&name, record_type)
149 }
150 "hyperv" | "hyper-v" | "vms" => inspect_hyperv(),
151 "ip_config" | "ip_detail" | "dhcp" => inspect_ip_config(),
152 "overclocker" | "thermal_deep" | "clocks" | "voltage" => inspect_overclocker().await,
153 other => Err(format!(
154 "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, permissions, login_history, share_access, registry_audit, thermal, activation, patch_history, ad_user, dns_lookup, hyperv, ip_config, overclocker.",
155 other
156 )),
157
158 }
159}
160
161fn parse_max_entries(args: &Value) -> usize {
162 args.get("max_entries")
163 .and_then(|v| v.as_u64())
164 .map(|n| n as usize)
165 .unwrap_or(DEFAULT_MAX_ENTRIES)
166 .clamp(1, MAX_ENTRIES_CAP)
167}
168
169fn parse_port_filter(args: &Value) -> Option<u16> {
170 args.get("port")
171 .and_then(|v| v.as_u64())
172 .and_then(|n| u16::try_from(n).ok())
173}
174
175fn parse_name_filter(args: &Value) -> Option<String> {
176 args.get("name")
177 .and_then(|v| v.as_str())
178 .map(str::trim)
179 .filter(|value| !value.is_empty())
180 .map(|value| value.to_string())
181}
182
183fn parse_lookback_hours(args: &Value) -> Option<u32> {
184 args.get("lookback_hours")
185 .and_then(|v| v.as_u64())
186 .map(|n| n as u32)
187}
188
189fn parse_issue_text(args: &Value) -> Option<String> {
190 args.get("issue")
191 .and_then(|v| v.as_str())
192 .map(str::trim)
193 .filter(|value| !value.is_empty())
194 .map(|value| value.to_string())
195}
196
197fn resolve_optional_path(args: &Value) -> Result<PathBuf, String> {
198 match args.get("path").and_then(|v| v.as_str()) {
199 Some(raw_path) => resolve_path(raw_path),
200 None => {
201 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))
202 }
203 }
204}
205
206fn inspect_summary(max_entries: usize) -> Result<String, String> {
207 let current_dir =
208 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
209 let workspace_root = crate::tools::file_ops::workspace_root();
210 let workspace_mode = workspace_mode_label(&workspace_root);
211 let path_stats = analyze_path_env();
212 let toolchains = collect_toolchains();
213
214 let mut out = String::from("Host inspection: summary\n\n");
215 out.push_str(&format!("- OS: {}\n", std::env::consts::OS));
216 out.push_str(&format!("- Current directory: {}\n", current_dir.display()));
217 out.push_str(&format!("- Workspace root: {}\n", workspace_root.display()));
218 out.push_str(&format!("- Workspace mode: {}\n", workspace_mode));
219 out.push_str(&format!("- Preferred shell: {}\n", preferred_shell_label()));
220 out.push_str(&format!(
221 "- PATH entries: {} total, {} unique, {} duplicates, {} missing\n",
222 path_stats.total_entries,
223 path_stats.unique_entries,
224 path_stats.duplicate_entries.len(),
225 path_stats.missing_entries.len()
226 ));
227
228 if toolchains.found.is_empty() {
229 out.push_str(
230 "- Toolchains found: none of the common developer tools were detected on PATH\n",
231 );
232 } else {
233 out.push_str("- Toolchains found:\n");
234 for (label, version) in toolchains.found.iter().take(max_entries.min(8)) {
235 out.push_str(&format!(" - {}: {}\n", label, version));
236 }
237 if toolchains.found.len() > max_entries.min(8) {
238 out.push_str(&format!(
239 " - ... {} more found tools omitted\n",
240 toolchains.found.len() - max_entries.min(8)
241 ));
242 }
243 }
244
245 if !toolchains.missing.is_empty() {
246 out.push_str(&format!(
247 "- Common tools not detected on PATH: {}\n",
248 toolchains.missing.join(", ")
249 ));
250 }
251
252 for (label, path) in [("Desktop", desktop_dir()), ("Downloads", downloads_dir())] {
253 match path {
254 Some(path) if path.exists() => match count_top_level_items(&path) {
255 Ok(count) => out.push_str(&format!(
256 "- {}: {} top-level items at {}\n",
257 label,
258 count,
259 path.display()
260 )),
261 Err(e) => out.push_str(&format!(
262 "- {}: exists at {} but could not inspect ({})\n",
263 label,
264 path.display(),
265 e
266 )),
267 },
268 Some(path) => out.push_str(&format!(
269 "- {}: expected at {} but not found\n",
270 label,
271 path.display()
272 )),
273 None => out.push_str(&format!("- {}: location unavailable on this host\n", label)),
274 }
275 }
276
277 Ok(out.trim_end().to_string())
278}
279
280fn inspect_toolchains() -> Result<String, String> {
281 let report = collect_toolchains();
282 let mut out = String::from("Host inspection: toolchains\n\n");
283
284 if report.found.is_empty() {
285 out.push_str("- No common developer tools were detected on PATH.");
286 } else {
287 out.push_str("Detected developer tools:\n");
288 for (label, version) in report.found {
289 out.push_str(&format!("- {}: {}\n", label, version));
290 }
291 }
292
293 if !report.missing.is_empty() {
294 out.push_str("\nNot detected on PATH:\n");
295 for label in report.missing {
296 out.push_str(&format!("- {}\n", label));
297 }
298 }
299
300 Ok(out.trim_end().to_string())
301}
302
303fn inspect_path(max_entries: usize) -> Result<String, String> {
304 let path_stats = analyze_path_env();
305 let mut out = String::from("Host inspection: PATH\n\n");
306 out.push_str(&format!("- Total entries: {}\n", path_stats.total_entries));
307 out.push_str(&format!(
308 "- Unique entries: {}\n",
309 path_stats.unique_entries
310 ));
311 out.push_str(&format!(
312 "- Duplicate entries: {}\n",
313 path_stats.duplicate_entries.len()
314 ));
315 out.push_str(&format!(
316 "- Missing paths: {}\n",
317 path_stats.missing_entries.len()
318 ));
319
320 out.push_str("\nPATH entries:\n");
321 for entry in path_stats.entries.iter().take(max_entries) {
322 out.push_str(&format!("- {}\n", entry));
323 }
324 if path_stats.entries.len() > max_entries {
325 out.push_str(&format!(
326 "- ... {} more entries omitted\n",
327 path_stats.entries.len() - max_entries
328 ));
329 }
330
331 if !path_stats.duplicate_entries.is_empty() {
332 out.push_str("\nDuplicate entries:\n");
333 for entry in path_stats.duplicate_entries.iter().take(max_entries) {
334 out.push_str(&format!("- {}\n", entry));
335 }
336 if path_stats.duplicate_entries.len() > max_entries {
337 out.push_str(&format!(
338 "- ... {} more duplicates omitted\n",
339 path_stats.duplicate_entries.len() - max_entries
340 ));
341 }
342 }
343
344 if !path_stats.missing_entries.is_empty() {
345 out.push_str("\nMissing directories:\n");
346 for entry in path_stats.missing_entries.iter().take(max_entries) {
347 out.push_str(&format!("- {}\n", entry));
348 }
349 if path_stats.missing_entries.len() > max_entries {
350 out.push_str(&format!(
351 "- ... {} more missing entries omitted\n",
352 path_stats.missing_entries.len() - max_entries
353 ));
354 }
355 }
356
357 Ok(out.trim_end().to_string())
358}
359
360fn inspect_env_doctor(max_entries: usize) -> Result<String, String> {
361 let path_stats = analyze_path_env();
362 let toolchains = collect_toolchains();
363 let package_managers = collect_package_managers();
364 let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
365
366 let mut out = String::from("Host inspection: env_doctor\n\n");
367 out.push_str(&format!(
368 "- PATH health: {} duplicates, {} missing entries\n",
369 path_stats.duplicate_entries.len(),
370 path_stats.missing_entries.len()
371 ));
372 out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
373 out.push_str(&format!(
374 "- Package managers found: {}\n",
375 package_managers.found.len()
376 ));
377
378 if !package_managers.found.is_empty() {
379 out.push_str("\nPackage managers:\n");
380 for (label, version) in package_managers.found.iter().take(max_entries) {
381 out.push_str(&format!("- {}: {}\n", label, version));
382 }
383 if package_managers.found.len() > max_entries {
384 out.push_str(&format!(
385 "- ... {} more package managers omitted\n",
386 package_managers.found.len() - max_entries
387 ));
388 }
389 }
390
391 if !path_stats.duplicate_entries.is_empty() {
392 out.push_str("\nDuplicate PATH entries:\n");
393 for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
394 out.push_str(&format!("- {}\n", entry));
395 }
396 if path_stats.duplicate_entries.len() > max_entries.min(5) {
397 out.push_str(&format!(
398 "- ... {} more duplicate entries omitted\n",
399 path_stats.duplicate_entries.len() - max_entries.min(5)
400 ));
401 }
402 }
403
404 if !path_stats.missing_entries.is_empty() {
405 out.push_str("\nMissing PATH entries:\n");
406 for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
407 out.push_str(&format!("- {}\n", entry));
408 }
409 if path_stats.missing_entries.len() > max_entries.min(5) {
410 out.push_str(&format!(
411 "- ... {} more missing entries omitted\n",
412 path_stats.missing_entries.len() - max_entries.min(5)
413 ));
414 }
415 }
416
417 if !findings.is_empty() {
418 out.push_str("\nFindings:\n");
419 for finding in findings.iter().take(max_entries.max(5)) {
420 out.push_str(&format!("- {}\n", finding));
421 }
422 if findings.len() > max_entries.max(5) {
423 out.push_str(&format!(
424 "- ... {} more findings omitted\n",
425 findings.len() - max_entries.max(5)
426 ));
427 }
428 } else {
429 out.push_str("\nFindings:\n- No obvious environment drift was detected from PATH and package-manager checks.");
430 }
431
432 out.push_str(
433 "\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.",
434 );
435
436 Ok(out.trim_end().to_string())
437}
438
439#[derive(Clone, Copy, Debug, Eq, PartialEq)]
440enum FixPlanKind {
441 EnvPath,
442 PortConflict,
443 LmStudio,
444 DriverInstall,
445 GroupPolicy,
446 FirewallRule,
447 SshKey,
448 WslSetup,
449 ServiceConfig,
450 WindowsActivation,
451 RegistryEdit,
452 ScheduledTaskCreate,
453 DiskCleanup,
454 DnsResolution,
455 Generic,
456}
457
458async fn inspect_fix_plan(
459 issue: Option<String>,
460 port_filter: Option<u16>,
461 max_entries: usize,
462) -> Result<String, String> {
463 let issue = issue.unwrap_or_else(|| {
464 "Help me fix PATH, toolchain, port-conflict, or LM Studio connectivity problems."
465 .to_string()
466 });
467 let plan_kind = classify_fix_plan_kind(&issue, port_filter);
468 match plan_kind {
469 FixPlanKind::EnvPath => inspect_env_fix_plan(&issue, max_entries),
470 FixPlanKind::PortConflict => inspect_port_fix_plan(&issue, port_filter, max_entries),
471 FixPlanKind::LmStudio => inspect_lm_studio_fix_plan(&issue, max_entries).await,
472 FixPlanKind::DriverInstall => inspect_driver_install_fix_plan(&issue),
473 FixPlanKind::GroupPolicy => inspect_group_policy_fix_plan(&issue),
474 FixPlanKind::FirewallRule => inspect_firewall_rule_fix_plan(&issue),
475 FixPlanKind::SshKey => inspect_ssh_key_fix_plan(&issue),
476 FixPlanKind::WslSetup => inspect_wsl_setup_fix_plan(&issue),
477 FixPlanKind::ServiceConfig => inspect_service_config_fix_plan(&issue),
478 FixPlanKind::WindowsActivation => inspect_windows_activation_fix_plan(&issue),
479 FixPlanKind::RegistryEdit => inspect_registry_edit_fix_plan(&issue),
480 FixPlanKind::ScheduledTaskCreate => inspect_scheduled_task_fix_plan(&issue),
481 FixPlanKind::DiskCleanup => inspect_disk_cleanup_fix_plan(&issue),
482 FixPlanKind::DnsResolution => inspect_dns_fix_plan(&issue),
483 FixPlanKind::Generic => inspect_generic_fix_plan(&issue),
484 }
485}
486
487fn classify_fix_plan_kind(issue: &str, port_filter: Option<u16>) -> FixPlanKind {
488 let lower = issue.to_ascii_lowercase();
489 if lower.contains("firewall rule")
492 || lower.contains("inbound rule")
493 || lower.contains("outbound rule")
494 || (lower.contains("firewall")
495 && (lower.contains("allow")
496 || lower.contains("block")
497 || lower.contains("create")
498 || lower.contains("open")))
499 {
500 FixPlanKind::FirewallRule
501 } else if port_filter.is_some()
502 || lower.contains("port ")
503 || lower.contains("address already in use")
504 || lower.contains("already in use")
505 || lower.contains("what owns port")
506 || lower.contains("listening on port")
507 {
508 FixPlanKind::PortConflict
509 } else if lower.contains("lm studio")
510 || lower.contains("localhost:1234")
511 || lower.contains("/v1/models")
512 || lower.contains("no coding model loaded")
513 || lower.contains("embedding model")
514 || lower.contains("server on port 1234")
515 || lower.contains("runtime refresh")
516 {
517 FixPlanKind::LmStudio
518 } else if lower.contains("driver")
519 || lower.contains("gpu driver")
520 || lower.contains("nvidia driver")
521 || lower.contains("amd driver")
522 || lower.contains("install driver")
523 || lower.contains("update driver")
524 {
525 FixPlanKind::DriverInstall
526 } else if lower.contains("group policy")
527 || lower.contains("gpedit")
528 || lower.contains("local policy")
529 || lower.contains("secpol")
530 || lower.contains("administrative template")
531 {
532 FixPlanKind::GroupPolicy
533 } else if lower.contains("ssh key")
534 || lower.contains("ssh-keygen")
535 || lower.contains("generate ssh")
536 || lower.contains("authorized_keys")
537 || lower.contains("id_rsa")
538 || lower.contains("id_ed25519")
539 {
540 FixPlanKind::SshKey
541 } else if lower.contains("wsl")
542 || lower.contains("windows subsystem for linux")
543 || lower.contains("install ubuntu")
544 || lower.contains("install linux on windows")
545 || lower.contains("wsl2")
546 {
547 FixPlanKind::WslSetup
548 } else if lower.contains("service")
549 && (lower.contains("start ")
550 || lower.contains("stop ")
551 || lower.contains("restart ")
552 || lower.contains("enable ")
553 || lower.contains("disable ")
554 || lower.contains("configure service"))
555 {
556 FixPlanKind::ServiceConfig
557 } else if lower.contains("activate windows")
558 || lower.contains("windows activation")
559 || lower.contains("product key")
560 || lower.contains("kms")
561 || lower.contains("not activated")
562 {
563 FixPlanKind::WindowsActivation
564 } else if lower.contains("registry")
565 || lower.contains("regedit")
566 || lower.contains("hklm")
567 || lower.contains("hkcu")
568 || lower.contains("reg add")
569 || lower.contains("reg delete")
570 || lower.contains("registry key")
571 {
572 FixPlanKind::RegistryEdit
573 } else if lower.contains("scheduled task")
574 || lower.contains("task scheduler")
575 || lower.contains("schtasks")
576 || lower.contains("create task")
577 || lower.contains("run on startup")
578 || lower.contains("run on schedule")
579 || lower.contains("cron")
580 {
581 FixPlanKind::ScheduledTaskCreate
582 } else if lower.contains("disk cleanup")
583 || lower.contains("free up disk")
584 || lower.contains("free up space")
585 || lower.contains("clear cache")
586 || lower.contains("disk full")
587 || lower.contains("low disk space")
588 || lower.contains("reclaim space")
589 {
590 FixPlanKind::DiskCleanup
591 } else if lower.contains("cargo")
592 || lower.contains("rustc")
593 || lower.contains("path")
594 || lower.contains("package manager")
595 || lower.contains("package managers")
596 || lower.contains("toolchain")
597 || lower.contains("winget")
598 || lower.contains("choco")
599 || lower.contains("scoop")
600 || lower.contains("python")
601 || lower.contains("node")
602 {
603 FixPlanKind::EnvPath
604 } else if lower.contains("dns ")
605 || lower.contains("nameserver")
606 || lower.contains("cannot resolve")
607 || lower.contains("nslookup")
608 || lower.contains("flushdns")
609 {
610 FixPlanKind::DnsResolution
611 } else {
612 FixPlanKind::Generic
613 }
614}
615
616fn inspect_env_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
617 let path_stats = analyze_path_env();
618 let toolchains = collect_toolchains();
619 let package_managers = collect_package_managers();
620 let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
621 let found_tools = toolchains
622 .found
623 .iter()
624 .map(|(label, _)| label.as_str())
625 .collect::<HashSet<_>>();
626 let found_managers = package_managers
627 .found
628 .iter()
629 .map(|(label, _)| label.as_str())
630 .collect::<HashSet<_>>();
631
632 let mut out = String::from("Host inspection: fix_plan\n\n");
633 out.push_str(&format!("- Requested issue: {}\n", issue));
634 out.push_str("- Fix-plan type: environment/path\n");
635 out.push_str(&format!(
636 "- PATH health: {} duplicates, {} missing entries\n",
637 path_stats.duplicate_entries.len(),
638 path_stats.missing_entries.len()
639 ));
640 out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
641 out.push_str(&format!(
642 "- Package managers found: {}\n",
643 package_managers.found.len()
644 ));
645
646 out.push_str("\nLikely causes:\n");
647 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
648 out.push_str(
649 "- 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",
650 );
651 }
652 if path_stats.duplicate_entries.is_empty()
653 && path_stats.missing_entries.is_empty()
654 && !findings.is_empty()
655 {
656 for finding in findings.iter().take(max_entries.max(4)) {
657 out.push_str(&format!("- {}\n", finding));
658 }
659 } else {
660 if !path_stats.duplicate_entries.is_empty() {
661 out.push_str("- Duplicate PATH rows create clutter and can hide which install path is actually winning.\n");
662 }
663 if !path_stats.missing_entries.is_empty() {
664 out.push_str("- Stale PATH rows point at directories that no longer exist, which makes environment drift harder to reason about.\n");
665 }
666 }
667 if found_tools.contains("node")
668 && !found_managers.contains("npm")
669 && !found_managers.contains("pnpm")
670 {
671 out.push_str("- Node is present without a detected package manager. That usually means a partial install or PATH drift.\n");
672 }
673 if found_tools.contains("python")
674 && !found_managers.contains("pip")
675 && !found_managers.contains("uv")
676 && !found_managers.contains("pipx")
677 {
678 out.push_str("- Python is present without a detected package manager. That usually means the launcher works but Scripts/bin is not discoverable.\n");
679 }
680
681 out.push_str("\nFix plan:\n");
682 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");
683 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
684 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");
685 } else if !found_tools.contains("rustc") && !found_managers.contains("cargo") {
686 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");
687 }
688 if !path_stats.duplicate_entries.is_empty() || !path_stats.missing_entries.is_empty() {
689 out.push_str("- Clean duplicate or dead PATH rows in Environment Variables so the winning toolchain path is obvious and stable.\n");
690 }
691 if found_tools.contains("node")
692 && !found_managers.contains("npm")
693 && !found_managers.contains("pnpm")
694 {
695 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");
696 }
697 if found_tools.contains("python")
698 && !found_managers.contains("pip")
699 && !found_managers.contains("uv")
700 && !found_managers.contains("pipx")
701 {
702 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");
703 }
704
705 if !path_stats.duplicate_entries.is_empty() {
706 out.push_str("\nExample duplicate PATH rows:\n");
707 for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
708 out.push_str(&format!("- {}\n", entry));
709 }
710 }
711 if !path_stats.missing_entries.is_empty() {
712 out.push_str("\nExample missing PATH rows:\n");
713 for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
714 out.push_str(&format!("- {}\n", entry));
715 }
716 }
717
718 out.push_str(
719 "\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.",
720 );
721 Ok(out.trim_end().to_string())
722}
723
724fn inspect_port_fix_plan(
725 issue: &str,
726 port_filter: Option<u16>,
727 max_entries: usize,
728) -> Result<String, String> {
729 let requested_port = port_filter.or_else(|| first_port_in_text(issue));
730 let listeners = collect_listening_ports().unwrap_or_default();
731 let mut matching = listeners;
732 if let Some(port) = requested_port {
733 matching.retain(|entry| entry.port == port);
734 }
735 let processes = collect_processes().unwrap_or_default();
736
737 let mut out = String::from("Host inspection: fix_plan\n\n");
738 out.push_str(&format!("- Requested issue: {}\n", issue));
739 out.push_str("- Fix-plan type: port_conflict\n");
740 if let Some(port) = requested_port {
741 out.push_str(&format!("- Requested port: {}\n", port));
742 } else {
743 out.push_str("- Requested port: not parsed from the issue text\n");
744 }
745 out.push_str(&format!("- Matching listeners found: {}\n", matching.len()));
746
747 if !matching.is_empty() {
748 out.push_str("\nCurrent listeners:\n");
749 for entry in matching.iter().take(max_entries.min(5)) {
750 let process_name = entry
751 .pid
752 .as_deref()
753 .and_then(|pid| pid.parse::<u32>().ok())
754 .and_then(|pid| {
755 processes
756 .iter()
757 .find(|process| process.pid == pid)
758 .map(|process| process.name.as_str())
759 })
760 .unwrap_or("unknown");
761 let pid = entry.pid.as_deref().unwrap_or("unknown");
762 out.push_str(&format!(
763 "- {} {} ({}) pid {} process {}\n",
764 entry.protocol, entry.local, entry.state, pid, process_name
765 ));
766 }
767 }
768
769 out.push_str("\nFix plan:\n");
770 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");
771 if !matching.is_empty() {
772 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");
773 } else {
774 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");
775 }
776 out.push_str("- If the port is intentionally occupied, move your app to another port instead of fighting the existing process.\n");
777 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");
778 out.push_str(
779 "\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.",
780 );
781 Ok(out.trim_end().to_string())
782}
783
784async fn inspect_lm_studio_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
785 let config = crate::agent::config::load_config();
786 let configured_api = config
787 .api_url
788 .unwrap_or_else(|| "http://localhost:1234/v1".to_string());
789 let models_url = format!("{}/models", configured_api.trim_end_matches('/'));
790 let reachability = probe_http_endpoint(&models_url).await;
791 let embed_model = detect_loaded_embed_model(&configured_api).await;
792
793 let mut out = String::from("Host inspection: fix_plan\n\n");
794 out.push_str(&format!("- Requested issue: {}\n", issue));
795 out.push_str("- Fix-plan type: lm_studio\n");
796 out.push_str(&format!("- Configured API URL: {}\n", configured_api));
797 out.push_str(&format!("- Probe URL: {}\n", models_url));
798 match &reachability {
799 EndpointProbe::Reachable(status) => {
800 out.push_str(&format!("- Endpoint reachable: yes (HTTP {})\n", status))
801 }
802 EndpointProbe::Unreachable(detail) => {
803 out.push_str(&format!("- Endpoint reachable: no ({})\n", detail))
804 }
805 }
806 out.push_str(&format!(
807 "- Embedding model loaded: {}\n",
808 embed_model.as_deref().unwrap_or("none detected")
809 ));
810
811 out.push_str("\nFix plan:\n");
812 match reachability {
813 EndpointProbe::Reachable(_) => {
814 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");
815 }
816 EndpointProbe::Unreachable(_) => {
817 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");
818 }
819 }
820 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");
821 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");
822 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");
823 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");
824 if let Some(model) = embed_model {
825 out.push_str(&format!(
826 "- Current embedding model already visible: {}. That means the embeddings lane is configured, so focus on the chat model or endpoint next.\n",
827 model
828 ));
829 }
830 if max_entries > 0 {
831 out.push_str(
832 "\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.",
833 );
834 }
835 Ok(out.trim_end().to_string())
836}
837
838fn inspect_driver_install_fix_plan(issue: &str) -> Result<String, String> {
839 #[cfg(target_os = "windows")]
841 let gpu_info = {
842 let out = Command::new("powershell")
843 .args([
844 "-NoProfile",
845 "-NonInteractive",
846 "-Command",
847 "Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate | ForEach-Object { \"GPU: $($_.Name) | Driver: $($_.DriverVersion) | Date: $($_.DriverDate)\" }",
848 ])
849 .output()
850 .ok()
851 .and_then(|o| String::from_utf8(o.stdout).ok())
852 .unwrap_or_default();
853 out.trim().to_string()
854 };
855 #[cfg(not(target_os = "windows"))]
856 let gpu_info = String::from("(GPU detection not available on this platform)");
857
858 let mut out = String::from("Host inspection: fix_plan\n\n");
859 out.push_str(&format!("- Requested issue: {}\n", issue));
860 out.push_str("- Fix-plan type: driver_install\n");
861 if !gpu_info.is_empty() {
862 out.push_str(&format!("\nDetected GPU(s):\n{}\n", gpu_info));
863 }
864 out.push_str("\nFix plan — Installing or updating GPU drivers:\n");
865 out.push_str("1. Identify your GPU make from the detection above (NVIDIA, AMD, or Intel).\n");
866 out.push_str(
867 "2. Open Device Manager: press Win+X → Device Manager → expand Display Adapters.\n",
868 );
869 out.push_str("3. Right-click your GPU → Properties → Driver tab — note the current driver version and date.\n");
870 out.push_str("4. Download the latest driver directly from the manufacturer:\n");
871 out.push_str(" - NVIDIA: geforce.com/drivers (use GeForce Experience for auto-detection)\n");
872 out.push_str(" - AMD: amd.com/support (use Auto-Detect tool)\n");
873 out.push_str(" - Intel: intel.com/content/www/us/en/download-center/home.html\n");
874 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");
875 out.push_str("6. Reboot when prompted — driver installs always require a restart.\n");
876 out.push_str("\nVerification:\n");
877 out.push_str("- After reboot, run in PowerShell:\n Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate\n");
878 out.push_str("- The DriverVersion should match what you installed.\n");
879 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.");
880 Ok(out.trim_end().to_string())
881}
882
883fn inspect_group_policy_fix_plan(issue: &str) -> Result<String, String> {
884 #[cfg(target_os = "windows")]
886 let edition = {
887 Command::new("powershell")
888 .args([
889 "-NoProfile",
890 "-NonInteractive",
891 "-Command",
892 "(Get-CimInstance Win32_OperatingSystem).Caption",
893 ])
894 .output()
895 .ok()
896 .and_then(|o| String::from_utf8(o.stdout).ok())
897 .unwrap_or_default()
898 .trim()
899 .to_string()
900 };
901 #[cfg(not(target_os = "windows"))]
902 let edition = String::from("(Windows edition detection not available)");
903
904 let is_home = edition.to_lowercase().contains("home");
905
906 let mut out = String::from("Host inspection: fix_plan\n\n");
907 out.push_str(&format!("- Requested issue: {}\n", issue));
908 out.push_str("- Fix-plan type: group_policy\n");
909 out.push_str(&format!(
910 "- Windows edition detected: {}\n",
911 if edition.is_empty() {
912 "unknown".to_string()
913 } else {
914 edition.clone()
915 }
916 ));
917
918 if is_home {
919 out.push_str("\nWARNING: Windows Home does not include the Local Group Policy Editor (gpedit.msc).\n");
920 out.push_str("Options on Home edition:\n");
921 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");
922 out.push_str(
923 "2. Install the gpedit.msc enabler script (third-party — use with caution).\n",
924 );
925 out.push_str("3. Upgrade to Windows Pro if you need full Group Policy support.\n");
926 } else {
927 out.push_str("\nFix plan — Editing Local Group Policy:\n");
928 out.push_str("1. Press Win+R → type gpedit.msc → press Enter (requires administrator).\n");
929 out.push_str("2. Navigate the tree: Computer Configuration (machine-wide) or User Configuration (current user).\n");
930 out.push_str("3. Drill into Administrative Templates → find the policy you want.\n");
931 out.push_str("4. Double-click a policy → set to Enabled, Disabled, or Not Configured.\n");
932 out.push_str("5. Click OK — most policies apply on next logon or after gpupdate.\n");
933 out.push_str("6. To force immediate application, run in an elevated PowerShell:\n gpupdate /force\n");
934 }
935 out.push_str("\nVerification:\n");
936 out.push_str("- Run `gpresult /r` in an elevated command prompt to see applied policies.\n");
937 out.push_str(
938 "- Or: `Get-GPResultantSetOfPolicy` in PowerShell (requires RSAT on domain machines).\n",
939 );
940 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.");
941 Ok(out.trim_end().to_string())
942}
943
944fn inspect_firewall_rule_fix_plan(issue: &str) -> Result<String, String> {
945 #[cfg(target_os = "windows")]
946 let profile_state = {
947 Command::new("powershell")
948 .args([
949 "-NoProfile",
950 "-NonInteractive",
951 "-Command",
952 "Get-NetFirewallProfile | Select-Object Name,Enabled | ForEach-Object { \"$($_.Name): $($_.Enabled)\" }",
953 ])
954 .output()
955 .ok()
956 .and_then(|o| String::from_utf8(o.stdout).ok())
957 .unwrap_or_default()
958 .trim()
959 .to_string()
960 };
961 #[cfg(not(target_os = "windows"))]
962 let profile_state = String::new();
963
964 let mut out = String::from("Host inspection: fix_plan\n\n");
965 out.push_str(&format!("- Requested issue: {}\n", issue));
966 out.push_str("- Fix-plan type: firewall_rule\n");
967 if !profile_state.is_empty() {
968 out.push_str(&format!("\nFirewall profile state:\n{}\n", profile_state));
969 }
970 out.push_str("\nFix plan — Creating or modifying a Windows Firewall rule (PowerShell, run as Administrator):\n");
971 out.push_str("\nTo ALLOW inbound traffic on a port:\n");
972 out.push_str(" New-NetFirewallRule -DisplayName \"My App Port 8080\" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any\n");
973 out.push_str("\nTo BLOCK outbound traffic to an address:\n");
974 out.push_str(" New-NetFirewallRule -DisplayName \"Block Example\" -Direction Outbound -RemoteAddress 1.2.3.4 -Action Block\n");
975 out.push_str("\nTo ALLOW an application through the firewall:\n");
976 out.push_str(" New-NetFirewallRule -DisplayName \"My App\" -Direction Inbound -Program \"C:\\Path\\To\\App.exe\" -Action Allow\n");
977 out.push_str("\nTo REMOVE a rule you created:\n");
978 out.push_str(" Remove-NetFirewallRule -DisplayName \"My App Port 8080\"\n");
979 out.push_str("\nTo see existing custom rules:\n");
980 out.push_str(" Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.PolicyStoreSourceType -ne 'GroupPolicy' } | Select-Object DisplayName,Direction,Action\n");
981 out.push_str("\nVerification:\n");
982 out.push_str("- After creating the rule, test reachability from another machine or use:\n Test-NetConnection -ComputerName localhost -Port 8080\n");
983 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.");
984 Ok(out.trim_end().to_string())
985}
986
987fn inspect_ssh_key_fix_plan(issue: &str) -> Result<String, String> {
988 let home = dirs_home().unwrap_or_else(|| std::path::PathBuf::from("~"));
989 let ssh_dir = home.join(".ssh");
990 let has_ssh_dir = ssh_dir.exists();
991 let has_ed25519 = ssh_dir.join("id_ed25519").exists();
992 let has_rsa = ssh_dir.join("id_rsa").exists();
993 let has_authorized_keys = ssh_dir.join("authorized_keys").exists();
994
995 let mut out = String::from("Host inspection: fix_plan\n\n");
996 out.push_str(&format!("- Requested issue: {}\n", issue));
997 out.push_str("- Fix-plan type: ssh_key\n");
998 out.push_str(&format!("- ~/.ssh directory exists: {}\n", has_ssh_dir));
999 out.push_str(&format!("- id_ed25519 key found: {}\n", has_ed25519));
1000 out.push_str(&format!("- id_rsa key found: {}\n", has_rsa));
1001 out.push_str(&format!(
1002 "- authorized_keys found: {}\n",
1003 has_authorized_keys
1004 ));
1005
1006 if has_ed25519 {
1007 out.push_str("\nYou already have an Ed25519 key. If you want to use it, skip to the 'Add to agent' step.\n");
1008 }
1009
1010 out.push_str("\nFix plan — Generating an SSH key pair:\n");
1011 out.push_str("1. Open PowerShell (or Terminal) — no elevation needed.\n");
1012 out.push_str("2. Generate an Ed25519 key (preferred over RSA):\n");
1013 out.push_str(" ssh-keygen -t ed25519 -C \"your@email.com\"\n");
1014 out.push_str(
1015 " - Accept the default path (~/.ssh/id_ed25519) unless you need a custom name.\n",
1016 );
1017 out.push_str(" - Set a passphrase (recommended) or press Enter twice for no passphrase.\n");
1018 out.push_str("3. Start the SSH agent and add your key:\n");
1019 out.push_str(" # Windows (PowerShell, run as Admin once to enable the service):\n");
1020 out.push_str(" Set-Service -Name ssh-agent -StartupType Automatic\n");
1021 out.push_str(" Start-Service ssh-agent\n");
1022 out.push_str(" # Then add the key (normal PowerShell):\n");
1023 out.push_str(" ssh-add ~/.ssh/id_ed25519\n");
1024 out.push_str("4. Copy your PUBLIC key to the target server's authorized_keys:\n");
1025 out.push_str(" # Print your public key:\n");
1026 out.push_str(" cat ~/.ssh/id_ed25519.pub\n");
1027 out.push_str(" # On the target server, append it:\n");
1028 out.push_str(" echo \"<paste public key>\" >> ~/.ssh/authorized_keys\n");
1029 out.push_str(" chmod 600 ~/.ssh/authorized_keys\n");
1030 out.push_str("5. Test the connection:\n");
1031 out.push_str(" ssh user@server-address\n");
1032 out.push_str("\nFor GitHub/GitLab:\n");
1033 out.push_str("- Copy the public key: Get-Content ~/.ssh/id_ed25519.pub | Set-Clipboard\n");
1034 out.push_str("- Paste it into GitHub Settings → SSH and GPG keys → New SSH key\n");
1035 out.push_str("- Test: ssh -T git@github.com\n");
1036 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.");
1037 Ok(out.trim_end().to_string())
1038}
1039
1040fn inspect_wsl_setup_fix_plan(issue: &str) -> Result<String, String> {
1041 #[cfg(target_os = "windows")]
1042 let wsl_status = {
1043 let out = Command::new("wsl")
1044 .args(["--status"])
1045 .output()
1046 .ok()
1047 .and_then(|o| {
1048 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
1049 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
1050 Some(format!("{}{}", stdout, stderr))
1051 })
1052 .unwrap_or_default();
1053 out.trim().to_string()
1054 };
1055 #[cfg(not(target_os = "windows"))]
1056 let wsl_status = String::new();
1057
1058 let wsl_installed =
1059 !wsl_status.is_empty() && !wsl_status.to_lowercase().contains("not installed");
1060
1061 let mut out = String::from("Host inspection: fix_plan\n\n");
1062 out.push_str(&format!("- Requested issue: {}\n", issue));
1063 out.push_str("- Fix-plan type: wsl_setup\n");
1064 out.push_str(&format!("- WSL already installed: {}\n", wsl_installed));
1065 if !wsl_status.is_empty() {
1066 out.push_str(&format!("- WSL status:\n{}\n", wsl_status));
1067 }
1068
1069 if wsl_installed {
1070 out.push_str("\nWSL is already installed. To install a new Linux distro:\n");
1071 out.push_str("1. Run in PowerShell (Admin): wsl --install -d Ubuntu\n");
1072 out.push_str(" Available distros: wsl --list --online\n");
1073 out.push_str("2. After install, launch from Start menu or type 'ubuntu' in PowerShell.\n");
1074 out.push_str("3. Create your Linux username and password when prompted.\n");
1075 } else {
1076 out.push_str("\nFix plan — Installing WSL2 (Windows Subsystem for Linux):\n");
1077 out.push_str("1. Open PowerShell as Administrator.\n");
1078 out.push_str("2. Install WSL with the default Ubuntu distro:\n");
1079 out.push_str(" wsl --install\n");
1080 out.push_str(" (This enables the required Windows features, downloads WSL2, and installs Ubuntu)\n");
1081 out.push_str("3. Reboot when prompted — WSL requires a restart after the first install.\n");
1082 out.push_str("4. After reboot, Ubuntu will launch automatically and ask you to create a username and password.\n");
1083 out.push_str("5. Set WSL2 as the default version (should already be set, but confirm):\n");
1084 out.push_str(" wsl --set-default-version 2\n");
1085 out.push_str("\nTo install a different distro instead of Ubuntu:\n");
1086 out.push_str(" wsl --install -d Debian\n");
1087 out.push_str(" wsl --list --online # to see all available distros\n");
1088 }
1089 out.push_str("\nVerification:\n");
1090 out.push_str("- Run: wsl --list --verbose\n");
1091 out.push_str("- You should see your distro with State: Running and Version: 2\n");
1092 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.");
1093 Ok(out.trim_end().to_string())
1094}
1095
1096fn inspect_service_config_fix_plan(issue: &str) -> Result<String, String> {
1097 let lower = issue.to_ascii_lowercase();
1098 let service_hint = if lower.contains("ssh") {
1100 Some("sshd")
1101 } else if lower.contains("mysql") {
1102 Some("MySQL80")
1103 } else if lower.contains("postgres") || lower.contains("postgresql") {
1104 Some("postgresql")
1105 } else if lower.contains("redis") {
1106 Some("Redis")
1107 } else if lower.contains("nginx") {
1108 Some("nginx")
1109 } else if lower.contains("apache") {
1110 Some("Apache2.4")
1111 } else {
1112 None
1113 };
1114
1115 #[cfg(target_os = "windows")]
1116 let service_state = if let Some(svc) = service_hint {
1117 Command::new("powershell")
1118 .args([
1119 "-NoProfile",
1120 "-NonInteractive",
1121 "-Command",
1122 &format!("Get-Service -Name '{}' -ErrorAction SilentlyContinue | Select-Object Name,Status,StartType | ForEach-Object {{ \"Service: $($_.Name) | Status: $($_.Status) | StartType: $($_.StartType)\" }}", svc),
1123 ])
1124 .output()
1125 .ok()
1126 .and_then(|o| String::from_utf8(o.stdout).ok())
1127 .unwrap_or_default()
1128 .trim()
1129 .to_string()
1130 } else {
1131 String::new()
1132 };
1133 #[cfg(not(target_os = "windows"))]
1134 let service_state = String::new();
1135
1136 let mut out = String::from("Host inspection: fix_plan\n\n");
1137 out.push_str(&format!("- Requested issue: {}\n", issue));
1138 out.push_str("- Fix-plan type: service_config\n");
1139 if let Some(svc) = service_hint {
1140 out.push_str(&format!("- Service detected in request: {}\n", svc));
1141 }
1142 if !service_state.is_empty() {
1143 out.push_str(&format!("- Current state: {}\n", service_state));
1144 }
1145
1146 out.push_str("\nFix plan — Managing Windows services (PowerShell, run as Administrator):\n");
1147 out.push_str("\nStart a service:\n");
1148 out.push_str(" Start-Service -Name \"ServiceName\"\n");
1149 out.push_str("\nStop a service:\n");
1150 out.push_str(" Stop-Service -Name \"ServiceName\"\n");
1151 out.push_str("\nRestart a service:\n");
1152 out.push_str(" Restart-Service -Name \"ServiceName\"\n");
1153 out.push_str("\nEnable a service to start automatically:\n");
1154 out.push_str(" Set-Service -Name \"ServiceName\" -StartupType Automatic\n");
1155 out.push_str("\nDisable a service (stops it from auto-starting):\n");
1156 out.push_str(" Set-Service -Name \"ServiceName\" -StartupType Disabled\n");
1157 out.push_str("\nFind the exact service name:\n");
1158 out.push_str(" Get-Service | Where-Object { $_.DisplayName -like '*mysql*' }\n");
1159 out.push_str("\nVerification:\n");
1160 out.push_str(" Get-Service -Name \"ServiceName\" | Select-Object Name,Status,StartType\n");
1161 if let Some(svc) = service_hint {
1162 out.push_str(&format!(
1163 "\nFor your detected service ({}):\n Get-Service -Name '{}'\n",
1164 svc, svc
1165 ));
1166 }
1167 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.");
1168 Ok(out.trim_end().to_string())
1169}
1170
1171fn inspect_windows_activation_fix_plan(issue: &str) -> Result<String, String> {
1172 #[cfg(target_os = "windows")]
1173 let activation_status = {
1174 Command::new("powershell")
1175 .args([
1176 "-NoProfile",
1177 "-NonInteractive",
1178 "-Command",
1179 "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 + ')' })\" }",
1180 ])
1181 .output()
1182 .ok()
1183 .and_then(|o| String::from_utf8(o.stdout).ok())
1184 .unwrap_or_default()
1185 .trim()
1186 .to_string()
1187 };
1188 #[cfg(not(target_os = "windows"))]
1189 let activation_status = String::new();
1190
1191 let is_licensed = activation_status.to_lowercase().contains("licensed")
1192 && !activation_status.to_lowercase().contains("not licensed");
1193
1194 let mut out = String::from("Host inspection: fix_plan\n\n");
1195 out.push_str(&format!("- Requested issue: {}\n", issue));
1196 out.push_str("- Fix-plan type: windows_activation\n");
1197 if !activation_status.is_empty() {
1198 out.push_str(&format!(
1199 "- Current activation state:\n{}\n",
1200 activation_status
1201 ));
1202 }
1203
1204 if is_licensed {
1205 out.push_str(
1206 "\nWindows appears to be activated. If you are still seeing activation prompts, try:\n",
1207 );
1208 out.push_str("1. Run in elevated PowerShell: slmgr /ato\n");
1209 out.push_str(" (Forces an online activation attempt)\n");
1210 out.push_str("2. Check activation details: slmgr /dli\n");
1211 } else {
1212 out.push_str("\nFix plan — Activating Windows:\n");
1213 out.push_str("1. Check your current status first:\n");
1214 out.push_str(" slmgr /dli (basic info)\n");
1215 out.push_str(" slmgr /dlv (detailed — shows remaining rearms, grace period)\n");
1216 out.push_str("\n2. If you have a retail product key:\n");
1217 out.push_str(" slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX (install key)\n");
1218 out.push_str(" slmgr /ato (activate online)\n");
1219 out.push_str("\n3. If you had a digital license (linked to your Microsoft account):\n");
1220 out.push_str(" - Go to Settings → System → Activation\n");
1221 out.push_str(" - Click 'Troubleshoot' → 'I changed hardware on this device recently'\n");
1222 out.push_str(" - Sign in with the Microsoft account that holds the license\n");
1223 out.push_str("\n4. If using a volume license (organization/enterprise):\n");
1224 out.push_str(" - Contact your IT department for the KMS server address\n");
1225 out.push_str(" - Set KMS host: slmgr /skms kms.yourorg.com\n");
1226 out.push_str(" - Activate: slmgr /ato\n");
1227 }
1228 out.push_str("\nVerification:\n");
1229 out.push_str(" slmgr /dli — should show 'License Status: Licensed'\n");
1230 out.push_str(" Or: Settings → System → Activation → 'Windows is activated'\n");
1231 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.");
1232 Ok(out.trim_end().to_string())
1233}
1234
1235fn inspect_registry_edit_fix_plan(issue: &str) -> Result<String, String> {
1236 let mut out = String::from("Host inspection: fix_plan\n\n");
1237 out.push_str(&format!("- Requested issue: {}\n", issue));
1238 out.push_str("- Fix-plan type: registry_edit\n");
1239 out.push_str(
1240 "\nCAUTION: Registry edits affect core Windows behavior. Always back up before editing.\n",
1241 );
1242 out.push_str("\nFix plan — Safely editing the Windows Registry:\n");
1243 out.push_str("\n1. Back up before you touch anything:\n");
1244 out.push_str(" # Export the key you're about to change (PowerShell):\n");
1245 out.push_str(" reg export \"HKLM\\SOFTWARE\\MyKey\" C:\\backup\\MyKey_backup.reg\n");
1246 out.push_str(" # Or export the whole registry (takes a while):\n");
1247 out.push_str(" reg export HKLM C:\\backup\\HKLM_full.reg\n");
1248 out.push_str("\n2. Read a value (PowerShell, no elevation needed for HKCU):\n");
1249 out.push_str(" Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1250 out.push_str("\n3. Create or update a DWORD value (PowerShell, Admin for HKLM):\n");
1251 out.push_str(
1252 " Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue' -Value 1 -Type DWord\n",
1253 );
1254 out.push_str("\n4. Create a new key:\n");
1255 out.push_str(" New-Item -Path 'HKLM:\\SOFTWARE\\MyNewKey' -Force\n");
1256 out.push_str("\n5. Delete a value:\n");
1257 out.push_str(" Remove-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1258 out.push_str("\n6. Restore from backup if something breaks:\n");
1259 out.push_str(" reg import C:\\backup\\MyKey_backup.reg\n");
1260 out.push_str("\nCommon registry hives:\n");
1261 out.push_str(" HKLM = HKEY_LOCAL_MACHINE (machine-wide, requires Admin)\n");
1262 out.push_str(" HKCU = HKEY_CURRENT_USER (current user, no elevation needed)\n");
1263 out.push_str(" HKCR = HKEY_CLASSES_ROOT (file associations)\n");
1264 out.push_str("\nVerification:\n");
1265 out.push_str(" Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' | Select-Object MyValue\n");
1266 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.");
1267 Ok(out.trim_end().to_string())
1268}
1269
1270fn inspect_scheduled_task_fix_plan(issue: &str) -> Result<String, String> {
1271 let mut out = String::from("Host inspection: fix_plan\n\n");
1272 out.push_str(&format!("- Requested issue: {}\n", issue));
1273 out.push_str("- Fix-plan type: scheduled_task_create\n");
1274 out.push_str("\nFix plan — Creating a Scheduled Task (PowerShell, run as Administrator):\n");
1275 out.push_str("\nExample: Run a script at 9 AM every day\n");
1276 out.push_str(" $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-File C:\\Scripts\\MyScript.ps1'\n");
1277 out.push_str(" $trigger = New-ScheduledTaskTrigger -Daily -At '09:00AM'\n");
1278 out.push_str(" Register-ScheduledTask -TaskName 'MyDailyTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1279 out.push_str("\nExample: Run at Windows startup\n");
1280 out.push_str(" $trigger = New-ScheduledTaskTrigger -AtStartup\n");
1281 out.push_str(" Register-ScheduledTask -TaskName 'MyStartupTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1282 out.push_str("\nExample: Run at user logon\n");
1283 out.push_str(" $trigger = New-ScheduledTaskTrigger -AtLogon\n");
1284 out.push_str(
1285 " Register-ScheduledTask -TaskName 'MyLogonTask' -Action $action -Trigger $trigger\n",
1286 );
1287 out.push_str("\nExample: Run every 30 minutes\n");
1288 out.push_str(" $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 30) -Once -At (Get-Date)\n");
1289 out.push_str("\nView all tasks:\n");
1290 out.push_str(" Get-ScheduledTask | Select-Object TaskName,State | Sort-Object TaskName\n");
1291 out.push_str("\nDelete a task:\n");
1292 out.push_str(" Unregister-ScheduledTask -TaskName 'MyDailyTask' -Confirm:$false\n");
1293 out.push_str("\nRun a task immediately:\n");
1294 out.push_str(" Start-ScheduledTask -TaskName 'MyDailyTask'\n");
1295 out.push_str("\nVerification:\n");
1296 out.push_str(" Get-ScheduledTask -TaskName 'MyDailyTask' | Select-Object TaskName,State,LastRunTime,NextRunTime\n");
1297 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.");
1298 Ok(out.trim_end().to_string())
1299}
1300
1301fn inspect_disk_cleanup_fix_plan(issue: &str) -> Result<String, String> {
1302 #[cfg(target_os = "windows")]
1303 let disk_info = {
1304 Command::new("powershell")
1305 .args([
1306 "-NoProfile",
1307 "-NonInteractive",
1308 "-Command",
1309 "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\" }",
1310 ])
1311 .output()
1312 .ok()
1313 .and_then(|o| String::from_utf8(o.stdout).ok())
1314 .unwrap_or_default()
1315 .trim()
1316 .to_string()
1317 };
1318 #[cfg(not(target_os = "windows"))]
1319 let disk_info = String::new();
1320
1321 let mut out = String::from("Host inspection: fix_plan\n\n");
1322 out.push_str(&format!("- Requested issue: {}\n", issue));
1323 out.push_str("- Fix-plan type: disk_cleanup\n");
1324 if !disk_info.is_empty() {
1325 out.push_str(&format!("\nCurrent drive usage:\n{}\n", disk_info));
1326 }
1327 out.push_str("\nFix plan — Reclaiming disk space (ordered by impact):\n");
1328 out.push_str("\n1. Run Windows Disk Cleanup (built-in, GUI):\n");
1329 out.push_str(" cleanmgr /sageset:1 (configure what to clean)\n");
1330 out.push_str(" cleanmgr /sagerun:1 (run the cleanup)\n");
1331 out.push_str(" Tick 'Windows Update Cleanup' for the biggest reclaim (often 5-20 GB).\n");
1332 out.push_str("\n2. Clear the Windows Update cache (PowerShell, Admin):\n");
1333 out.push_str(" Stop-Service wuauserv\n");
1334 out.push_str(" Remove-Item C:\\Windows\\SoftwareDistribution\\Download\\* -Recurse -Force\n");
1335 out.push_str(" Start-Service wuauserv\n");
1336 out.push_str("\n3. Clear Windows Temp folder:\n");
1337 out.push_str(" Remove-Item $env:TEMP\\* -Recurse -Force -ErrorAction SilentlyContinue\n");
1338 out.push_str(
1339 " Remove-Item C:\\Windows\\Temp\\* -Recurse -Force -ErrorAction SilentlyContinue\n",
1340 );
1341 out.push_str("\n4. Developer cache directories (often the biggest culprits):\n");
1342 out.push_str(" - Rust build artifacts: cargo clean (inside each project)\n");
1343 out.push_str(" - npm cache: npm cache clean --force\n");
1344 out.push_str(" - pip cache: pip cache purge\n");
1345 out.push_str(
1346 " - Docker: docker system prune -a (removes all unused images/containers)\n",
1347 );
1348 out.push_str(" - Cargo registry cache: Remove-Item ~\\.cargo\\registry -Recurse -Force (will redownload on next build)\n");
1349 out.push_str("\n5. Check for large files:\n");
1350 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");
1351 out.push_str("\nVerification:\n");
1352 out.push_str(
1353 " Get-PSDrive C | Select-Object @{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}}\n",
1354 );
1355 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.");
1356 Ok(out.trim_end().to_string())
1357}
1358
1359fn inspect_generic_fix_plan(issue: &str) -> Result<String, String> {
1360 let mut out = String::from("Host inspection: fix_plan\n\n");
1361 out.push_str(&format!("- Requested issue: {}\n", issue));
1362 out.push_str("- Fix-plan type: generic\n");
1363 out.push_str(
1364 "\nGuidance:\n- Use `fix_plan` with a descriptive issue string to get a grounded, machine-specific walkthrough.\n\
1365 Structured lanes available:\n\
1366 - PATH/toolchain drift (cargo, rustc, node, python, winget, choco, scoop)\n\
1367 - Port conflict (address already in use, what owns port)\n\
1368 - LM Studio connectivity (localhost:1234, no coding model loaded, embedding model)\n\
1369 - Driver install (GPU driver, nvidia driver, install driver, update driver)\n\
1370 - Group Policy (gpedit, local policy, administrative template)\n\
1371 - Firewall rule (inbound rule, outbound rule, open port, allow port, block port)\n\
1372 - SSH key (ssh-keygen, generate ssh, authorized_keys)\n\
1373 - WSL setup (wsl2, windows subsystem for linux, install ubuntu)\n\
1374 - Service config (start/stop/restart/enable/disable a service)\n\
1375 - Windows activation (product key, not activated, kms)\n\
1376 - Registry edit (regedit, reg add, hklm, hkcu, registry key)\n\
1377 - Scheduled task (task scheduler, schtasks, run on startup, cron)\n\
1378 - Disk cleanup (free up disk, clear cache, disk full, reclaim space)\n\
1379 - If your issue is outside these lanes, run the closest `inspect_host` topic first to ground the diagnosis.",
1380 );
1381 Ok(out.trim_end().to_string())
1382}
1383
1384fn inspect_resource_load() -> Result<String, String> {
1385 #[cfg(target_os = "windows")]
1386 {
1387 let output = Command::new("powershell")
1388 .args([
1389 "-NoProfile",
1390 "-Command",
1391 "(Get-CimInstance Win32_Processor).LoadPercentage; Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json -Compress",
1392 ])
1393 .output()
1394 .map_err(|e| format!("Failed to run powershell: {e}"))?;
1395
1396 let text = String::from_utf8_lossy(&output.stdout);
1397 let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
1398
1399 let cpu_load = lines
1400 .next()
1401 .and_then(|l| l.parse::<u32>().ok())
1402 .unwrap_or(0);
1403 let mem_json = lines.collect::<Vec<_>>().join("");
1404 let mem_val: Value = serde_json::from_str(&mem_json).unwrap_or(Value::Null);
1405
1406 let total_kb = mem_val["TotalVisibleMemorySize"].as_u64().unwrap_or(1);
1407 let free_kb = mem_val["FreePhysicalMemory"].as_u64().unwrap_or(0);
1408 let used_kb = total_kb.saturating_sub(free_kb);
1409 let mem_percent = if total_kb > 0 {
1410 (used_kb * 100) / total_kb
1411 } else {
1412 0
1413 };
1414
1415 let mut out = String::from("Host inspection: resource_load\n\n");
1416 out.push_str("**System Performance Summary:**\n");
1417 out.push_str(&format!("- CPU Load: {}%\n", cpu_load));
1418 out.push_str(&format!(
1419 "- Memory Usage: {} / {} ({}%)\n",
1420 human_bytes(used_kb * 1024),
1421 human_bytes(total_kb * 1024),
1422 mem_percent
1423 ));
1424
1425 if cpu_load > 85 {
1426 out.push_str("\n[Warning] CPU load is extremely high. System may be unresponsive.\n");
1427 }
1428 if mem_percent > 90 {
1429 out.push_str("\n[Warning] Memory usage is near capacity. Swap activity may slow down the machine.\n");
1430 }
1431
1432 Ok(out)
1433 }
1434 #[cfg(not(target_os = "windows"))]
1435 {
1436 Ok("Resource load inspection is not yet implemented for this platform.".to_string())
1437 }
1438}
1439
1440#[derive(Debug)]
1441enum EndpointProbe {
1442 Reachable(u16),
1443 Unreachable(String),
1444}
1445
1446async fn probe_http_endpoint(url: &str) -> EndpointProbe {
1447 let client = match reqwest::Client::builder()
1448 .timeout(std::time::Duration::from_secs(3))
1449 .build()
1450 {
1451 Ok(client) => client,
1452 Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1453 };
1454
1455 match client.get(url).send().await {
1456 Ok(resp) => EndpointProbe::Reachable(resp.status().as_u16()),
1457 Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1458 }
1459}
1460
1461async fn detect_loaded_embed_model(configured_api: &str) -> Option<String> {
1462 let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1463 let url = format!("{}/api/v0/models", base);
1464 let client = reqwest::Client::builder()
1465 .timeout(std::time::Duration::from_secs(3))
1466 .build()
1467 .ok()?;
1468
1469 #[derive(serde::Deserialize)]
1470 struct ModelList {
1471 data: Vec<ModelEntry>,
1472 }
1473 #[derive(serde::Deserialize)]
1474 struct ModelEntry {
1475 id: String,
1476 #[serde(rename = "type", default)]
1477 model_type: String,
1478 #[serde(default)]
1479 state: String,
1480 }
1481
1482 let response = client.get(url).send().await.ok()?;
1483 let models = response.json::<ModelList>().await.ok()?;
1484 models
1485 .data
1486 .into_iter()
1487 .find(|model| model.model_type == "embeddings" && model.state == "loaded")
1488 .map(|model| model.id)
1489}
1490
1491fn first_port_in_text(text: &str) -> Option<u16> {
1492 text.split(|c: char| !c.is_ascii_digit())
1493 .find(|fragment| !fragment.is_empty())
1494 .and_then(|fragment| fragment.parse::<u16>().ok())
1495}
1496
1497fn inspect_processes(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1498 let mut processes = collect_processes()?;
1499 if let Some(filter) = name_filter.as_deref() {
1500 let lowered = filter.to_ascii_lowercase();
1501 processes.retain(|entry| entry.name.to_ascii_lowercase().contains(&lowered));
1502 }
1503 processes.sort_by(|a, b| {
1504 let a_cpu = a.cpu_percent.unwrap_or(0.0);
1505 let b_cpu = b.cpu_percent.unwrap_or(0.0);
1506 b_cpu
1507 .partial_cmp(&a_cpu)
1508 .unwrap_or(std::cmp::Ordering::Equal)
1509 .then_with(|| b.memory_bytes.cmp(&a.memory_bytes))
1510 .then_with(|| a.name.cmp(&b.name))
1511 .then_with(|| a.pid.cmp(&b.pid))
1512 });
1513
1514 let total_memory: u64 = processes.iter().map(|entry| entry.memory_bytes).sum();
1515
1516 let mut out = String::from("Host inspection: processes\n\n");
1517 if let Some(filter) = name_filter.as_deref() {
1518 out.push_str(&format!("- Filter name: {}\n", filter));
1519 }
1520 out.push_str(&format!("- Processes found: {}\n", processes.len()));
1521 out.push_str(&format!(
1522 "- Total reported working set: {}\n",
1523 human_bytes(total_memory)
1524 ));
1525
1526 if processes.is_empty() {
1527 out.push_str("\nNo running processes matched.");
1528 return Ok(out);
1529 }
1530
1531 out.push_str("\nTop processes by resource usage:\n");
1532 for entry in processes.iter().take(max_entries) {
1533 let cpu_str = entry
1534 .cpu_percent
1535 .map(|p| format!(" [CPU: {:.1}%]", p))
1536 .or_else(|| entry.cpu_seconds.map(|s| format!(" [CPU: {:.1}s]", s)))
1537 .unwrap_or_default();
1538 let io_str = if let (Some(r), Some(w)) = (entry.read_ops, entry.write_ops) {
1539 format!(" [I/O R:{}/W:{}]", r, w)
1540 } else {
1541 " [I/O unknown]".to_string()
1542 };
1543 out.push_str(&format!(
1544 "- {} (pid {}) - {}{}{}{}\n",
1545 entry.name,
1546 entry.pid,
1547 human_bytes(entry.memory_bytes),
1548 cpu_str,
1549 io_str,
1550 entry
1551 .detail
1552 .as_deref()
1553 .map(|detail| format!(" [{}]", detail))
1554 .unwrap_or_default()
1555 ));
1556 }
1557 if processes.len() > max_entries {
1558 out.push_str(&format!(
1559 "- ... {} more processes omitted\n",
1560 processes.len() - max_entries
1561 ));
1562 }
1563
1564 Ok(out.trim_end().to_string())
1565}
1566
1567fn inspect_network(max_entries: usize) -> Result<String, String> {
1568 let adapters = collect_network_adapters()?;
1569 let active_count = adapters
1570 .iter()
1571 .filter(|adapter| adapter.is_active())
1572 .count();
1573 let exposure = listener_exposure_summary(collect_listening_ports().ok().unwrap_or_default());
1574
1575 let mut out = String::from("Host inspection: network\n\n");
1576 out.push_str(&format!("- Adapters found: {}\n", adapters.len()));
1577 out.push_str(&format!("- Active adapters: {}\n", active_count));
1578 out.push_str(&format!(
1579 "- Listener exposure: {} loopback-only, {} wildcard/public, {} specific-bind\n",
1580 exposure.loopback_only, exposure.wildcard_public, exposure.specific_bind
1581 ));
1582
1583 if adapters.is_empty() {
1584 out.push_str("\nNo adapter details were detected.");
1585 return Ok(out);
1586 }
1587
1588 out.push_str("\nAdapter summary:\n");
1589 for adapter in adapters.iter().take(max_entries) {
1590 let status = if adapter.is_active() {
1591 "active"
1592 } else if adapter.disconnected {
1593 "disconnected"
1594 } else {
1595 "idle"
1596 };
1597 let mut details = vec![status.to_string()];
1598 if !adapter.ipv4.is_empty() {
1599 details.push(format!("ipv4 {}", adapter.ipv4.join(", ")));
1600 }
1601 if !adapter.ipv6.is_empty() {
1602 details.push(format!("ipv6 {}", adapter.ipv6.join(", ")));
1603 }
1604 if !adapter.gateways.is_empty() {
1605 details.push(format!("gateway {}", adapter.gateways.join(", ")));
1606 }
1607 if !adapter.dns_servers.is_empty() {
1608 details.push(format!("dns {}", adapter.dns_servers.join(", ")));
1609 }
1610 out.push_str(&format!("- {} - {}\n", adapter.name, details.join(" | ")));
1611 }
1612 if adapters.len() > max_entries {
1613 out.push_str(&format!(
1614 "- ... {} more adapters omitted\n",
1615 adapters.len() - max_entries
1616 ));
1617 }
1618
1619 Ok(out.trim_end().to_string())
1620}
1621
1622fn inspect_services(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1623 let mut services = collect_services()?;
1624 if let Some(filter) = name_filter.as_deref() {
1625 let lowered = filter.to_ascii_lowercase();
1626 services.retain(|entry| {
1627 entry.name.to_ascii_lowercase().contains(&lowered)
1628 || entry
1629 .display_name
1630 .as_deref()
1631 .map(|d| d.to_ascii_lowercase().contains(&lowered))
1632 .unwrap_or(false)
1633 });
1634 }
1635
1636 services.sort_by(|a, b| {
1637 let a_running =
1638 a.status.to_ascii_lowercase() == "running" || a.status.to_ascii_lowercase() == "active";
1639 let b_running =
1640 b.status.to_ascii_lowercase() == "running" || b.status.to_ascii_lowercase() == "active";
1641 b_running.cmp(&a_running).then_with(|| a.name.cmp(&b.name))
1642 });
1643
1644 let running = services
1645 .iter()
1646 .filter(|entry| {
1647 entry.status.eq_ignore_ascii_case("running")
1648 || entry.status.eq_ignore_ascii_case("active")
1649 })
1650 .count();
1651 let failed = services
1652 .iter()
1653 .filter(|entry| {
1654 entry.status.eq_ignore_ascii_case("failed")
1655 || entry.status.eq_ignore_ascii_case("error")
1656 || entry.status.eq_ignore_ascii_case("stopped")
1657 })
1658 .count();
1659
1660 let mut out = String::from("Host inspection: services\n\n");
1661 if let Some(filter) = name_filter.as_deref() {
1662 out.push_str(&format!("- Filter name: {}\n", filter));
1663 }
1664 out.push_str(&format!("- Services found: {}\n", services.len()));
1665 out.push_str(&format!("- Running/active: {}\n", running));
1666 out.push_str(&format!("- Failed/stopped: {}\n", failed));
1667
1668 if services.is_empty() {
1669 out.push_str("\nNo services matched.");
1670 return Ok(out);
1671 }
1672
1673 let per_section = (max_entries / 2).max(5);
1675
1676 let running_services: Vec<_> = services
1677 .iter()
1678 .filter(|e| {
1679 e.status.eq_ignore_ascii_case("running") || e.status.eq_ignore_ascii_case("active")
1680 })
1681 .collect();
1682 let stopped_services: Vec<_> = services
1683 .iter()
1684 .filter(|e| {
1685 e.status.eq_ignore_ascii_case("stopped")
1686 || e.status.eq_ignore_ascii_case("failed")
1687 || e.status.eq_ignore_ascii_case("error")
1688 })
1689 .collect();
1690
1691 let fmt_entry = |entry: &&ServiceEntry| {
1692 let startup = entry
1693 .startup
1694 .as_deref()
1695 .map(|v| format!(" | startup {}", v))
1696 .unwrap_or_default();
1697 let logon = entry
1698 .start_name
1699 .as_deref()
1700 .map(|v| format!(" | LogOn: {}", v))
1701 .unwrap_or_default();
1702 let display = entry
1703 .display_name
1704 .as_deref()
1705 .filter(|v| *v != &entry.name)
1706 .map(|v| format!(" [{}]", v))
1707 .unwrap_or_default();
1708 format!(
1709 "- {}{} - {}{}{}\n",
1710 entry.name, display, entry.status, startup, logon
1711 )
1712 };
1713
1714 out.push_str(&format!(
1715 "\nRunning services ({} total, showing up to {}):\n",
1716 running_services.len(),
1717 per_section
1718 ));
1719 for entry in running_services.iter().take(per_section) {
1720 out.push_str(&fmt_entry(entry));
1721 }
1722 if running_services.len() > per_section {
1723 out.push_str(&format!(
1724 "- ... {} more running services omitted\n",
1725 running_services.len() - per_section
1726 ));
1727 }
1728
1729 out.push_str(&format!(
1730 "\nStopped/failed services ({} total, showing up to {}):\n",
1731 stopped_services.len(),
1732 per_section
1733 ));
1734 for entry in stopped_services.iter().take(per_section) {
1735 out.push_str(&fmt_entry(entry));
1736 }
1737 if stopped_services.len() > per_section {
1738 out.push_str(&format!(
1739 "- ... {} more stopped services omitted\n",
1740 stopped_services.len() - per_section
1741 ));
1742 }
1743
1744 Ok(out.trim_end().to_string())
1745}
1746
1747async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
1748 inspect_directory("Disk", path, max_entries).await
1749}
1750
1751fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
1752 let mut listeners = collect_listening_ports()?;
1753 if let Some(port) = port_filter {
1754 listeners.retain(|entry| entry.port == port);
1755 }
1756 listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
1757
1758 let mut out = String::from("Host inspection: ports\n\n");
1759 if let Some(port) = port_filter {
1760 out.push_str(&format!("- Filter port: {}\n", port));
1761 }
1762 out.push_str(&format!(
1763 "- Listening endpoints found: {}\n",
1764 listeners.len()
1765 ));
1766
1767 if listeners.is_empty() {
1768 out.push_str("\nNo listening endpoints matched.");
1769 return Ok(out);
1770 }
1771
1772 out.push_str("\nListening endpoints:\n");
1773 for entry in listeners.iter().take(max_entries) {
1774 let pid_str = entry
1775 .pid
1776 .as_deref()
1777 .map(|p| format!(" pid {}", p))
1778 .unwrap_or_default();
1779 let name_str = entry
1780 .process_name
1781 .as_deref()
1782 .map(|n| format!(" [{}]", n))
1783 .unwrap_or_default();
1784 out.push_str(&format!(
1785 "- {} {} ({}){}{}\n",
1786 entry.protocol, entry.local, entry.state, pid_str, name_str
1787 ));
1788 }
1789 if listeners.len() > max_entries {
1790 out.push_str(&format!(
1791 "- ... {} more listening endpoints omitted\n",
1792 listeners.len() - max_entries
1793 ));
1794 }
1795
1796 Ok(out.trim_end().to_string())
1797}
1798
1799fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
1800 if !path.exists() {
1801 return Err(format!("Path does not exist: {}", path.display()));
1802 }
1803 if !path.is_dir() {
1804 return Err(format!("Path is not a directory: {}", path.display()));
1805 }
1806
1807 let markers = collect_project_markers(&path);
1808 let hematite_state = collect_hematite_state(&path);
1809 let git_state = inspect_git_state(&path);
1810 let release_state = inspect_release_artifacts(&path);
1811
1812 let mut out = String::from("Host inspection: repo_doctor\n\n");
1813 out.push_str(&format!("- Path: {}\n", path.display()));
1814 out.push_str(&format!(
1815 "- Workspace mode: {}\n",
1816 workspace_mode_for_path(&path)
1817 ));
1818
1819 if markers.is_empty() {
1820 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");
1821 } else {
1822 out.push_str("- Project markers:\n");
1823 for marker in markers.iter().take(max_entries) {
1824 out.push_str(&format!(" - {}\n", marker));
1825 }
1826 }
1827
1828 match git_state {
1829 Some(git) => {
1830 out.push_str(&format!("- Git root: {}\n", git.root.display()));
1831 out.push_str(&format!("- Git branch: {}\n", git.branch));
1832 out.push_str(&format!("- Git status: {}\n", git.status_label()));
1833 }
1834 None => out.push_str("- Git: not inside a detected work tree\n"),
1835 }
1836
1837 out.push_str(&format!(
1838 "- Hematite docs/imports/reports: {}/{}/{}\n",
1839 hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
1840 ));
1841 if hematite_state.workspace_profile {
1842 out.push_str("- Workspace profile: present\n");
1843 } else {
1844 out.push_str("- Workspace profile: absent\n");
1845 }
1846
1847 if let Some(release) = release_state {
1848 out.push_str(&format!("- Cargo version: {}\n", release.version));
1849 out.push_str(&format!(
1850 "- Windows artifacts for current version: {}/{}/{}\n",
1851 bool_label(release.portable_dir),
1852 bool_label(release.portable_zip),
1853 bool_label(release.setup_exe)
1854 ));
1855 }
1856
1857 Ok(out.trim_end().to_string())
1858}
1859
1860async fn inspect_known_directory(
1861 label: &str,
1862 path: Option<PathBuf>,
1863 max_entries: usize,
1864) -> Result<String, String> {
1865 let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
1866 inspect_directory(label, path, max_entries).await
1867}
1868
1869async fn inspect_directory(
1870 label: &str,
1871 path: PathBuf,
1872 max_entries: usize,
1873) -> Result<String, String> {
1874 let label = label.to_string();
1875 tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
1876 .await
1877 .map_err(|e| format!("inspect_host task failed: {e}"))?
1878}
1879
1880fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
1881 if !path.exists() {
1882 return Err(format!("Path does not exist: {}", path.display()));
1883 }
1884 if !path.is_dir() {
1885 return Err(format!("Path is not a directory: {}", path.display()));
1886 }
1887
1888 let mut top_level_entries = Vec::new();
1889 for entry in fs::read_dir(path)
1890 .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
1891 {
1892 match entry {
1893 Ok(entry) => top_level_entries.push(entry),
1894 Err(_) => continue,
1895 }
1896 }
1897 top_level_entries.sort_by_key(|entry| entry.file_name());
1898
1899 let top_level_count = top_level_entries.len();
1900 let mut sample_names = Vec::new();
1901 let mut largest_entries = Vec::new();
1902 let mut aggregate = PathAggregate::default();
1903 let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
1904
1905 for entry in top_level_entries {
1906 let name = entry.file_name().to_string_lossy().to_string();
1907 if sample_names.len() < max_entries {
1908 sample_names.push(name.clone());
1909 }
1910 let kind = match entry.file_type() {
1911 Ok(ft) if ft.is_dir() => "dir",
1912 Ok(ft) if ft.is_symlink() => "symlink",
1913 _ => "file",
1914 };
1915 let stats = measure_path(&entry.path(), &mut budget);
1916 aggregate.merge(&stats);
1917 largest_entries.push(LargestEntry {
1918 name,
1919 kind,
1920 bytes: stats.total_bytes,
1921 });
1922 }
1923
1924 largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
1925
1926 let mut out = format!("Directory inspection: {}\n\n", label);
1927 out.push_str(&format!("- Path: {}\n", path.display()));
1928 out.push_str(&format!("- Top-level items: {}\n", top_level_count));
1929 out.push_str(&format!("- Recursive files: {}\n", aggregate.file_count));
1930 out.push_str(&format!(
1931 "- Recursive directories: {}\n",
1932 aggregate.dir_count
1933 ));
1934 out.push_str(&format!(
1935 "- Total size: {}{}\n",
1936 human_bytes(aggregate.total_bytes),
1937 if aggregate.partial {
1938 " (partial scan)"
1939 } else {
1940 ""
1941 }
1942 ));
1943 if aggregate.skipped_entries > 0 {
1944 out.push_str(&format!(
1945 "- Skipped entries: {} (permissions, symlinks, or scan budget)\n",
1946 aggregate.skipped_entries
1947 ));
1948 }
1949
1950 if !largest_entries.is_empty() {
1951 out.push_str("\nLargest top-level entries:\n");
1952 for entry in largest_entries.iter().take(max_entries) {
1953 out.push_str(&format!(
1954 "- {} [{}] - {}\n",
1955 entry.name,
1956 entry.kind,
1957 human_bytes(entry.bytes)
1958 ));
1959 }
1960 }
1961
1962 if !sample_names.is_empty() {
1963 out.push_str("\nSample names:\n");
1964 for name in sample_names {
1965 out.push_str(&format!("- {}\n", name));
1966 }
1967 }
1968
1969 Ok(out.trim_end().to_string())
1970}
1971
1972fn resolve_path(raw: &str) -> Result<PathBuf, String> {
1973 let trimmed = raw.trim();
1974 if trimmed.is_empty() {
1975 return Err("Path must not be empty.".to_string());
1976 }
1977
1978 if let Some(rest) = trimmed
1979 .strip_prefix("~/")
1980 .or_else(|| trimmed.strip_prefix("~\\"))
1981 {
1982 let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
1983 return Ok(home.join(rest));
1984 }
1985
1986 let path = PathBuf::from(trimmed);
1987 if path.is_absolute() {
1988 Ok(path)
1989 } else {
1990 let cwd =
1991 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
1992 let full_path = cwd.join(&path);
1993
1994 if !full_path.exists()
1997 && (trimmed.starts_with(".hematite") || trimmed.starts_with("hematite.exe"))
1998 {
1999 if let Some(home) = home::home_dir() {
2000 let home_path = home.join(trimmed);
2001 if home_path.exists() {
2002 return Ok(home_path);
2003 }
2004 }
2005 }
2006
2007 Ok(full_path)
2008 }
2009}
2010
2011fn workspace_mode_label(workspace_root: &Path) -> &'static str {
2012 workspace_mode_for_path(workspace_root)
2013}
2014
2015fn workspace_mode_for_path(path: &Path) -> &'static str {
2016 if is_project_marker_path(path) {
2017 "project"
2018 } else if path.join(".hematite").join("docs").exists()
2019 || path.join(".hematite").join("imports").exists()
2020 || path.join(".hematite").join("reports").exists()
2021 {
2022 "docs-only"
2023 } else {
2024 "general directory"
2025 }
2026}
2027
2028fn is_project_marker_path(path: &Path) -> bool {
2029 [
2030 "Cargo.toml",
2031 "package.json",
2032 "pyproject.toml",
2033 "go.mod",
2034 "composer.json",
2035 "requirements.txt",
2036 "Makefile",
2037 "justfile",
2038 ]
2039 .iter()
2040 .any(|name| path.join(name).exists())
2041 || path.join(".git").exists()
2042}
2043
2044fn preferred_shell_label() -> &'static str {
2045 #[cfg(target_os = "windows")]
2046 {
2047 "PowerShell"
2048 }
2049 #[cfg(not(target_os = "windows"))]
2050 {
2051 "sh"
2052 }
2053}
2054
2055fn desktop_dir() -> Option<PathBuf> {
2056 home::home_dir().map(|home| home.join("Desktop"))
2057}
2058
2059fn downloads_dir() -> Option<PathBuf> {
2060 home::home_dir().map(|home| home.join("Downloads"))
2061}
2062
2063fn count_top_level_items(path: &Path) -> Result<usize, String> {
2064 let mut count = 0usize;
2065 for entry in
2066 fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
2067 {
2068 if entry.is_ok() {
2069 count += 1;
2070 }
2071 }
2072 Ok(count)
2073}
2074
2075#[derive(Default)]
2076struct PathAggregate {
2077 total_bytes: u64,
2078 file_count: u64,
2079 dir_count: u64,
2080 skipped_entries: u64,
2081 partial: bool,
2082}
2083
2084impl PathAggregate {
2085 fn merge(&mut self, other: &PathAggregate) {
2086 self.total_bytes += other.total_bytes;
2087 self.file_count += other.file_count;
2088 self.dir_count += other.dir_count;
2089 self.skipped_entries += other.skipped_entries;
2090 self.partial |= other.partial;
2091 }
2092}
2093
2094struct LargestEntry {
2095 name: String,
2096 kind: &'static str,
2097 bytes: u64,
2098}
2099
2100fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
2101 if *budget == 0 {
2102 return PathAggregate {
2103 partial: true,
2104 skipped_entries: 1,
2105 ..PathAggregate::default()
2106 };
2107 }
2108 *budget -= 1;
2109
2110 let metadata = match fs::symlink_metadata(path) {
2111 Ok(metadata) => metadata,
2112 Err(_) => {
2113 return PathAggregate {
2114 skipped_entries: 1,
2115 ..PathAggregate::default()
2116 }
2117 }
2118 };
2119
2120 let file_type = metadata.file_type();
2121 if file_type.is_symlink() {
2122 return PathAggregate {
2123 skipped_entries: 1,
2124 ..PathAggregate::default()
2125 };
2126 }
2127
2128 if metadata.is_file() {
2129 return PathAggregate {
2130 total_bytes: metadata.len(),
2131 file_count: 1,
2132 ..PathAggregate::default()
2133 };
2134 }
2135
2136 if !metadata.is_dir() {
2137 return PathAggregate::default();
2138 }
2139
2140 let mut aggregate = PathAggregate {
2141 dir_count: 1,
2142 ..PathAggregate::default()
2143 };
2144
2145 let read_dir = match fs::read_dir(path) {
2146 Ok(read_dir) => read_dir,
2147 Err(_) => {
2148 aggregate.skipped_entries += 1;
2149 return aggregate;
2150 }
2151 };
2152
2153 for child in read_dir {
2154 match child {
2155 Ok(child) => {
2156 let child_stats = measure_path(&child.path(), budget);
2157 aggregate.merge(&child_stats);
2158 }
2159 Err(_) => aggregate.skipped_entries += 1,
2160 }
2161 }
2162
2163 aggregate
2164}
2165
2166struct PathAnalysis {
2167 total_entries: usize,
2168 unique_entries: usize,
2169 entries: Vec<String>,
2170 duplicate_entries: Vec<String>,
2171 missing_entries: Vec<String>,
2172}
2173
2174fn analyze_path_env() -> PathAnalysis {
2175 let mut entries = Vec::new();
2176 let mut duplicate_entries = Vec::new();
2177 let mut missing_entries = Vec::new();
2178 let mut seen = HashSet::new();
2179
2180 let raw_path = std::env::var_os("PATH").unwrap_or_default();
2181 for path in std::env::split_paths(&raw_path) {
2182 let display = path.display().to_string();
2183 if display.trim().is_empty() {
2184 continue;
2185 }
2186
2187 let normalized = normalize_path_entry(&display);
2188 if !seen.insert(normalized) {
2189 duplicate_entries.push(display.clone());
2190 }
2191 if !path.exists() {
2192 missing_entries.push(display.clone());
2193 }
2194 entries.push(display);
2195 }
2196
2197 let total_entries = entries.len();
2198 let unique_entries = seen.len();
2199
2200 PathAnalysis {
2201 total_entries,
2202 unique_entries,
2203 entries,
2204 duplicate_entries,
2205 missing_entries,
2206 }
2207}
2208
2209fn normalize_path_entry(value: &str) -> String {
2210 #[cfg(target_os = "windows")]
2211 {
2212 value
2213 .replace('/', "\\")
2214 .trim_end_matches(['\\', '/'])
2215 .to_ascii_lowercase()
2216 }
2217 #[cfg(not(target_os = "windows"))]
2218 {
2219 value.trim_end_matches('/').to_string()
2220 }
2221}
2222
2223struct ToolchainReport {
2224 found: Vec<(String, String)>,
2225 missing: Vec<String>,
2226}
2227
2228struct PackageManagerReport {
2229 found: Vec<(String, String)>,
2230}
2231
2232#[derive(Debug, Clone)]
2233struct ProcessEntry {
2234 name: String,
2235 pid: u32,
2236 memory_bytes: u64,
2237 cpu_seconds: Option<f64>,
2238 cpu_percent: Option<f64>,
2239 read_ops: Option<u64>,
2240 write_ops: Option<u64>,
2241 detail: Option<String>,
2242}
2243
2244#[derive(Debug, Clone)]
2245struct ServiceEntry {
2246 name: String,
2247 status: String,
2248 startup: Option<String>,
2249 display_name: Option<String>,
2250 start_name: Option<String>,
2251}
2252
2253#[derive(Debug, Clone, Default)]
2254struct NetworkAdapter {
2255 name: String,
2256 ipv4: Vec<String>,
2257 ipv6: Vec<String>,
2258 gateways: Vec<String>,
2259 dns_servers: Vec<String>,
2260 disconnected: bool,
2261}
2262
2263impl NetworkAdapter {
2264 fn is_active(&self) -> bool {
2265 !self.disconnected
2266 && (!self.ipv4.is_empty() || !self.ipv6.is_empty() || !self.gateways.is_empty())
2267 }
2268}
2269
2270#[derive(Debug, Clone, Copy, Default)]
2271struct ListenerExposureSummary {
2272 loopback_only: usize,
2273 wildcard_public: usize,
2274 specific_bind: usize,
2275}
2276
2277#[derive(Debug, Clone)]
2278struct ListeningPort {
2279 protocol: String,
2280 local: String,
2281 port: u16,
2282 state: String,
2283 pid: Option<String>,
2284 process_name: Option<String>,
2285}
2286
2287fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
2288 #[cfg(target_os = "windows")]
2289 {
2290 collect_windows_listening_ports()
2291 }
2292 #[cfg(not(target_os = "windows"))]
2293 {
2294 collect_unix_listening_ports()
2295 }
2296}
2297
2298fn collect_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2299 #[cfg(target_os = "windows")]
2300 {
2301 collect_windows_network_adapters()
2302 }
2303 #[cfg(not(target_os = "windows"))]
2304 {
2305 collect_unix_network_adapters()
2306 }
2307}
2308
2309fn collect_services() -> Result<Vec<ServiceEntry>, String> {
2310 #[cfg(target_os = "windows")]
2311 {
2312 collect_windows_services()
2313 }
2314 #[cfg(not(target_os = "windows"))]
2315 {
2316 collect_unix_services()
2317 }
2318}
2319
2320#[cfg(target_os = "windows")]
2321fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
2322 let output = Command::new("netstat")
2323 .args(["-ano", "-p", "tcp"])
2324 .output()
2325 .map_err(|e| format!("Failed to run netstat: {e}"))?;
2326 if !output.status.success() {
2327 return Err("netstat returned a non-success status.".to_string());
2328 }
2329
2330 let text = String::from_utf8_lossy(&output.stdout);
2331 let mut listeners = Vec::new();
2332 for line in text.lines() {
2333 let trimmed = line.trim();
2334 if !trimmed.starts_with("TCP") {
2335 continue;
2336 }
2337 let cols: Vec<&str> = trimmed.split_whitespace().collect();
2338 if cols.len() < 5 || cols[3] != "LISTENING" {
2339 continue;
2340 }
2341 let Some(port) = extract_port_from_socket(cols[1]) else {
2342 continue;
2343 };
2344 listeners.push(ListeningPort {
2345 protocol: cols[0].to_string(),
2346 local: cols[1].to_string(),
2347 port,
2348 state: cols[3].to_string(),
2349 pid: Some(cols[4].to_string()),
2350 process_name: None,
2351 });
2352 }
2353
2354 let unique_pids: Vec<String> = listeners
2357 .iter()
2358 .filter_map(|l| l.pid.clone())
2359 .collect::<HashSet<_>>()
2360 .into_iter()
2361 .collect();
2362
2363 if !unique_pids.is_empty() {
2364 let pid_list = unique_pids.join(",");
2365 let ps_cmd = format!(
2366 "Get-Process -Id {} -ErrorAction SilentlyContinue | Select-Object Id,Name | Format-Table -HideTableHeaders",
2367 pid_list
2368 );
2369 if let Ok(ps_out) = Command::new("powershell")
2370 .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
2371 .output()
2372 {
2373 let mut pid_map = std::collections::HashMap::<String, String>::new();
2374 let ps_text = String::from_utf8_lossy(&ps_out.stdout);
2375 for line in ps_text.lines() {
2376 let parts: Vec<&str> = line.split_whitespace().collect();
2377 if parts.len() >= 2 {
2378 pid_map.insert(parts[0].to_string(), parts[1].to_string());
2379 }
2380 }
2381 for listener in &mut listeners {
2382 if let Some(pid) = &listener.pid {
2383 listener.process_name = pid_map.get(pid).cloned();
2384 }
2385 }
2386 }
2387 }
2388
2389 Ok(listeners)
2390}
2391
2392#[cfg(not(target_os = "windows"))]
2393fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
2394 let output = Command::new("ss")
2395 .args(["-ltn"])
2396 .output()
2397 .map_err(|e| format!("Failed to run ss: {e}"))?;
2398 if !output.status.success() {
2399 return Err("ss returned a non-success status.".to_string());
2400 }
2401
2402 let text = String::from_utf8_lossy(&output.stdout);
2403 let mut listeners = Vec::new();
2404 for line in text.lines().skip(1) {
2405 let cols: Vec<&str> = line.split_whitespace().collect();
2406 if cols.len() < 4 {
2407 continue;
2408 }
2409 let Some(port) = extract_port_from_socket(cols[3]) else {
2410 continue;
2411 };
2412 listeners.push(ListeningPort {
2413 protocol: "tcp".to_string(),
2414 local: cols[3].to_string(),
2415 port,
2416 state: cols[0].to_string(),
2417 pid: None,
2418 process_name: None,
2419 });
2420 }
2421
2422 Ok(listeners)
2423}
2424
2425fn collect_processes() -> Result<Vec<ProcessEntry>, String> {
2426 #[cfg(target_os = "windows")]
2427 {
2428 collect_windows_processes()
2429 }
2430 #[cfg(not(target_os = "windows"))]
2431 {
2432 collect_unix_processes()
2433 }
2434}
2435
2436#[cfg(target_os = "windows")]
2437fn collect_windows_services() -> Result<Vec<ServiceEntry>, String> {
2438 let command = "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,DisplayName,StartName | ConvertTo-Json -Compress";
2439 let output = Command::new("powershell")
2440 .args(["-NoProfile", "-Command", command])
2441 .output()
2442 .map_err(|e| format!("Failed to run PowerShell service inspection: {e}"))?;
2443 if !output.status.success() {
2444 return Err("PowerShell service inspection returned a non-success status.".to_string());
2445 }
2446
2447 parse_windows_services_json(&String::from_utf8_lossy(&output.stdout))
2448}
2449
2450#[cfg(not(target_os = "windows"))]
2451fn collect_unix_services() -> Result<Vec<ServiceEntry>, String> {
2452 let status_output = Command::new("systemctl")
2453 .args([
2454 "list-units",
2455 "--type=service",
2456 "--all",
2457 "--no-pager",
2458 "--no-legend",
2459 "--plain",
2460 ])
2461 .output()
2462 .map_err(|e| format!("Failed to run systemctl list-units: {e}"))?;
2463 if !status_output.status.success() {
2464 return Err("systemctl list-units returned a non-success status.".to_string());
2465 }
2466
2467 let startup_output = Command::new("systemctl")
2468 .args([
2469 "list-unit-files",
2470 "--type=service",
2471 "--no-legend",
2472 "--no-pager",
2473 "--plain",
2474 ])
2475 .output()
2476 .map_err(|e| format!("Failed to run systemctl list-unit-files: {e}"))?;
2477 if !startup_output.status.success() {
2478 return Err("systemctl list-unit-files returned a non-success status.".to_string());
2479 }
2480
2481 Ok(parse_unix_services(
2482 &String::from_utf8_lossy(&status_output.stdout),
2483 &String::from_utf8_lossy(&startup_output.stdout),
2484 ))
2485}
2486
2487#[cfg(target_os = "windows")]
2488fn collect_windows_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2489 let output = Command::new("ipconfig")
2490 .args(["/all"])
2491 .output()
2492 .map_err(|e| format!("Failed to run ipconfig: {e}"))?;
2493 if !output.status.success() {
2494 return Err("ipconfig returned a non-success status.".to_string());
2495 }
2496
2497 Ok(parse_windows_ipconfig_all(&String::from_utf8_lossy(
2498 &output.stdout,
2499 )))
2500}
2501
2502#[cfg(not(target_os = "windows"))]
2503fn collect_unix_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2504 let addr_output = Command::new("ip")
2505 .args(["-o", "addr", "show", "up"])
2506 .output()
2507 .map_err(|e| format!("Failed to run ip addr: {e}"))?;
2508 if !addr_output.status.success() {
2509 return Err("ip addr returned a non-success status.".to_string());
2510 }
2511
2512 let route_output = Command::new("ip")
2513 .args(["route", "show", "default"])
2514 .output()
2515 .map_err(|e| format!("Failed to run ip route: {e}"))?;
2516 if !route_output.status.success() {
2517 return Err("ip route returned a non-success status.".to_string());
2518 }
2519
2520 let mut adapters = parse_unix_ip_addr(&String::from_utf8_lossy(&addr_output.stdout));
2521 apply_unix_default_routes(
2522 &mut adapters,
2523 &String::from_utf8_lossy(&route_output.stdout),
2524 );
2525 apply_unix_dns_servers(&mut adapters);
2526 Ok(adapters)
2527}
2528
2529#[cfg(target_os = "windows")]
2530fn collect_windows_processes() -> Result<Vec<ProcessEntry>, String> {
2531 let script = r#"
2533 $s1 = Get-Process | Select-Object Id, CPU
2534 Start-Sleep -Milliseconds 250
2535 $s2 = Get-Process | Select-Object Name, Id, WorkingSet64, CPU, ReadOperationCount, WriteOperationCount
2536 $s2 | ForEach-Object {
2537 $p2 = $_
2538 $p1 = $s1 | Where-Object { $_.Id -eq $p2.Id }
2539 $pct = 0.0
2540 if ($p1 -and $p2.CPU -gt $p1.CPU) {
2541 # (Delta CPU seconds / interval) * 100 / LogicalProcessors
2542 # Note: We skip division by logical processors to show 'per-core' usage or just raw % if preferred.
2543 # Standard Task Manager style is (delta / interval) * 100.
2544 $pct = [math]::Round((($p2.CPU - $p1.CPU) / 0.25) * 100, 1)
2545 }
2546 "PID:$($p2.Id)|NAME:$($p2.Name)|MEM:$($p2.WorkingSet64)|CPU_S:$($p2.CPU)|CPU_P:$pct|READ:$($p2.ReadOperationCount)|WRITE:$($p2.WriteOperationCount)"
2547 }
2548 "#;
2549
2550 let output = Command::new("powershell")
2551 .args(["-NoProfile", "-Command", script])
2552 .output()
2553 .map_err(|e| format!("Failed to run powershell Get-Process: {e}"))?;
2554
2555 let text = String::from_utf8_lossy(&output.stdout);
2556 let mut out = Vec::new();
2557 for line in text.lines() {
2558 let parts: Vec<&str> = line.trim().split('|').collect();
2559 if parts.len() < 5 {
2560 continue;
2561 }
2562 let mut entry = ProcessEntry {
2563 name: "unknown".to_string(),
2564 pid: 0,
2565 memory_bytes: 0,
2566 cpu_seconds: None,
2567 cpu_percent: None,
2568 read_ops: None,
2569 write_ops: None,
2570 detail: None,
2571 };
2572 for p in parts {
2573 if let Some((k, v)) = p.split_once(':') {
2574 match k {
2575 "PID" => entry.pid = v.parse().unwrap_or(0),
2576 "NAME" => entry.name = v.to_string(),
2577 "MEM" => entry.memory_bytes = v.parse().unwrap_or(0),
2578 "CPU_S" => entry.cpu_seconds = v.parse().ok(),
2579 "CPU_P" => entry.cpu_percent = v.parse().ok(),
2580 "READ" => entry.read_ops = v.parse().ok(),
2581 "WRITE" => entry.write_ops = v.parse().ok(),
2582 _ => {}
2583 }
2584 }
2585 }
2586 out.push(entry);
2587 }
2588 Ok(out)
2589}
2590
2591#[cfg(not(target_os = "windows"))]
2592fn collect_unix_processes() -> Result<Vec<ProcessEntry>, String> {
2593 let output = Command::new("ps")
2594 .args(["-eo", "pid=,rss=,comm="])
2595 .output()
2596 .map_err(|e| format!("Failed to run ps: {e}"))?;
2597 if !output.status.success() {
2598 return Err("ps returned a non-success status.".to_string());
2599 }
2600
2601 let text = String::from_utf8_lossy(&output.stdout);
2602 let mut processes = Vec::new();
2603 for line in text.lines() {
2604 let cols: Vec<&str> = line.split_whitespace().collect();
2605 if cols.len() < 3 {
2606 continue;
2607 }
2608 let (Some(pid), Some(rss_kib)) = (cols[0].parse::<u32>().ok(), cols[1].parse::<u64>().ok())
2609 else {
2610 continue;
2611 };
2612 processes.push(ProcessEntry {
2613 name: cols[2..].join(" "),
2614 pid,
2615 memory_bytes: rss_kib * 1024,
2616 cpu_seconds: None,
2617 cpu_percent: None,
2618 read_ops: None,
2619 write_ops: None,
2620 detail: None,
2621 });
2622 }
2623
2624 Ok(processes)
2625}
2626
2627fn extract_port_from_socket(value: &str) -> Option<u16> {
2628 let cleaned = value.trim().trim_matches(['[', ']']);
2629 let port_str = cleaned.rsplit(':').next()?;
2630 port_str.parse::<u16>().ok()
2631}
2632
2633fn listener_exposure_summary(listeners: Vec<ListeningPort>) -> ListenerExposureSummary {
2634 let mut summary = ListenerExposureSummary::default();
2635 for entry in listeners {
2636 let local = entry.local.to_ascii_lowercase();
2637 if is_loopback_listener(&local) {
2638 summary.loopback_only += 1;
2639 } else if is_wildcard_listener(&local) {
2640 summary.wildcard_public += 1;
2641 } else {
2642 summary.specific_bind += 1;
2643 }
2644 }
2645 summary
2646}
2647
2648fn is_loopback_listener(local: &str) -> bool {
2649 local.starts_with("127.")
2650 || local.starts_with("[::1]")
2651 || local.starts_with("::1")
2652 || local.starts_with("localhost:")
2653}
2654
2655fn is_wildcard_listener(local: &str) -> bool {
2656 local.starts_with("0.0.0.0:")
2657 || local.starts_with("[::]:")
2658 || local.starts_with(":::")
2659 || local == "*:*"
2660}
2661
2662struct GitState {
2663 root: PathBuf,
2664 branch: String,
2665 dirty_entries: usize,
2666}
2667
2668impl GitState {
2669 fn status_label(&self) -> String {
2670 if self.dirty_entries == 0 {
2671 "clean".to_string()
2672 } else {
2673 format!("dirty ({} changed path(s))", self.dirty_entries)
2674 }
2675 }
2676}
2677
2678fn inspect_git_state(path: &Path) -> Option<GitState> {
2679 let root = capture_first_line(
2680 "git",
2681 &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
2682 )?;
2683 let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
2684 .unwrap_or_else(|| "detached".to_string());
2685 let output = Command::new("git")
2686 .args(["-C", path.to_str()?, "status", "--short"])
2687 .output()
2688 .ok()?;
2689 if !output.status.success() {
2690 return None;
2691 }
2692 let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
2693 Some(GitState {
2694 root: PathBuf::from(root),
2695 branch,
2696 dirty_entries,
2697 })
2698}
2699
2700struct HematiteState {
2701 docs_count: usize,
2702 import_count: usize,
2703 report_count: usize,
2704 workspace_profile: bool,
2705}
2706
2707fn collect_hematite_state(path: &Path) -> HematiteState {
2708 let root = path.join(".hematite");
2709 HematiteState {
2710 docs_count: count_entries_if_exists(&root.join("docs")),
2711 import_count: count_entries_if_exists(&root.join("imports")),
2712 report_count: count_entries_if_exists(&root.join("reports")),
2713 workspace_profile: root.join("workspace_profile.json").exists(),
2714 }
2715}
2716
2717fn count_entries_if_exists(path: &Path) -> usize {
2718 if !path.exists() || !path.is_dir() {
2719 return 0;
2720 }
2721 fs::read_dir(path)
2722 .ok()
2723 .map(|iter| iter.filter(|entry| entry.is_ok()).count())
2724 .unwrap_or(0)
2725}
2726
2727fn collect_project_markers(path: &Path) -> Vec<String> {
2728 [
2729 "Cargo.toml",
2730 "package.json",
2731 "pyproject.toml",
2732 "go.mod",
2733 "justfile",
2734 "Makefile",
2735 ".git",
2736 ]
2737 .iter()
2738 .filter_map(|name| path.join(name).exists().then(|| (*name).to_string()))
2739 .collect()
2740}
2741
2742struct ReleaseArtifactState {
2743 version: String,
2744 portable_dir: bool,
2745 portable_zip: bool,
2746 setup_exe: bool,
2747}
2748
2749fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
2750 let cargo_toml = path.join("Cargo.toml");
2751 if !cargo_toml.exists() {
2752 return None;
2753 }
2754 let cargo_text = fs::read_to_string(cargo_toml).ok()?;
2755 let version = [regex_line_capture(
2756 &cargo_text,
2757 r#"(?m)^version\s*=\s*"([^"]+)""#,
2758 )?]
2759 .concat();
2760 let dist_windows = path.join("dist").join("windows");
2761 let prefix = format!("Hematite-{}", version);
2762 Some(ReleaseArtifactState {
2763 version,
2764 portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
2765 portable_zip: dist_windows
2766 .join(format!("{}-portable.zip", prefix))
2767 .exists(),
2768 setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
2769 })
2770}
2771
2772fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
2773 let regex = regex::Regex::new(pattern).ok()?;
2774 let captures = regex.captures(text)?;
2775 captures.get(1).map(|m| m.as_str().to_string())
2776}
2777
2778fn bool_label(value: bool) -> &'static str {
2779 if value {
2780 "yes"
2781 } else {
2782 "no"
2783 }
2784}
2785
2786fn collect_toolchains() -> ToolchainReport {
2787 let checks = [
2788 ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
2789 ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
2790 ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
2791 ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
2792 ToolCheck::new(
2793 "npm",
2794 &[
2795 CommandProbe::new("npm", &["--version"]),
2796 CommandProbe::new("npm.cmd", &["--version"]),
2797 ],
2798 ),
2799 ToolCheck::new(
2800 "pnpm",
2801 &[
2802 CommandProbe::new("pnpm", &["--version"]),
2803 CommandProbe::new("pnpm.cmd", &["--version"]),
2804 ],
2805 ),
2806 ToolCheck::new(
2807 "python",
2808 &[
2809 CommandProbe::new("python", &["--version"]),
2810 CommandProbe::new("python3", &["--version"]),
2811 CommandProbe::new("py", &["-3", "--version"]),
2812 CommandProbe::new("py", &["--version"]),
2813 ],
2814 ),
2815 ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
2816 ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
2817 ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
2818 ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
2819 ];
2820
2821 let mut found = Vec::new();
2822 let mut missing = Vec::new();
2823
2824 for check in checks {
2825 match check.detect() {
2826 Some(version) => found.push((check.label.to_string(), version)),
2827 None => missing.push(check.label.to_string()),
2828 }
2829 }
2830
2831 ToolchainReport { found, missing }
2832}
2833
2834fn collect_package_managers() -> PackageManagerReport {
2835 let checks = [
2836 ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
2837 ToolCheck::new(
2838 "npm",
2839 &[
2840 CommandProbe::new("npm", &["--version"]),
2841 CommandProbe::new("npm.cmd", &["--version"]),
2842 ],
2843 ),
2844 ToolCheck::new(
2845 "pnpm",
2846 &[
2847 CommandProbe::new("pnpm", &["--version"]),
2848 CommandProbe::new("pnpm.cmd", &["--version"]),
2849 ],
2850 ),
2851 ToolCheck::new(
2852 "pip",
2853 &[
2854 CommandProbe::new("python", &["-m", "pip", "--version"]),
2855 CommandProbe::new("python3", &["-m", "pip", "--version"]),
2856 CommandProbe::new("py", &["-3", "-m", "pip", "--version"]),
2857 CommandProbe::new("py", &["-m", "pip", "--version"]),
2858 CommandProbe::new("pip", &["--version"]),
2859 ],
2860 ),
2861 ToolCheck::new("pipx", &[CommandProbe::new("pipx", &["--version"])]),
2862 ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
2863 ToolCheck::new("winget", &[CommandProbe::new("winget", &["--version"])]),
2864 ToolCheck::new(
2865 "choco",
2866 &[
2867 CommandProbe::new("choco", &["--version"]),
2868 CommandProbe::new("choco.exe", &["--version"]),
2869 ],
2870 ),
2871 ToolCheck::new("scoop", &[CommandProbe::new("scoop", &["--version"])]),
2872 ];
2873
2874 let mut found = Vec::new();
2875 for check in checks {
2876 match check.detect() {
2877 Some(version) => found.push((check.label.to_string(), version)),
2878 None => {}
2879 }
2880 }
2881
2882 PackageManagerReport { found }
2883}
2884
2885#[derive(Clone)]
2886struct ToolCheck {
2887 label: &'static str,
2888 probes: Vec<CommandProbe>,
2889}
2890
2891impl ToolCheck {
2892 fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
2893 Self {
2894 label,
2895 probes: probes.to_vec(),
2896 }
2897 }
2898
2899 fn detect(&self) -> Option<String> {
2900 for probe in &self.probes {
2901 if let Some(output) = capture_first_line(probe.program, probe.args) {
2902 return Some(output);
2903 }
2904 }
2905 None
2906 }
2907}
2908
2909#[derive(Clone, Copy)]
2910struct CommandProbe {
2911 program: &'static str,
2912 args: &'static [&'static str],
2913}
2914
2915impl CommandProbe {
2916 const fn new(program: &'static str, args: &'static [&'static str]) -> Self {
2917 Self { program, args }
2918 }
2919}
2920
2921fn build_env_doctor_findings(
2922 toolchains: &ToolchainReport,
2923 package_managers: &PackageManagerReport,
2924 path_stats: &PathAnalysis,
2925) -> Vec<String> {
2926 let found_tools = toolchains
2927 .found
2928 .iter()
2929 .map(|(label, _)| label.as_str())
2930 .collect::<HashSet<_>>();
2931 let found_managers = package_managers
2932 .found
2933 .iter()
2934 .map(|(label, _)| label.as_str())
2935 .collect::<HashSet<_>>();
2936
2937 let mut findings = Vec::new();
2938
2939 if path_stats.duplicate_entries.len() > 0 {
2940 findings.push(format!(
2941 "PATH contains {} duplicate entries. That is usually harmless but worth cleaning up.",
2942 path_stats.duplicate_entries.len()
2943 ));
2944 }
2945 if path_stats.missing_entries.len() > 0 {
2946 findings.push(format!(
2947 "PATH contains {} entries that do not exist on disk.",
2948 path_stats.missing_entries.len()
2949 ));
2950 }
2951 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
2952 findings.push(
2953 "Rust is present but Cargo was not detected. That is an incomplete Rust toolchain."
2954 .to_string(),
2955 );
2956 }
2957 if found_tools.contains("node")
2958 && !found_managers.contains("npm")
2959 && !found_managers.contains("pnpm")
2960 {
2961 findings.push(
2962 "Node is present but no JavaScript package manager was detected (npm or pnpm)."
2963 .to_string(),
2964 );
2965 }
2966 if found_tools.contains("python")
2967 && !found_managers.contains("pip")
2968 && !found_managers.contains("uv")
2969 && !found_managers.contains("pipx")
2970 {
2971 findings.push(
2972 "Python is present but no Python package manager was detected (pip, uv, or pipx)."
2973 .to_string(),
2974 );
2975 }
2976 let windows_manager_count = ["winget", "choco", "scoop"]
2977 .iter()
2978 .filter(|label| found_managers.contains(**label))
2979 .count();
2980 if windows_manager_count > 1 {
2981 findings.push(
2982 "Multiple Windows package managers are installed. That is workable, but it can create overlap in update paths."
2983 .to_string(),
2984 );
2985 }
2986 if findings.is_empty() && !found_managers.is_empty() {
2987 findings.push(
2988 "Core package-manager coverage looks healthy for a normal developer workstation."
2989 .to_string(),
2990 );
2991 }
2992
2993 findings
2994}
2995
2996fn capture_first_line(program: &str, args: &[&str]) -> Option<String> {
2997 let output = std::process::Command::new(program)
2998 .args(args)
2999 .output()
3000 .ok()?;
3001 if !output.status.success() {
3002 return None;
3003 }
3004
3005 let stdout = if output.stdout.is_empty() {
3006 String::from_utf8_lossy(&output.stderr).into_owned()
3007 } else {
3008 String::from_utf8_lossy(&output.stdout).into_owned()
3009 };
3010
3011 stdout
3012 .lines()
3013 .map(str::trim)
3014 .find(|line| !line.is_empty())
3015 .map(|line| line.to_string())
3016}
3017
3018fn human_bytes(bytes: u64) -> String {
3019 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
3020 let mut value = bytes as f64;
3021 let mut unit_index = 0usize;
3022
3023 while value >= 1024.0 && unit_index < UNITS.len() - 1 {
3024 value /= 1024.0;
3025 unit_index += 1;
3026 }
3027
3028 if unit_index == 0 {
3029 format!("{} {}", bytes, UNITS[unit_index])
3030 } else {
3031 format!("{value:.1} {}", UNITS[unit_index])
3032 }
3033}
3034
3035#[cfg(target_os = "windows")]
3036fn parse_windows_ipconfig_all(text: &str) -> Vec<NetworkAdapter> {
3037 let mut adapters = Vec::new();
3038 let mut current: Option<NetworkAdapter> = None;
3039 let mut pending_dns = false;
3040
3041 for raw_line in text.lines() {
3042 let line = raw_line.trim_end();
3043 let trimmed = line.trim();
3044 if trimmed.is_empty() {
3045 pending_dns = false;
3046 continue;
3047 }
3048
3049 if !line.starts_with(' ') && trimmed.ends_with(':') && trimmed.contains("adapter") {
3050 if let Some(adapter) = current.take() {
3051 adapters.push(adapter);
3052 }
3053 current = Some(NetworkAdapter {
3054 name: trimmed.trim_end_matches(':').to_string(),
3055 ..NetworkAdapter::default()
3056 });
3057 pending_dns = false;
3058 continue;
3059 }
3060
3061 let Some(adapter) = current.as_mut() else {
3062 continue;
3063 };
3064
3065 if trimmed.contains("Media State") && trimmed.contains("disconnected") {
3066 adapter.disconnected = true;
3067 }
3068
3069 if let Some(value) = value_after_colon(trimmed) {
3070 let normalized = normalize_ipconfig_value(value);
3071 if trimmed.starts_with("IPv4 Address") && !normalized.is_empty() {
3072 adapter.ipv4.push(normalized);
3073 pending_dns = false;
3074 } else if trimmed.starts_with("IPv6 Address")
3075 || trimmed.starts_with("Temporary IPv6 Address")
3076 || trimmed.starts_with("Link-local IPv6 Address")
3077 {
3078 if !normalized.is_empty() {
3079 adapter.ipv6.push(normalized);
3080 }
3081 pending_dns = false;
3082 } else if trimmed.starts_with("Default Gateway") {
3083 if !normalized.is_empty() {
3084 adapter.gateways.push(normalized);
3085 }
3086 pending_dns = false;
3087 } else if trimmed.starts_with("DNS Servers") {
3088 if !normalized.is_empty() {
3089 adapter.dns_servers.push(normalized);
3090 }
3091 pending_dns = true;
3092 } else {
3093 pending_dns = false;
3094 }
3095 } else if pending_dns {
3096 let normalized = normalize_ipconfig_value(trimmed);
3097 if !normalized.is_empty() {
3098 adapter.dns_servers.push(normalized);
3099 }
3100 }
3101 }
3102
3103 if let Some(adapter) = current.take() {
3104 adapters.push(adapter);
3105 }
3106
3107 for adapter in &mut adapters {
3108 dedup_vec(&mut adapter.ipv4);
3109 dedup_vec(&mut adapter.ipv6);
3110 dedup_vec(&mut adapter.gateways);
3111 dedup_vec(&mut adapter.dns_servers);
3112 }
3113
3114 adapters
3115}
3116
3117#[cfg(not(target_os = "windows"))]
3118fn parse_unix_ip_addr(text: &str) -> Vec<NetworkAdapter> {
3119 let mut adapters = std::collections::BTreeMap::<String, NetworkAdapter>::new();
3120
3121 for line in text.lines() {
3122 let cols: Vec<&str> = line.split_whitespace().collect();
3123 if cols.len() < 4 {
3124 continue;
3125 }
3126 let name = cols[1].trim_end_matches(':').to_string();
3127 let family = cols[2];
3128 let addr = cols[3].split('/').next().unwrap_or("").to_string();
3129 let entry = adapters
3130 .entry(name.clone())
3131 .or_insert_with(|| NetworkAdapter {
3132 name,
3133 ..NetworkAdapter::default()
3134 });
3135 match family {
3136 "inet" if !addr.is_empty() => entry.ipv4.push(addr),
3137 "inet6" if !addr.is_empty() => entry.ipv6.push(addr),
3138 _ => {}
3139 }
3140 }
3141
3142 adapters.into_values().collect()
3143}
3144
3145#[cfg(not(target_os = "windows"))]
3146fn apply_unix_default_routes(adapters: &mut [NetworkAdapter], text: &str) {
3147 for line in text.lines() {
3148 let cols: Vec<&str> = line.split_whitespace().collect();
3149 if cols.len() < 5 {
3150 continue;
3151 }
3152 let gateway = cols
3153 .windows(2)
3154 .find(|pair| pair[0] == "via")
3155 .map(|pair| pair[1].to_string());
3156 let dev = cols
3157 .windows(2)
3158 .find(|pair| pair[0] == "dev")
3159 .map(|pair| pair[1]);
3160 if let (Some(gateway), Some(dev)) = (gateway, dev) {
3161 if let Some(adapter) = adapters.iter_mut().find(|adapter| adapter.name == dev) {
3162 adapter.gateways.push(gateway);
3163 }
3164 }
3165 }
3166
3167 for adapter in adapters {
3168 dedup_vec(&mut adapter.gateways);
3169 }
3170}
3171
3172#[cfg(not(target_os = "windows"))]
3173fn apply_unix_dns_servers(adapters: &mut [NetworkAdapter]) {
3174 let Ok(text) = fs::read_to_string("/etc/resolv.conf") else {
3175 return;
3176 };
3177 let mut dns_servers = text
3178 .lines()
3179 .filter_map(|line| line.strip_prefix("nameserver "))
3180 .map(str::trim)
3181 .filter(|value| !value.is_empty())
3182 .map(|value| value.to_string())
3183 .collect::<Vec<_>>();
3184 dedup_vec(&mut dns_servers);
3185 if dns_servers.is_empty() {
3186 return;
3187 }
3188 for adapter in adapters.iter_mut().filter(|adapter| adapter.is_active()) {
3189 adapter.dns_servers = dns_servers.clone();
3190 }
3191}
3192
3193#[cfg(target_os = "windows")]
3194fn value_after_colon(line: &str) -> Option<&str> {
3195 line.split_once(':').map(|(_, value)| value.trim())
3196}
3197
3198#[cfg(target_os = "windows")]
3199fn normalize_ipconfig_value(value: &str) -> String {
3200 value
3201 .trim()
3202 .trim_matches(['(', ')'])
3203 .trim_end_matches("(Preferred)")
3204 .trim()
3205 .to_string()
3206}
3207
3208fn dedup_vec(values: &mut Vec<String>) {
3209 let mut seen = HashSet::new();
3210 values.retain(|value| seen.insert(value.clone()));
3211}
3212
3213#[cfg(target_os = "windows")]
3214fn parse_windows_services_json(text: &str) -> Result<Vec<ServiceEntry>, String> {
3215 let trimmed = text.trim();
3216 if trimmed.is_empty() {
3217 return Ok(Vec::new());
3218 }
3219
3220 let value: Value = serde_json::from_str(trimmed)
3221 .map_err(|e| format!("Failed to parse PowerShell service JSON: {e}"))?;
3222 let entries = match value {
3223 Value::Array(items) => items,
3224 other => vec![other],
3225 };
3226
3227 let mut services = Vec::new();
3228 for entry in entries {
3229 let Some(name) = entry.get("Name").and_then(|v| v.as_str()) else {
3230 continue;
3231 };
3232 services.push(ServiceEntry {
3233 name: name.to_string(),
3234 status: entry
3235 .get("State")
3236 .and_then(|v| v.as_str())
3237 .unwrap_or("unknown")
3238 .to_string(),
3239 startup: entry
3240 .get("StartMode")
3241 .and_then(|v| v.as_str())
3242 .map(|v| v.to_string()),
3243 display_name: entry
3244 .get("DisplayName")
3245 .and_then(|v| v.as_str())
3246 .map(|v| v.to_string()),
3247 start_name: entry
3248 .get("StartName")
3249 .and_then(|v| v.as_str())
3250 .map(|v| v.to_string()),
3251 });
3252 }
3253
3254 Ok(services)
3255}
3256
3257#[cfg(not(target_os = "windows"))]
3258fn parse_unix_services(status_text: &str, startup_text: &str) -> Vec<ServiceEntry> {
3259 let mut startup_modes = std::collections::HashMap::<String, String>::new();
3260 for line in startup_text.lines() {
3261 let cols: Vec<&str> = line.split_whitespace().collect();
3262 if cols.len() < 2 {
3263 continue;
3264 }
3265 startup_modes.insert(cols[0].to_string(), cols[1].to_string());
3266 }
3267
3268 let mut services = Vec::new();
3269 for line in status_text.lines() {
3270 let cols: Vec<&str> = line.split_whitespace().collect();
3271 if cols.len() < 4 {
3272 continue;
3273 }
3274 let unit = cols[0];
3275 let load = cols[1];
3276 let active = cols[2];
3277 let sub = cols[3];
3278 let description = if cols.len() > 4 {
3279 Some(cols[4..].join(" "))
3280 } else {
3281 None
3282 };
3283 services.push(ServiceEntry {
3284 name: unit.to_string(),
3285 status: format!("{}/{}", active, sub),
3286 startup: startup_modes
3287 .get(unit)
3288 .cloned()
3289 .or_else(|| Some(load.to_string())),
3290 display_name: description,
3291 start_name: None,
3292 });
3293 }
3294
3295 services
3296}
3297
3298fn inspect_health_report() -> Result<String, String> {
3304 let mut needs_fix: Vec<String> = Vec::new();
3305 let mut watch: Vec<String> = Vec::new();
3306 let mut good: Vec<String> = Vec::new();
3307 let mut tips: Vec<String> = Vec::new();
3308
3309 health_check_disk(&mut needs_fix, &mut watch, &mut good);
3310 health_check_memory(&mut watch, &mut good);
3311 health_check_tools(&mut watch, &mut good, &mut tips);
3312 health_check_recent_errors(&mut watch, &mut tips);
3313
3314 let overall = if !needs_fix.is_empty() {
3315 "ACTION REQUIRED"
3316 } else if !watch.is_empty() {
3317 "WORTH A LOOK"
3318 } else {
3319 "ALL GOOD"
3320 };
3321
3322 let mut out = format!("System Health Report — {overall}\n\n");
3323
3324 if !needs_fix.is_empty() {
3325 out.push_str("Needs fixing:\n");
3326 for item in &needs_fix {
3327 out.push_str(&format!(" [!] {item}\n"));
3328 }
3329 out.push('\n');
3330 }
3331 if !watch.is_empty() {
3332 out.push_str("Worth watching:\n");
3333 for item in &watch {
3334 out.push_str(&format!(" [-] {item}\n"));
3335 }
3336 out.push('\n');
3337 }
3338 if !good.is_empty() {
3339 out.push_str("Looking good:\n");
3340 for item in &good {
3341 out.push_str(&format!(" [+] {item}\n"));
3342 }
3343 out.push('\n');
3344 }
3345 if !tips.is_empty() {
3346 out.push_str("To dig deeper:\n");
3347 for tip in &tips {
3348 out.push_str(&format!(" {tip}\n"));
3349 }
3350 }
3351
3352 Ok(out.trim_end().to_string())
3353}
3354
3355fn health_check_disk(needs_fix: &mut Vec<String>, watch: &mut Vec<String>, good: &mut Vec<String>) {
3356 #[cfg(target_os = "windows")]
3357 {
3358 let script = r#"try {
3359 $d = Get-PSDrive C -ErrorAction Stop
3360 "$($d.Free)|$($d.Used)"
3361} catch { "ERR" }"#;
3362 if let Ok(out) = Command::new("powershell")
3363 .args(["-NoProfile", "-Command", script])
3364 .output()
3365 {
3366 let text = String::from_utf8_lossy(&out.stdout);
3367 let text = text.trim();
3368 if !text.starts_with("ERR") {
3369 let parts: Vec<&str> = text.split('|').collect();
3370 if parts.len() == 2 {
3371 let free_bytes: u64 = parts[0].trim().parse().unwrap_or(0);
3372 let used_bytes: u64 = parts[1].trim().parse().unwrap_or(0);
3373 let total = free_bytes + used_bytes;
3374 let free_gb = free_bytes / 1_073_741_824;
3375 let pct_free = if total > 0 {
3376 (free_bytes as f64 / total as f64 * 100.0) as u64
3377 } else {
3378 0
3379 };
3380 let msg = format!("Disk: {free_gb} GB free on C: ({pct_free}% available)");
3381 if free_gb < 5 {
3382 needs_fix.push(format!(
3383 "{msg} — very low. Free up space or your system may slow down or stop working."
3384 ));
3385 } else if free_gb < 15 {
3386 watch.push(format!("{msg} — getting low, consider cleaning up."));
3387 } else {
3388 good.push(msg);
3389 }
3390 return;
3391 }
3392 }
3393 }
3394 watch.push("Disk: could not read free space from C: drive.".to_string());
3395 }
3396
3397 #[cfg(not(target_os = "windows"))]
3398 {
3399 if let Ok(out) = Command::new("df").args(["-BG", "/"]).output() {
3400 let text = String::from_utf8_lossy(&out.stdout);
3401 for line in text.lines().skip(1) {
3402 let cols: Vec<&str> = line.split_whitespace().collect();
3403 if cols.len() >= 5 {
3404 let avail_str = cols[3].trim_end_matches('G');
3405 let use_pct = cols[4].trim_end_matches('%');
3406 let avail_gb: u64 = avail_str.parse().unwrap_or(0);
3407 let used_pct: u64 = use_pct.parse().unwrap_or(0);
3408 let msg = format!("Disk: {avail_gb} GB free on / ({used_pct}% used)");
3409 if avail_gb < 5 {
3410 needs_fix.push(format!(
3411 "{msg} — very low. Free up space to prevent system issues."
3412 ));
3413 } else if avail_gb < 15 {
3414 watch.push(format!("{msg} — getting low."));
3415 } else {
3416 good.push(msg);
3417 }
3418 return;
3419 }
3420 }
3421 }
3422 watch.push("Disk: could not determine free space.".to_string());
3423 }
3424}
3425
3426fn health_check_memory(watch: &mut Vec<String>, good: &mut Vec<String>) {
3427 #[cfg(target_os = "windows")]
3428 {
3429 let script = r#"try {
3430 $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
3431 "$($os.FreePhysicalMemory)|$($os.TotalVisibleMemorySize)"
3432} catch { "ERR" }"#;
3433 if let Ok(out) = Command::new("powershell")
3434 .args(["-NoProfile", "-Command", script])
3435 .output()
3436 {
3437 let text = String::from_utf8_lossy(&out.stdout);
3438 let text = text.trim();
3439 if !text.starts_with("ERR") {
3440 let parts: Vec<&str> = text.split('|').collect();
3441 if parts.len() == 2 {
3442 let free_kb: u64 = parts[0].trim().parse().unwrap_or(0);
3443 let total_kb: u64 = parts[1].trim().parse().unwrap_or(0);
3444 if total_kb > 0 {
3445 let free_gb = free_kb / 1_048_576;
3446 let total_gb = total_kb / 1_048_576;
3447 let free_pct = free_kb * 100 / total_kb;
3448 let msg = format!(
3449 "RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)"
3450 );
3451 if free_pct < 10 {
3452 watch.push(format!(
3453 "{msg} — very low. Close unused apps to free up memory."
3454 ));
3455 } else if free_pct < 25 {
3456 watch.push(format!("{msg} — running a bit low."));
3457 } else {
3458 good.push(msg);
3459 }
3460 return;
3461 }
3462 }
3463 }
3464 }
3465 }
3466
3467 #[cfg(not(target_os = "windows"))]
3468 {
3469 if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
3470 let mut total_kb = 0u64;
3471 let mut avail_kb = 0u64;
3472 for line in content.lines() {
3473 if line.starts_with("MemTotal:") {
3474 total_kb = line
3475 .split_whitespace()
3476 .nth(1)
3477 .and_then(|v| v.parse().ok())
3478 .unwrap_or(0);
3479 } else if line.starts_with("MemAvailable:") {
3480 avail_kb = line
3481 .split_whitespace()
3482 .nth(1)
3483 .and_then(|v| v.parse().ok())
3484 .unwrap_or(0);
3485 }
3486 }
3487 if total_kb > 0 {
3488 let free_gb = avail_kb / 1_048_576;
3489 let total_gb = total_kb / 1_048_576;
3490 let free_pct = avail_kb * 100 / total_kb;
3491 let msg =
3492 format!("RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)");
3493 if free_pct < 10 {
3494 watch.push(format!("{msg} — very low. Close unused apps."));
3495 } else if free_pct < 25 {
3496 watch.push(format!("{msg} — running a bit low."));
3497 } else {
3498 good.push(msg);
3499 }
3500 }
3501 }
3502 }
3503}
3504
3505fn health_check_tools(watch: &mut Vec<String>, good: &mut Vec<String>, tips: &mut Vec<String>) {
3506 let tool_checks: &[(&str, &str, &str)] = &[
3507 ("git", "--version", "Git"),
3508 ("cargo", "--version", "Rust / Cargo"),
3509 ("node", "--version", "Node.js"),
3510 ("python", "--version", "Python"),
3511 ("python3", "--version", "Python 3"),
3512 ("npm", "--version", "npm"),
3513 ];
3514
3515 let mut found: Vec<String> = Vec::new();
3516 let mut missing: Vec<String> = Vec::new();
3517 let mut python_found = false;
3518
3519 for (cmd, arg, label) in tool_checks {
3520 if cmd.starts_with("python") && python_found {
3521 continue;
3522 }
3523 let ok = Command::new(cmd)
3524 .arg(arg)
3525 .stdout(std::process::Stdio::null())
3526 .stderr(std::process::Stdio::null())
3527 .status()
3528 .map(|s| s.success())
3529 .unwrap_or(false);
3530 if ok {
3531 found.push((*label).to_string());
3532 if cmd.starts_with("python") {
3533 python_found = true;
3534 }
3535 } else if !cmd.starts_with("python") || !python_found {
3536 missing.push((*label).to_string());
3537 }
3538 }
3539
3540 if !found.is_empty() {
3541 good.push(format!("Dev tools found: {}", found.join(", ")));
3542 }
3543 if !missing.is_empty() {
3544 watch.push(format!(
3545 "Not installed (or not on PATH): {} — only matters if you need them",
3546 missing.join(", ")
3547 ));
3548 tips.push(
3549 "Run inspect_host(topic=\"toolchains\") for exact version details on all dev tools."
3550 .to_string(),
3551 );
3552 }
3553}
3554
3555fn health_check_recent_errors(watch: &mut Vec<String>, tips: &mut Vec<String>) {
3556 #[cfg(target_os = "windows")]
3557 {
3558 let script = r#"try {
3559 $cutoff = (Get-Date).AddHours(-24)
3560 $count = (Get-WinEvent -FilterHashtable @{LogName='Application','System'; Level=1,2,3; StartTime=$cutoff} -MaxEvents 200 -ErrorAction SilentlyContinue | Measure-Object).Count
3561 $count
3562} catch { "0" }"#;
3563 if let Ok(out) = Command::new("powershell")
3564 .args(["-NoProfile", "-Command", script])
3565 .output()
3566 {
3567 let text = String::from_utf8_lossy(&out.stdout);
3568 let count: u64 = text.trim().parse().unwrap_or(0);
3569 if count > 0 {
3570 watch.push(format!(
3571 "{count} critical/error event{} in Windows event logs in the last 24 hours.",
3572 if count == 1 { "" } else { "s" }
3573 ));
3574 tips.push(
3575 "Run inspect_host(topic=\"log_check\") to see the actual error messages."
3576 .to_string(),
3577 );
3578 }
3579 }
3580 }
3581
3582 #[cfg(not(target_os = "windows"))]
3583 {
3584 if let Ok(out) = Command::new("journalctl")
3585 .args(["-p", "3", "-n", "1", "--no-pager", "--quiet"])
3586 .output()
3587 {
3588 let text = String::from_utf8_lossy(&out.stdout);
3589 if !text.trim().is_empty() {
3590 watch.push("Critical/error entries found in the system journal.".to_string());
3591 tips.push(
3592 "Run inspect_host(topic=\"log_check\") to see recent errors.".to_string(),
3593 );
3594 }
3595 }
3596 }
3597}
3598
3599fn inspect_log_check(lookback_hours: Option<u32>, max_entries: usize) -> Result<String, String> {
3602 let mut out = String::from("Host inspection: log_check\n\n");
3603
3604 #[cfg(target_os = "windows")]
3605 {
3606 let hours = lookback_hours.unwrap_or(24);
3608 out.push_str(&format!(
3609 "Checking System/Application logs from the last {} hours...\n\n",
3610 hours
3611 ));
3612
3613 let n = max_entries.clamp(1, 50);
3614 let script = format!(
3615 r#"try {{
3616 $events = Get-WinEvent -FilterHashtable @{{LogName='Application','System'; Level=1,2,3; StartTime=(Get-Date).AddHours(-{hours})}} -MaxEvents 100 -ErrorAction SilentlyContinue
3617 if (-not $events) {{ "NO_EVENTS"; exit }}
3618 $events | Select-Object -First {n} | ForEach-Object {{
3619 $line = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + '|' + $_.LevelDisplayName + '|' + $_.ProviderName + '|' + (($_.Message -split '[\r\n]')[0].Trim())
3620 $line
3621 }}
3622}} catch {{ "ERROR:" + $_.Exception.Message }}"#,
3623 hours = hours,
3624 n = n
3625 );
3626 let output = Command::new("powershell")
3627 .args(["-NoProfile", "-Command", &script])
3628 .output()
3629 .map_err(|e| format!("log_check: failed to run PowerShell: {e}"))?;
3630
3631 let raw = String::from_utf8_lossy(&output.stdout);
3632 let text = raw.trim();
3633
3634 if text.is_empty() || text == "NO_EVENTS" {
3635 out.push_str("No critical or error events found in Application/System logs.\n");
3636 return Ok(out.trim_end().to_string());
3637 }
3638 if text.starts_with("ERROR:") {
3639 out.push_str(&format!("Warning: event log query returned: {text}\n"));
3640 return Ok(out.trim_end().to_string());
3641 }
3642
3643 let mut count = 0usize;
3644 for line in text.lines() {
3645 let parts: Vec<&str> = line.splitn(4, '|').collect();
3646 if parts.len() == 4 {
3647 let (time, level, source, msg) = (parts[0], parts[1], parts[2], parts[3]);
3648 out.push_str(&format!("[{time}] [{level}] {source}: {msg}\n"));
3649 count += 1;
3650 }
3651 }
3652 out.push_str(&format!(
3653 "\nEvents shown: {count} (critical/error from Application + System logs)\n"
3654 ));
3655 }
3656
3657 #[cfg(not(target_os = "windows"))]
3658 {
3659 let _ = lookback_hours;
3660 let n = max_entries.clamp(1, 50).to_string();
3662 let output = Command::new("journalctl")
3663 .args(["-p", "3", "-n", &n, "--no-pager", "--output=short-precise"])
3664 .output();
3665
3666 match output {
3667 Ok(o) if o.status.success() => {
3668 let text = String::from_utf8_lossy(&o.stdout);
3669 let trimmed = text.trim();
3670 if trimmed.is_empty() || trimmed.contains("No entries") {
3671 out.push_str("No critical or error entries found in the system journal.\n");
3672 } else {
3673 out.push_str(trimmed);
3674 out.push('\n');
3675 out.push_str("\n(source: journalctl -p 3 = critical/alert/emergency/error)\n");
3676 }
3677 }
3678 _ => {
3679 let log_paths = ["/var/log/syslog", "/var/log/messages"];
3681 let mut found = false;
3682 for log_path in &log_paths {
3683 if let Ok(content) = std::fs::read_to_string(log_path) {
3684 let lines: Vec<&str> = content.lines().collect();
3685 let tail: Vec<&str> = lines
3686 .iter()
3687 .rev()
3688 .filter(|l| {
3689 let l_lower = l.to_ascii_lowercase();
3690 l_lower.contains("error") || l_lower.contains("crit")
3691 })
3692 .take(max_entries)
3693 .copied()
3694 .collect::<Vec<_>>()
3695 .into_iter()
3696 .rev()
3697 .collect();
3698 if !tail.is_empty() {
3699 out.push_str(&format!("Source: {log_path}\n"));
3700 for l in &tail {
3701 out.push_str(l);
3702 out.push('\n');
3703 }
3704 found = true;
3705 break;
3706 }
3707 }
3708 }
3709 if !found {
3710 out.push_str(
3711 "journalctl not found and no readable syslog detected on this system.\n",
3712 );
3713 }
3714 }
3715 }
3716 }
3717
3718 Ok(out.trim_end().to_string())
3719}
3720
3721fn inspect_startup_items(max_entries: usize) -> Result<String, String> {
3724 let mut out = String::from("Host inspection: startup_items\n\n");
3725
3726 #[cfg(target_os = "windows")]
3727 {
3728 let script = r#"
3730$hives = @(
3731 @{Hive='HKLM'; Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
3732 @{Hive='HKCU'; Path='HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
3733 @{Hive='HKLM (32-bit)'; Path='HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'}
3734)
3735foreach ($h in $hives) {
3736 try {
3737 $props = Get-ItemProperty -Path $h.Path -ErrorAction Stop
3738 $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
3739 "$($h.Hive)|$($_.Name)|$($_.Value)"
3740 }
3741 } catch {}
3742}
3743"#;
3744 let output = Command::new("powershell")
3745 .args(["-NoProfile", "-Command", script])
3746 .output()
3747 .map_err(|e| format!("startup_items: failed to run PowerShell: {e}"))?;
3748
3749 let raw = String::from_utf8_lossy(&output.stdout);
3750 let text = raw.trim();
3751
3752 let entries: Vec<(String, String, String)> = text
3753 .lines()
3754 .filter_map(|l| {
3755 let parts: Vec<&str> = l.splitn(3, '|').collect();
3756 if parts.len() == 3 {
3757 Some((
3758 parts[0].to_string(),
3759 parts[1].to_string(),
3760 parts[2].to_string(),
3761 ))
3762 } else {
3763 None
3764 }
3765 })
3766 .take(max_entries)
3767 .collect();
3768
3769 if entries.is_empty() {
3770 out.push_str("No startup entries found in the Windows Run registry keys.\n");
3771 } else {
3772 out.push_str("Registry run keys (programs that start with Windows):\n\n");
3773 let mut last_hive = String::new();
3774 for (hive, name, value) in &entries {
3775 if *hive != last_hive {
3776 out.push_str(&format!("[{}]\n", hive));
3777 last_hive = hive.clone();
3778 }
3779 let display = if value.len() > 100 {
3781 format!("{}…", &value[..100])
3782 } else {
3783 value.clone()
3784 };
3785 out.push_str(&format!(" {name}: {display}\n"));
3786 }
3787 out.push_str(&format!("\nTotal startup entries: {}\n", entries.len()));
3788 }
3789
3790 let unified_script = r#"Get-CimInstance Win32_StartupCommand | ForEach-Object { " $($_.Name): $($_.Command) ($($_.Location))" }"#;
3792 if let Ok(unified_out) = Command::new("powershell")
3793 .args(["-NoProfile", "-Command", unified_script])
3794 .output()
3795 {
3796 let unified_text = String::from_utf8_lossy(&unified_out.stdout);
3797 let trimmed = unified_text.trim();
3798 if !trimmed.is_empty() {
3799 out.push_str("\n=== Unified Startup Commands (WMI) ===\n");
3800 out.push_str(trimmed);
3801 out.push('\n');
3802 }
3803 }
3804 }
3805
3806 #[cfg(not(target_os = "windows"))]
3807 {
3808 let output = Command::new("systemctl")
3810 .args([
3811 "list-unit-files",
3812 "--type=service",
3813 "--state=enabled",
3814 "--no-legend",
3815 "--no-pager",
3816 "--plain",
3817 ])
3818 .output();
3819
3820 match output {
3821 Ok(o) if o.status.success() => {
3822 let text = String::from_utf8_lossy(&o.stdout);
3823 let services: Vec<&str> = text
3824 .lines()
3825 .filter(|l| !l.trim().is_empty())
3826 .take(max_entries)
3827 .collect();
3828 if services.is_empty() {
3829 out.push_str("No enabled systemd services found.\n");
3830 } else {
3831 out.push_str("Enabled systemd services (run at boot):\n\n");
3832 for s in &services {
3833 out.push_str(&format!(" {s}\n"));
3834 }
3835 out.push_str(&format!(
3836 "\nShowing {} of enabled services.\n",
3837 services.len()
3838 ));
3839 }
3840 }
3841 _ => {
3842 out.push_str(
3843 "systemctl not found on this system. Cannot enumerate startup services.\n",
3844 );
3845 }
3846 }
3847
3848 if let Ok(cron_out) = Command::new("crontab").args(["-l"]).output() {
3850 let cron_text = String::from_utf8_lossy(&cron_out.stdout);
3851 let reboot_entries: Vec<&str> = cron_text
3852 .lines()
3853 .filter(|l| l.trim_start().starts_with("@reboot"))
3854 .collect();
3855 if !reboot_entries.is_empty() {
3856 out.push_str("\nCron @reboot entries:\n");
3857 for e in reboot_entries {
3858 out.push_str(&format!(" {e}\n"));
3859 }
3860 }
3861 }
3862 }
3863
3864 Ok(out.trim_end().to_string())
3865}
3866
3867fn inspect_os_config() -> Result<String, String> {
3868 let mut out = String::from("Host inspection: OS Configuration\n\n");
3869
3870 #[cfg(target_os = "windows")]
3871 {
3872 if let Ok(power_out) = Command::new("powercfg").args(["/getactivescheme"]).output() {
3874 let power_str = String::from_utf8_lossy(&power_out.stdout);
3875 out.push_str("=== Power Plan ===\n");
3876 out.push_str(power_str.trim());
3877 out.push_str("\n\n");
3878 }
3879
3880 let fw_script =
3882 "Get-NetFirewallProfile | Format-Table -Property Name, Enabled -AutoSize | Out-String";
3883 if let Ok(fw_out) = Command::new("powershell")
3884 .args(["-NoProfile", "-Command", fw_script])
3885 .output()
3886 {
3887 let fw_str = String::from_utf8_lossy(&fw_out.stdout);
3888 out.push_str("=== Firewall Profiles ===\n");
3889 out.push_str(fw_str.trim());
3890 out.push_str("\n\n");
3891 }
3892
3893 let uptime_script =
3895 "(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString()";
3896 if let Ok(uptime_out) = Command::new("powershell")
3897 .args(["-NoProfile", "-Command", uptime_script])
3898 .output()
3899 {
3900 let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
3901 out.push_str("=== System Uptime (Last Boot) ===\n");
3902 out.push_str(uptime_str.trim());
3903 out.push_str("\n\n");
3904 }
3905 }
3906
3907 #[cfg(not(target_os = "windows"))]
3908 {
3909 if let Ok(uptime_out) = Command::new("uptime").args(["-p"]).output() {
3911 let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
3912 out.push_str("=== System Uptime ===\n");
3913 out.push_str(uptime_str.trim());
3914 out.push_str("\n\n");
3915 }
3916
3917 if let Ok(ufw_out) = Command::new("ufw").arg("status").output() {
3919 let ufw_str = String::from_utf8_lossy(&ufw_out.stdout);
3920 if !ufw_str.trim().is_empty() {
3921 out.push_str("=== Firewall (UFW) ===\n");
3922 out.push_str(ufw_str.trim());
3923 out.push_str("\n\n");
3924 }
3925 }
3926 }
3927 Ok(out.trim_end().to_string())
3928}
3929
3930pub async fn resolve_host_issue(args: &Value) -> Result<String, String> {
3931 let action = args
3932 .get("action")
3933 .and_then(|v| v.as_str())
3934 .ok_or_else(|| "Missing required argument: 'action'".to_string())?;
3935
3936 let target = args
3937 .get("target")
3938 .and_then(|v| v.as_str())
3939 .unwrap_or("")
3940 .trim();
3941
3942 if target.is_empty() && action != "clear_temp" {
3943 return Err("Missing required argument: 'target' for this action".to_string());
3944 }
3945
3946 match action {
3947 "install_package" => {
3948 #[cfg(target_os = "windows")]
3949 {
3950 let cmd = format!("winget install --id {} -e --accept-package-agreements --accept-source-agreements", target);
3951 match Command::new("powershell")
3952 .args(["-NoProfile", "-Command", &cmd])
3953 .output()
3954 {
3955 Ok(out) => Ok(format!(
3956 "Executed remediation (winget install):\n{}",
3957 String::from_utf8_lossy(&out.stdout)
3958 )),
3959 Err(e) => Err(format!("Failed to run winget: {}", e)),
3960 }
3961 }
3962 #[cfg(not(target_os = "windows"))]
3963 {
3964 Err(
3965 "install_package via wrapper is only supported on Windows currently (winget)"
3966 .to_string(),
3967 )
3968 }
3969 }
3970 "restart_service" => {
3971 #[cfg(target_os = "windows")]
3972 {
3973 let cmd = format!("Restart-Service -Name {} -Force", target);
3974 match Command::new("powershell")
3975 .args(["-NoProfile", "-Command", &cmd])
3976 .output()
3977 {
3978 Ok(out) => {
3979 let err_str = String::from_utf8_lossy(&out.stderr);
3980 if !err_str.is_empty() {
3981 return Err(format!("Error restarting service:\n{}", err_str));
3982 }
3983 Ok(format!("Successfully restarted service: {}", target))
3984 }
3985 Err(e) => Err(format!("Failed to restart service: {}", e)),
3986 }
3987 }
3988 #[cfg(not(target_os = "windows"))]
3989 {
3990 Err(
3991 "restart_service via wrapper is only supported on Windows currently"
3992 .to_string(),
3993 )
3994 }
3995 }
3996 "clear_temp" => {
3997 #[cfg(target_os = "windows")]
3998 {
3999 let cmd = "Remove-Item -Path \"$env:TEMP\\*\" -Recurse -Force -ErrorAction SilentlyContinue";
4000 match Command::new("powershell")
4001 .args(["-NoProfile", "-Command", cmd])
4002 .output()
4003 {
4004 Ok(_) => Ok("Successfully cleared temporary files".to_string()),
4005 Err(e) => Err(format!("Failed to clear temp: {}", e)),
4006 }
4007 }
4008 #[cfg(not(target_os = "windows"))]
4009 {
4010 Err("clear_temp via wrapper is only supported on Windows currently".to_string())
4011 }
4012 }
4013 other => Err(format!("Unknown remediation action: {}", other)),
4014 }
4015}
4016
4017fn inspect_storage(max_entries: usize) -> Result<String, String> {
4020 let mut out = String::from("Host inspection: storage\n\n");
4021 let _ = max_entries; out.push_str("Drives:\n");
4025
4026 #[cfg(target_os = "windows")]
4027 {
4028 let script = r#"Get-PSDrive -PSProvider 'FileSystem' | ForEach-Object {
4029 $free = $_.Free
4030 $used = $_.Used
4031 if ($free -eq $null) { $free = 0 }
4032 if ($used -eq $null) { $used = 0 }
4033 $total = $free + $used
4034 "$($_.Name)|$free|$used|$total"
4035}"#;
4036 match Command::new("powershell")
4037 .args(["-NoProfile", "-Command", script])
4038 .output()
4039 {
4040 Ok(o) => {
4041 let text = String::from_utf8_lossy(&o.stdout);
4042 let mut drive_count = 0usize;
4043 for line in text.lines() {
4044 let parts: Vec<&str> = line.trim().split('|').collect();
4045 if parts.len() == 4 {
4046 let name = parts[0];
4047 let free: u64 = parts[1].parse().unwrap_or(0);
4048 let total: u64 = parts[3].parse().unwrap_or(0);
4049 if total == 0 {
4050 continue;
4051 }
4052 let free_gb = free / 1_073_741_824;
4053 let total_gb = total / 1_073_741_824;
4054 let used_pct = ((total - free) as f64 / total as f64 * 100.0) as u64;
4055 let bar_len = 20usize;
4056 let filled = (used_pct as usize * bar_len / 100).min(bar_len);
4057 let bar: String = "#".repeat(filled) + &".".repeat(bar_len - filled);
4058 let warn = if free_gb < 5 {
4059 " [!] CRITICALLY LOW"
4060 } else if free_gb < 15 {
4061 " [-] LOW"
4062 } else {
4063 ""
4064 };
4065 out.push_str(&format!(
4066 " {name}: [{bar}] {used_pct}% used — {free_gb} GB free of {total_gb} GB{warn}\n"
4067 ));
4068 drive_count += 1;
4069 }
4070 }
4071 if drive_count == 0 {
4072 out.push_str(" (could not enumerate drives)\n");
4073 }
4074 }
4075 Err(e) => out.push_str(&format!(" (drive scan failed: {e})\n")),
4076 }
4077
4078 let latency_script = "Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter \"Name='_Total'\" | Select-Object -ExpandProperty AvgDiskQueueLength";
4080 match Command::new("powershell")
4081 .args(["-NoProfile", "-Command", latency_script])
4082 .output()
4083 {
4084 Ok(o) => {
4085 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
4086 if !text.is_empty() {
4087 out.push_str("\nReal-time Disk Intensity:\n");
4088 out.push_str(&format!(" Average Disk Queue Length: {text}\n"));
4089 if let Ok(q) = text.parse::<f64>() {
4090 if q > 2.0 {
4091 out.push_str(
4092 " [!] WARNING: High disk latency detected (Queue Length > 2.0)\n",
4093 );
4094 } else {
4095 out.push_str(" [~] Disk latency is within healthy bounds.\n");
4096 }
4097 }
4098 }
4099 }
4100 Err(_) => {}
4101 }
4102 }
4103
4104 #[cfg(not(target_os = "windows"))]
4105 {
4106 match Command::new("df")
4107 .args(["-h", "--output=target,size,avail,pcent"])
4108 .output()
4109 {
4110 Ok(o) => {
4111 let text = String::from_utf8_lossy(&o.stdout);
4112 let mut count = 0usize;
4113 for line in text.lines().skip(1) {
4114 let cols: Vec<&str> = line.split_whitespace().collect();
4115 if cols.len() >= 4 && !cols[0].starts_with("tmpfs") {
4116 out.push_str(&format!(
4117 " {} size: {} avail: {} used: {}\n",
4118 cols[0], cols[1], cols[2], cols[3]
4119 ));
4120 count += 1;
4121 if count >= max_entries {
4122 break;
4123 }
4124 }
4125 }
4126 }
4127 Err(e) => out.push_str(&format!(" (df failed: {e})\n")),
4128 }
4129 }
4130
4131 out.push_str("\nLarge developer cache directories (if present):\n");
4133
4134 #[cfg(target_os = "windows")]
4135 {
4136 let home = std::env::var("USERPROFILE").unwrap_or_default();
4137 let check_dirs: &[(&str, &str)] = &[
4138 ("Temp", r"AppData\Local\Temp"),
4139 ("npm cache", r"AppData\Roaming\npm-cache"),
4140 ("Cargo registry", r".cargo\registry"),
4141 ("Cargo git", r".cargo\git"),
4142 ("pip cache", r"AppData\Local\pip\cache"),
4143 ("Yarn cache", r"AppData\Local\Yarn\Cache"),
4144 (".rustup toolchains", r".rustup\toolchains"),
4145 ("node_modules (home)", r"node_modules"),
4146 ];
4147
4148 let mut found_any = false;
4149 for (label, rel) in check_dirs {
4150 let full = format!(r"{}\{}", home, rel);
4151 let path = std::path::Path::new(&full);
4152 if path.exists() {
4153 let size_script = format!(
4155 r#"try {{ $s = (Get-ChildItem -Path '{}' -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum; [math]::Round($s/1MB,0) }} catch {{ '?' }}"#,
4156 full.replace('\'', "''")
4157 );
4158 let size_mb = Command::new("powershell")
4159 .args(["-NoProfile", "-Command", &size_script])
4160 .output()
4161 .ok()
4162 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
4163 .unwrap_or_else(|| "?".to_string());
4164 out.push_str(&format!(" {label}: {size_mb} MB ({full})\n"));
4165 found_any = true;
4166 }
4167 }
4168 if !found_any {
4169 out.push_str(" (none of the common cache directories found)\n");
4170 }
4171
4172 out.push_str("\nTip: to reclaim space, run inspect_host(topic=\"fix_plan\", issue=\"free up disk space\")\n");
4173 }
4174
4175 #[cfg(not(target_os = "windows"))]
4176 {
4177 let home = std::env::var("HOME").unwrap_or_default();
4178 let check_dirs: &[(&str, &str)] = &[
4179 ("npm cache", ".npm"),
4180 ("Cargo registry", ".cargo/registry"),
4181 ("pip cache", ".cache/pip"),
4182 (".rustup toolchains", ".rustup/toolchains"),
4183 ("Yarn cache", ".cache/yarn"),
4184 ];
4185 let mut found_any = false;
4186 for (label, rel) in check_dirs {
4187 let full = format!("{}/{}", home, rel);
4188 if std::path::Path::new(&full).exists() {
4189 let size = Command::new("du")
4190 .args(["-sh", &full])
4191 .output()
4192 .ok()
4193 .map(|o| {
4194 let s = String::from_utf8_lossy(&o.stdout);
4195 s.split_whitespace().next().unwrap_or("?").to_string()
4196 })
4197 .unwrap_or_else(|| "?".to_string());
4198 out.push_str(&format!(" {label}: {size} ({full})\n"));
4199 found_any = true;
4200 }
4201 }
4202 if !found_any {
4203 out.push_str(" (none of the common cache directories found)\n");
4204 }
4205 }
4206
4207 Ok(out.trim_end().to_string())
4208}
4209
4210fn inspect_hardware() -> Result<String, String> {
4213 let mut out = String::from("Host inspection: hardware\n\n");
4214
4215 #[cfg(target_os = "windows")]
4216 {
4217 let cpu_script = r#"Get-CimInstance Win32_Processor | ForEach-Object {
4219 "$($_.Name.Trim())|$($_.NumberOfCores)|$($_.NumberOfLogicalProcessors)|$([math]::Round($_.MaxClockSpeed/1000,1))"
4220} | Select-Object -First 1"#;
4221 if let Ok(o) = Command::new("powershell")
4222 .args(["-NoProfile", "-Command", cpu_script])
4223 .output()
4224 {
4225 let text = String::from_utf8_lossy(&o.stdout);
4226 let text = text.trim();
4227 let parts: Vec<&str> = text.split('|').collect();
4228 if parts.len() == 4 {
4229 out.push_str(&format!(
4230 "CPU: {}\n {} physical cores, {} logical processors, {:.1} GHz\n\n",
4231 parts[0],
4232 parts[1],
4233 parts[2],
4234 parts[3].parse::<f32>().unwrap_or(0.0)
4235 ));
4236 } else {
4237 out.push_str(&format!("CPU: {text}\n\n"));
4238 }
4239 }
4240
4241 let ram_script = r#"$sticks = Get-CimInstance Win32_PhysicalMemory
4243$total = ($sticks | Measure-Object Capacity -Sum).Sum / 1GB
4244$speed = ($sticks | Select-Object -First 1).Speed
4245"$([math]::Round($total,0)) GB @ $($speed) MHz ($($sticks.Count) stick(s))""#;
4246 if let Ok(o) = Command::new("powershell")
4247 .args(["-NoProfile", "-Command", ram_script])
4248 .output()
4249 {
4250 let text = String::from_utf8_lossy(&o.stdout);
4251 out.push_str(&format!("RAM: {}\n\n", text.trim().trim_matches('"')));
4252 }
4253
4254 let gpu_script = r#"Get-CimInstance Win32_VideoController | ForEach-Object {
4256 "$($_.Name)|$($_.DriverVersion)|$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
4257}"#;
4258 if let Ok(o) = Command::new("powershell")
4259 .args(["-NoProfile", "-Command", gpu_script])
4260 .output()
4261 {
4262 let text = String::from_utf8_lossy(&o.stdout);
4263 let lines: Vec<&str> = text.lines().collect();
4264 if !lines.is_empty() {
4265 out.push_str("GPU(s):\n");
4266 for line in lines.iter().filter(|l| !l.trim().is_empty()) {
4267 let parts: Vec<&str> = line.trim().split('|').collect();
4268 if parts.len() == 3 {
4269 let res = if parts[2] == "x" || parts[2].starts_with('0') {
4270 String::new()
4271 } else {
4272 format!(" — {}@display", parts[2])
4273 };
4274 out.push_str(&format!(
4275 " {}\n Driver: {}{}\n",
4276 parts[0], parts[1], res
4277 ));
4278 } else {
4279 out.push_str(&format!(" {}\n", line.trim()));
4280 }
4281 }
4282 out.push('\n');
4283 }
4284 }
4285
4286 let mb_script = r#"$mb = Get-CimInstance Win32_BaseBoard
4288$bios = Get-CimInstance Win32_BIOS
4289$cs = Get-CimInstance Win32_ComputerSystem
4290$proc = Get-CimInstance Win32_Processor | Select-Object -First 1
4291$virt = "Hypervisor: $($cs.HypervisorPresent)|SLAT: $($proc.SecondLevelAddressTranslationExtensions)"
4292"$($mb.Manufacturer.Trim()) $($mb.Product.Trim())|BIOS: $($bios.Manufacturer.Trim()) $($bios.SMBIOSBIOSVersion.Trim()) ($($bios.ReleaseDate))|$virt""#;
4293 if let Ok(o) = Command::new("powershell")
4294 .args(["-NoProfile", "-Command", mb_script])
4295 .output()
4296 {
4297 let text = String::from_utf8_lossy(&o.stdout);
4298 let text = text.trim().trim_matches('"');
4299 let parts: Vec<&str> = text.split('|').collect();
4300 if parts.len() == 4 {
4301 out.push_str(&format!(
4302 "Motherboard: {}\n{}\nVirtualization: {}, {}\n\n",
4303 parts[0].trim(),
4304 parts[1].trim(),
4305 parts[2].trim(),
4306 parts[3].trim()
4307 ));
4308 }
4309 }
4310
4311 let disp_script = r#"Get-CimInstance Win32_DesktopMonitor | Where-Object {$_.ScreenWidth -gt 0} | ForEach-Object {
4313 "$($_.Name)|$($_.ScreenWidth)x$($_.ScreenHeight)"
4314}"#;
4315 if let Ok(o) = Command::new("powershell")
4316 .args(["-NoProfile", "-Command", disp_script])
4317 .output()
4318 {
4319 let text = String::from_utf8_lossy(&o.stdout);
4320 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
4321 if !lines.is_empty() {
4322 out.push_str("Display(s):\n");
4323 for line in &lines {
4324 let parts: Vec<&str> = line.trim().split('|').collect();
4325 if parts.len() == 2 {
4326 out.push_str(&format!(" {} — {}\n", parts[0].trim(), parts[1]));
4327 }
4328 }
4329 }
4330 }
4331 }
4332
4333 #[cfg(not(target_os = "windows"))]
4334 {
4335 if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
4337 let model = content
4338 .lines()
4339 .find(|l| l.starts_with("model name"))
4340 .and_then(|l| l.split(':').nth(1))
4341 .map(str::trim)
4342 .unwrap_or("unknown");
4343 let cores = content
4344 .lines()
4345 .filter(|l| l.starts_with("processor"))
4346 .count();
4347 out.push_str(&format!("CPU: {model}\n {cores} logical processors\n\n"));
4348 }
4349
4350 if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4352 let total_kb: u64 = content
4353 .lines()
4354 .find(|l| l.starts_with("MemTotal:"))
4355 .and_then(|l| l.split_whitespace().nth(1))
4356 .and_then(|v| v.parse().ok())
4357 .unwrap_or(0);
4358 let total_gb = total_kb / 1_048_576;
4359 out.push_str(&format!("RAM: {total_gb} GB total\n\n"));
4360 }
4361
4362 if let Ok(o) = Command::new("lspci").args(["-vmm"]).output() {
4364 let text = String::from_utf8_lossy(&o.stdout);
4365 let gpu_lines: Vec<&str> = text
4366 .lines()
4367 .filter(|l| l.contains("VGA") || l.contains("Display") || l.contains("3D"))
4368 .collect();
4369 if !gpu_lines.is_empty() {
4370 out.push_str("GPU(s):\n");
4371 for l in gpu_lines {
4372 out.push_str(&format!(" {l}\n"));
4373 }
4374 out.push('\n');
4375 }
4376 }
4377
4378 if let Ok(o) = Command::new("dmidecode")
4380 .args(["-t", "baseboard", "-t", "bios"])
4381 .output()
4382 {
4383 let text = String::from_utf8_lossy(&o.stdout);
4384 out.push_str("Motherboard/BIOS:\n");
4385 for line in text
4386 .lines()
4387 .filter(|l| {
4388 l.contains("Manufacturer:")
4389 || l.contains("Product Name:")
4390 || l.contains("Version:")
4391 })
4392 .take(6)
4393 {
4394 out.push_str(&format!(" {}\n", line.trim()));
4395 }
4396 }
4397 }
4398
4399 Ok(out.trim_end().to_string())
4400}
4401
4402fn inspect_updates() -> Result<String, String> {
4405 let mut out = String::from("Host inspection: updates\n\n");
4406
4407 #[cfg(target_os = "windows")]
4408 {
4409 let script = r#"
4411try {
4412 $sess = New-Object -ComObject Microsoft.Update.Session
4413 $searcher = $sess.CreateUpdateSearcher()
4414 $count = $searcher.GetTotalHistoryCount()
4415 if ($count -gt 0) {
4416 $latest = $searcher.QueryHistory(0, 1) | Select-Object -First 1
4417 $latest.Date.ToString("yyyy-MM-dd HH:mm") + "|LAST_INSTALL"
4418 } else { "NONE|LAST_INSTALL" }
4419} catch { "ERROR:" + $_.Exception.Message + "|LAST_INSTALL" }
4420"#;
4421 if let Ok(o) = Command::new("powershell")
4422 .args(["-NoProfile", "-Command", script])
4423 .output()
4424 {
4425 let raw = String::from_utf8_lossy(&o.stdout);
4426 let text = raw.trim();
4427 if text.starts_with("ERROR:") {
4428 out.push_str("Last update install: (unable to query)\n");
4429 } else if text.contains("NONE") {
4430 out.push_str("Last update install: No update history found\n");
4431 } else {
4432 let date = text.replace("|LAST_INSTALL", "");
4433 out.push_str(&format!("Last update install: {date}\n"));
4434 }
4435 }
4436
4437 let pending_script = r#"
4439try {
4440 $sess = New-Object -ComObject Microsoft.Update.Session
4441 $searcher = $sess.CreateUpdateSearcher()
4442 $results = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
4443 $results.Updates.Count.ToString() + "|PENDING"
4444} catch { "ERROR:" + $_.Exception.Message + "|PENDING" }
4445"#;
4446 if let Ok(o) = Command::new("powershell")
4447 .args(["-NoProfile", "-Command", pending_script])
4448 .output()
4449 {
4450 let raw = String::from_utf8_lossy(&o.stdout);
4451 let text = raw.trim();
4452 if text.starts_with("ERROR:") {
4453 out.push_str("Pending updates: (unable to query via COM — try opening Windows Update manually)\n");
4454 } else {
4455 let count: i64 = text.replace("|PENDING", "").trim().parse().unwrap_or(-1);
4456 if count == 0 {
4457 out.push_str("Pending updates: Up to date — no updates waiting\n");
4458 } else if count > 0 {
4459 out.push_str(&format!("Pending updates: {count} update(s) available\n"));
4460 out.push_str(
4461 " → Open Windows Update (Settings > Windows Update) to install\n",
4462 );
4463 }
4464 }
4465 }
4466
4467 let svc_script = r#"
4469$svc = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
4470if ($svc) { $svc.Status.ToString() } else { "NOT_FOUND" }
4471"#;
4472 if let Ok(o) = Command::new("powershell")
4473 .args(["-NoProfile", "-Command", svc_script])
4474 .output()
4475 {
4476 let raw = String::from_utf8_lossy(&o.stdout);
4477 let status = raw.trim();
4478 out.push_str(&format!("Windows Update service: {status}\n"));
4479 }
4480 }
4481
4482 #[cfg(not(target_os = "windows"))]
4483 {
4484 let apt_out = Command::new("apt").args(["list", "--upgradable"]).output();
4485 let mut found = false;
4486 if let Ok(o) = apt_out {
4487 let text = String::from_utf8_lossy(&o.stdout);
4488 let lines: Vec<&str> = text
4489 .lines()
4490 .filter(|l| l.contains('/') && !l.contains("Listing"))
4491 .collect();
4492 if !lines.is_empty() {
4493 out.push_str(&format!(
4494 "{} package(s) can be upgraded (apt)\n",
4495 lines.len()
4496 ));
4497 out.push_str(" → Run: sudo apt upgrade\n");
4498 found = true;
4499 }
4500 }
4501 if !found {
4502 if let Ok(o) = Command::new("dnf")
4503 .args(["check-update", "--quiet"])
4504 .output()
4505 {
4506 let text = String::from_utf8_lossy(&o.stdout);
4507 let count = text
4508 .lines()
4509 .filter(|l| !l.is_empty() && !l.starts_with('!'))
4510 .count();
4511 if count > 0 {
4512 out.push_str(&format!("{count} package(s) can be upgraded (dnf)\n"));
4513 out.push_str(" → Run: sudo dnf upgrade\n");
4514 } else {
4515 out.push_str("System is up to date.\n");
4516 }
4517 } else {
4518 out.push_str("Could not query package manager for updates.\n");
4519 }
4520 }
4521 }
4522
4523 Ok(out.trim_end().to_string())
4524}
4525
4526fn inspect_security() -> Result<String, String> {
4529 let mut out = String::from("Host inspection: security\n\n");
4530
4531 #[cfg(target_os = "windows")]
4532 {
4533 let defender_script = r#"
4535try {
4536 $status = Get-MpComputerStatus -ErrorAction Stop
4537 "RTP:" + $status.RealTimeProtectionEnabled + "|SCAN:" + $status.QuickScanEndTime.ToString("yyyy-MM-dd HH:mm") + "|VER:" + $status.AntivirusSignatureVersion + "|AGE:" + $status.AntivirusSignatureAge
4538} catch { "ERROR:" + $_.Exception.Message }
4539"#;
4540 if let Ok(o) = Command::new("powershell")
4541 .args(["-NoProfile", "-Command", defender_script])
4542 .output()
4543 {
4544 let raw = String::from_utf8_lossy(&o.stdout);
4545 let text = raw.trim();
4546 if text.starts_with("ERROR:") {
4547 out.push_str(&format!("Windows Defender: unable to query — {text}\n"));
4548 } else {
4549 let get = |key: &str| -> String {
4550 text.split('|')
4551 .find(|s| s.starts_with(key))
4552 .and_then(|s| s.splitn(2, ':').nth(1))
4553 .unwrap_or("unknown")
4554 .to_string()
4555 };
4556 let rtp = get("RTP");
4557 let last_scan = {
4558 text.split('|')
4560 .find(|s| s.starts_with("SCAN:"))
4561 .and_then(|s| s.get(5..))
4562 .unwrap_or("unknown")
4563 .to_string()
4564 };
4565 let def_ver = get("VER");
4566 let age_days: i64 = get("AGE").parse().unwrap_or(-1);
4567
4568 let rtp_label = if rtp == "True" {
4569 "ENABLED"
4570 } else {
4571 "DISABLED [!]"
4572 };
4573 out.push_str(&format!(
4574 "Windows Defender real-time protection: {rtp_label}\n"
4575 ));
4576 out.push_str(&format!("Last quick scan: {last_scan}\n"));
4577 out.push_str(&format!("Signature version: {def_ver}\n"));
4578 if age_days >= 0 {
4579 let freshness = if age_days == 0 {
4580 "up to date".to_string()
4581 } else if age_days <= 3 {
4582 format!("{age_days} day(s) old — OK")
4583 } else if age_days <= 7 {
4584 format!("{age_days} day(s) old — consider updating")
4585 } else {
4586 format!("{age_days} day(s) old — [!] STALE, run Windows Update")
4587 };
4588 out.push_str(&format!("Signature age: {freshness}\n"));
4589 }
4590 if rtp != "True" {
4591 out.push_str(
4592 "\n[!] Real-time protection is OFF — your PC is not actively protected.\n",
4593 );
4594 out.push_str(
4595 " → Open Windows Security > Virus & threat protection to re-enable.\n",
4596 );
4597 }
4598 }
4599 }
4600
4601 out.push('\n');
4602
4603 let fw_script = r#"
4605try {
4606 Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object { $_.Name + ":" + $_.Enabled }
4607} catch { "ERROR:" + $_.Exception.Message }
4608"#;
4609 if let Ok(o) = Command::new("powershell")
4610 .args(["-NoProfile", "-Command", fw_script])
4611 .output()
4612 {
4613 let raw = String::from_utf8_lossy(&o.stdout);
4614 let text = raw.trim();
4615 if !text.starts_with("ERROR:") && !text.is_empty() {
4616 out.push_str("Windows Firewall:\n");
4617 for line in text.lines() {
4618 if let Some((name, enabled)) = line.split_once(':') {
4619 let state = if enabled.trim() == "True" {
4620 "ON"
4621 } else {
4622 "OFF [!]"
4623 };
4624 out.push_str(&format!(" {name}: {state}\n"));
4625 }
4626 }
4627 out.push('\n');
4628 }
4629 }
4630
4631 let act_script = r#"
4633try {
4634 $lic = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%' and LicenseStatus=1" -ErrorAction Stop | Select-Object -First 1
4635 if ($lic) { "ACTIVATED" } else { "NOT_ACTIVATED" }
4636} catch { "UNKNOWN" }
4637"#;
4638 if let Ok(o) = Command::new("powershell")
4639 .args(["-NoProfile", "-Command", act_script])
4640 .output()
4641 {
4642 let raw = String::from_utf8_lossy(&o.stdout);
4643 match raw.trim() {
4644 "ACTIVATED" => out.push_str("Windows activation: Activated\n"),
4645 "NOT_ACTIVATED" => out.push_str("Windows activation: [!] NOT ACTIVATED\n"),
4646 _ => out.push_str("Windows activation: Unable to determine\n"),
4647 }
4648 }
4649
4650 let uac_script = r#"
4652$val = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue
4653if ($val -eq 1) { "ON" } else { "OFF" }
4654"#;
4655 if let Ok(o) = Command::new("powershell")
4656 .args(["-NoProfile", "-Command", uac_script])
4657 .output()
4658 {
4659 let raw = String::from_utf8_lossy(&o.stdout);
4660 let state = raw.trim();
4661 let label = if state == "ON" {
4662 "Enabled"
4663 } else {
4664 "DISABLED [!] — recommended to re-enable via secpol.msc"
4665 };
4666 out.push_str(&format!("UAC (User Account Control): {label}\n"));
4667 }
4668 }
4669
4670 #[cfg(not(target_os = "windows"))]
4671 {
4672 if let Ok(o) = Command::new("ufw").arg("status").output() {
4673 let text = String::from_utf8_lossy(&o.stdout);
4674 out.push_str(&format!(
4675 "UFW: {}\n",
4676 text.lines().next().unwrap_or("unknown")
4677 ));
4678 }
4679 if let Ok(cfg) = std::fs::read_to_string("/etc/selinux/config") {
4680 if let Some(line) = cfg.lines().find(|l| l.starts_with("SELINUX=")) {
4681 out.push_str(&format!("{line}\n"));
4682 }
4683 }
4684 }
4685
4686 Ok(out.trim_end().to_string())
4687}
4688
4689fn inspect_pending_reboot() -> Result<String, String> {
4692 let mut out = String::from("Host inspection: pending_reboot\n\n");
4693
4694 #[cfg(target_os = "windows")]
4695 {
4696 let script = r#"
4697$reasons = @()
4698if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
4699 $reasons += "Windows Update requires a restart"
4700}
4701if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
4702 $reasons += "Windows component install/update requires a restart"
4703}
4704$pfro = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
4705if ($pfro -and $pfro.PendingFileRenameOperations) {
4706 $reasons += "Pending file rename operations (driver or system file replacement)"
4707}
4708if ($reasons.Count -eq 0) { "NO_REBOOT_NEEDED" } else { $reasons -join "|REASON|" }
4709"#;
4710 let output = Command::new("powershell")
4711 .args(["-NoProfile", "-Command", script])
4712 .output()
4713 .map_err(|e| format!("pending_reboot: {e}"))?;
4714
4715 let raw = String::from_utf8_lossy(&output.stdout);
4716 let text = raw.trim();
4717
4718 if text == "NO_REBOOT_NEEDED" {
4719 out.push_str("No restart required — system is up to date and stable.\n");
4720 } else if text.is_empty() {
4721 out.push_str("Could not determine reboot status.\n");
4722 } else {
4723 out.push_str("[!] A system restart is pending:\n\n");
4724 for reason in text.split("|REASON|") {
4725 out.push_str(&format!(" • {}\n", reason.trim()));
4726 }
4727 out.push_str("\nRecommendation: Save your work and restart when convenient.\n");
4728 }
4729 }
4730
4731 #[cfg(not(target_os = "windows"))]
4732 {
4733 if std::path::Path::new("/var/run/reboot-required").exists() {
4734 out.push_str("[!] A restart is required (see /var/run/reboot-required)\n");
4735 if let Ok(pkgs) = std::fs::read_to_string("/var/run/reboot-required.pkgs") {
4736 out.push_str("Packages requiring restart:\n");
4737 for p in pkgs.lines().take(10) {
4738 out.push_str(&format!(" • {p}\n"));
4739 }
4740 }
4741 } else {
4742 out.push_str("No restart required.\n");
4743 }
4744 }
4745
4746 Ok(out.trim_end().to_string())
4747}
4748
4749fn inspect_disk_health() -> Result<String, String> {
4752 let mut out = String::from("Host inspection: disk_health\n\n");
4753
4754 #[cfg(target_os = "windows")]
4755 {
4756 let script = r#"
4757try {
4758 $disks = Get-PhysicalDisk -ErrorAction Stop
4759 foreach ($d in $disks) {
4760 $size_gb = [math]::Round($d.Size / 1GB, 0)
4761 $d.FriendlyName + "|" + $d.MediaType + "|" + $size_gb + "GB|" + $d.HealthStatus + "|" + $d.OperationalStatus
4762 }
4763} catch { "ERROR:" + $_.Exception.Message }
4764"#;
4765 let output = Command::new("powershell")
4766 .args(["-NoProfile", "-Command", script])
4767 .output()
4768 .map_err(|e| format!("disk_health: {e}"))?;
4769
4770 let raw = String::from_utf8_lossy(&output.stdout);
4771 let text = raw.trim();
4772
4773 if text.starts_with("ERROR:") {
4774 out.push_str(&format!("Unable to query disk health: {text}\n"));
4775 out.push_str("This may require running as administrator.\n");
4776 } else if text.is_empty() {
4777 out.push_str("No physical disks found.\n");
4778 } else {
4779 out.push_str("Physical Drive Health:\n\n");
4780 for line in text.lines() {
4781 let parts: Vec<&str> = line.splitn(5, '|').collect();
4782 if parts.len() >= 4 {
4783 let name = parts[0];
4784 let media = parts[1];
4785 let size = parts[2];
4786 let health = parts[3];
4787 let op_status = parts.get(4).unwrap_or(&"");
4788 let health_label = match health.trim() {
4789 "Healthy" => "OK",
4790 "Warning" => "[!] WARNING",
4791 "Unhealthy" => "[!!] UNHEALTHY — BACK UP YOUR DATA NOW",
4792 other => other,
4793 };
4794 out.push_str(&format!(" {name}\n"));
4795 out.push_str(&format!(" Type: {media} | Size: {size}\n"));
4796 out.push_str(&format!(" Health: {health_label}\n"));
4797 if !op_status.is_empty() {
4798 out.push_str(&format!(" Status: {op_status}\n"));
4799 }
4800 out.push('\n');
4801 }
4802 }
4803 }
4804
4805 let smart_script = r#"
4807try {
4808 Get-WmiObject -Class MSStorageDriver_FailurePredictStatus -Namespace root\wmi -ErrorAction Stop |
4809 ForEach-Object { $_.InstanceName + "|" + $_.PredictFailure }
4810} catch { "" }
4811"#;
4812 if let Ok(o) = Command::new("powershell")
4813 .args(["-NoProfile", "-Command", smart_script])
4814 .output()
4815 {
4816 let raw2 = String::from_utf8_lossy(&o.stdout);
4817 let text2 = raw2.trim();
4818 if !text2.is_empty() {
4819 let failures: Vec<&str> = text2.lines().filter(|l| l.contains("|True")).collect();
4820 if failures.is_empty() {
4821 out.push_str("SMART failure prediction: No failures predicted\n");
4822 } else {
4823 out.push_str("[!!] SMART failure predicted on one or more drives:\n");
4824 for f in failures {
4825 let name = f.split('|').next().unwrap_or(f);
4826 out.push_str(&format!(" • {name}\n"));
4827 }
4828 out.push_str(
4829 "\nBack up your data immediately and replace the failing drive.\n",
4830 );
4831 }
4832 }
4833 }
4834 }
4835
4836 #[cfg(not(target_os = "windows"))]
4837 {
4838 if let Ok(o) = Command::new("lsblk")
4839 .args(["-d", "-o", "NAME,SIZE,TYPE,ROTA,MODEL"])
4840 .output()
4841 {
4842 let text = String::from_utf8_lossy(&o.stdout);
4843 out.push_str("Block devices:\n");
4844 out.push_str(text.trim());
4845 out.push('\n');
4846 }
4847 if let Ok(scan) = Command::new("smartctl").args(["--scan"]).output() {
4848 let devices = String::from_utf8_lossy(&scan.stdout);
4849 for dev_line in devices.lines().take(4) {
4850 let dev = dev_line.split_whitespace().next().unwrap_or("");
4851 if dev.is_empty() {
4852 continue;
4853 }
4854 if let Ok(o) = Command::new("smartctl").args(["-H", dev]).output() {
4855 let health = String::from_utf8_lossy(&o.stdout);
4856 if let Some(line) = health.lines().find(|l| l.contains("SMART overall-health"))
4857 {
4858 out.push_str(&format!("{dev}: {}\n", line.trim()));
4859 }
4860 }
4861 }
4862 } else {
4863 out.push_str("(install smartmontools for SMART health data)\n");
4864 }
4865 }
4866
4867 Ok(out.trim_end().to_string())
4868}
4869
4870fn inspect_battery() -> Result<String, String> {
4873 let mut out = String::from("Host inspection: battery\n\n");
4874
4875 #[cfg(target_os = "windows")]
4876 {
4877 let script = r#"
4878try {
4879 $bats = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
4880 if (-not $bats) { "NO_BATTERY"; exit }
4881
4882 # Modern Battery Health (Cycle count + Capacity health)
4883 $static = Get-CimInstance -Namespace root/WMI -ClassName BatteryStaticData -ErrorAction SilentlyContinue
4884 $full = Get-CimInstance -Namespace root/WMI -ClassName BatteryFullCapacity -ErrorAction SilentlyContinue
4885 $status = Get-CimInstance -Namespace root/WMI -ClassName BatteryStatus -ErrorAction SilentlyContinue
4886
4887 foreach ($b in $bats) {
4888 $state = switch ($b.BatteryStatus) {
4889 1 { "Discharging" }
4890 2 { "AC Power (Fully Charged)" }
4891 3 { "AC Power (Charging)" }
4892 default { "Status $($b.BatteryStatus)" }
4893 }
4894
4895 $cycles = if ($status) { $status.CycleCount } else { "unknown" }
4896 $health = if ($static -and $full) {
4897 [math]::Round(($full.FullChargeCapacity / $static.DesignCapacity) * 100, 1)
4898 } else { "unknown" }
4899
4900 $b.Name + "|" + $b.EstimatedChargeRemaining + "|" + $state + "|" + $cycles + "|" + $health
4901 }
4902} catch { "ERROR:" + $_.Exception.Message }
4903"#;
4904 let output = Command::new("powershell")
4905 .args(["-NoProfile", "-Command", script])
4906 .output()
4907 .map_err(|e| format!("battery: {e}"))?;
4908
4909 let raw = String::from_utf8_lossy(&output.stdout);
4910 let text = raw.trim();
4911
4912 if text == "NO_BATTERY" {
4913 out.push_str("No battery detected — desktop or AC-only system.\n");
4914 return Ok(out.trim_end().to_string());
4915 }
4916 if text.starts_with("ERROR:") {
4917 out.push_str(&format!("Unable to query battery: {text}\n"));
4918 return Ok(out.trim_end().to_string());
4919 }
4920
4921 for line in text.lines() {
4922 let parts: Vec<&str> = line.split('|').collect();
4923 if parts.len() == 5 {
4924 let name = parts[0];
4925 let charge: i64 = parts[1].parse().unwrap_or(-1);
4926 let state = parts[2];
4927 let cycles = parts[3];
4928 let health = parts[4];
4929
4930 out.push_str(&format!("Battery: {name}\n"));
4931 if charge >= 0 {
4932 let bar_filled = (charge as usize * 20) / 100;
4933 out.push_str(&format!(
4934 " Charge: [{}{}] {}%\n",
4935 "#".repeat(bar_filled),
4936 ".".repeat(20 - bar_filled),
4937 charge
4938 ));
4939 }
4940 out.push_str(&format!(" Status: {state}\n"));
4941 out.push_str(&format!(" Cycles: {cycles}\n"));
4942 out.push_str(&format!(
4943 " Health: {health}% (Actual vs Design Capacity)\n\n"
4944 ));
4945 }
4946 }
4947 }
4948
4949 #[cfg(not(target_os = "windows"))]
4950 {
4951 let power_path = std::path::Path::new("/sys/class/power_supply");
4952 let mut found = false;
4953 if power_path.exists() {
4954 if let Ok(entries) = std::fs::read_dir(power_path) {
4955 for entry in entries.flatten() {
4956 let p = entry.path();
4957 if let Ok(t) = std::fs::read_to_string(p.join("type")) {
4958 if t.trim() == "Battery" {
4959 found = true;
4960 let name = p
4961 .file_name()
4962 .unwrap_or_default()
4963 .to_string_lossy()
4964 .to_string();
4965 out.push_str(&format!("Battery: {name}\n"));
4966 let read = |f: &str| {
4967 std::fs::read_to_string(p.join(f))
4968 .ok()
4969 .map(|s| s.trim().to_string())
4970 };
4971 if let Some(cap) = read("capacity") {
4972 out.push_str(&format!(" Charge: {cap}%\n"));
4973 }
4974 if let Some(status) = read("status") {
4975 out.push_str(&format!(" Status: {status}\n"));
4976 }
4977 if let (Some(full), Some(design)) =
4978 (read("energy_full"), read("energy_full_design"))
4979 {
4980 if let (Ok(f), Ok(d)) = (full.parse::<f64>(), design.parse::<f64>())
4981 {
4982 if d > 0.0 {
4983 out.push_str(&format!(
4984 " Wear level: {:.1}% of design capacity\n",
4985 (f / d) * 100.0
4986 ));
4987 }
4988 }
4989 }
4990 }
4991 }
4992 }
4993 }
4994 }
4995 if !found {
4996 out.push_str("No battery found.\n");
4997 }
4998 }
4999
5000 Ok(out.trim_end().to_string())
5001}
5002
5003fn inspect_recent_crashes(max_entries: usize) -> Result<String, String> {
5006 let mut out = String::from("Host inspection: recent_crashes\n\n");
5007 let n = max_entries.clamp(1, 30);
5008
5009 #[cfg(target_os = "windows")]
5010 {
5011 let bsod_script = format!(
5013 r#"
5014try {{
5015 $events = Get-WinEvent -FilterHashtable @{{LogName='System'; Id=41,1001}} -MaxEvents {n} -ErrorAction SilentlyContinue
5016 if ($events) {{
5017 $events | ForEach-Object {{
5018 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + $_.Id + "|" + (($_.Message -split "[\r\n]")[0].Trim())
5019 }}
5020 }} else {{ "NO_BSOD" }}
5021}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5022 );
5023
5024 if let Ok(o) = Command::new("powershell")
5025 .args(["-NoProfile", "-Command", &bsod_script])
5026 .output()
5027 {
5028 let raw = String::from_utf8_lossy(&o.stdout);
5029 let text = raw.trim();
5030 if text == "NO_BSOD" {
5031 out.push_str("System crashes (BSOD/kernel): None in recent history\n");
5032 } else if text.starts_with("ERROR:") {
5033 out.push_str("System crashes: unable to query\n");
5034 } else {
5035 out.push_str("System crashes / unexpected shutdowns:\n");
5036 for line in text.lines() {
5037 let parts: Vec<&str> = line.splitn(3, '|').collect();
5038 if parts.len() >= 3 {
5039 let time = parts[0];
5040 let id = parts[1];
5041 let msg = parts[2];
5042 let label = if id == "41" {
5043 "Unexpected shutdown"
5044 } else {
5045 "BSOD (BugCheck)"
5046 };
5047 out.push_str(&format!(" [{time}] {label}: {msg}\n"));
5048 }
5049 }
5050 out.push('\n');
5051 }
5052 }
5053
5054 let app_script = format!(
5056 r#"
5057try {{
5058 $crashes = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue
5059 if ($crashes) {{
5060 $crashes | ForEach-Object {{
5061 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + (($_.Message -split "[\r\n]")[0].Trim())
5062 }}
5063 }} else {{ "NO_CRASHES" }}
5064}} catch {{ "ERROR_APP:" + $_.Exception.Message }}"#
5065 );
5066
5067 if let Ok(o) = Command::new("powershell")
5068 .args(["-NoProfile", "-Command", &app_script])
5069 .output()
5070 {
5071 let raw = String::from_utf8_lossy(&o.stdout);
5072 let text = raw.trim();
5073 if text == "NO_CRASHES" {
5074 out.push_str("Application crashes: None in recent history\n");
5075 } else if text.starts_with("ERROR_APP:") {
5076 out.push_str("Application crashes: unable to query\n");
5077 } else {
5078 out.push_str("Application crashes:\n");
5079 for line in text.lines().take(n) {
5080 let parts: Vec<&str> = line.splitn(2, '|').collect();
5081 if parts.len() >= 2 {
5082 out.push_str(&format!(" [{}] {}\n", parts[0], parts[1]));
5083 }
5084 }
5085 }
5086 }
5087 }
5088
5089 #[cfg(not(target_os = "windows"))]
5090 {
5091 let n_str = n.to_string();
5092 if let Ok(o) = Command::new("journalctl")
5093 .args(["-k", "--no-pager", "-n", &n_str, "-p", "0..2"])
5094 .output()
5095 {
5096 let text = String::from_utf8_lossy(&o.stdout);
5097 let trimmed = text.trim();
5098 if trimmed.is_empty() || trimmed.contains("No entries") {
5099 out.push_str("No kernel panics or critical crashes found.\n");
5100 } else {
5101 out.push_str("Kernel critical events:\n");
5102 out.push_str(trimmed);
5103 out.push('\n');
5104 }
5105 }
5106 if let Ok(o) = Command::new("coredumpctl")
5107 .args(["list", "--no-pager"])
5108 .output()
5109 {
5110 let text = String::from_utf8_lossy(&o.stdout);
5111 let count = text
5112 .lines()
5113 .filter(|l| !l.trim().is_empty() && !l.starts_with("TIME"))
5114 .count();
5115 if count > 0 {
5116 out.push_str(&format!(
5117 "\nCore dumps on file: {count}\n → Run: coredumpctl list\n"
5118 ));
5119 }
5120 }
5121 }
5122
5123 Ok(out.trim_end().to_string())
5124}
5125
5126fn inspect_scheduled_tasks(max_entries: usize) -> Result<String, String> {
5129 let mut out = String::from("Host inspection: scheduled_tasks\n\n");
5130 let n = max_entries.clamp(1, 30);
5131
5132 #[cfg(target_os = "windows")]
5133 {
5134 let script = format!(
5135 r#"
5136try {{
5137 $tasks = Get-ScheduledTask -ErrorAction Stop |
5138 Where-Object {{ $_.State -ne 'Disabled' }} |
5139 ForEach-Object {{
5140 $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
5141 $lastRun = if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {{
5142 $info.LastRunTime.ToString("yyyy-MM-dd HH:mm")
5143 }} else {{ "never" }}
5144 $res = if ($info) {{ "0x{{0:x}}" -f $info.LastTaskResult }} else {{ "---" }}
5145 $exec = ($_.Actions | Select-Object -First 1).Execute
5146 if (-not $exec) {{ $exec = "(no exec)" }}
5147 $_.TaskName + "|" + $_.TaskPath + "|" + $_.State + "|" + $lastRun + "|" + $res + "|" + $exec
5148 }}
5149 $tasks | Select-Object -First {n}
5150}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5151 );
5152
5153 let output = Command::new("powershell")
5154 .args(["-NoProfile", "-Command", &script])
5155 .output()
5156 .map_err(|e| format!("scheduled_tasks: {e}"))?;
5157
5158 let raw = String::from_utf8_lossy(&output.stdout);
5159 let text = raw.trim();
5160
5161 if text.starts_with("ERROR:") {
5162 out.push_str(&format!("Unable to query scheduled tasks: {text}\n"));
5163 } else if text.is_empty() {
5164 out.push_str("No active scheduled tasks found.\n");
5165 } else {
5166 out.push_str(&format!("Active scheduled tasks (up to {n}):\n\n"));
5167 for line in text.lines() {
5168 let parts: Vec<&str> = line.splitn(6, '|').collect();
5169 if parts.len() >= 5 {
5170 let name = parts[0];
5171 let path = parts[1];
5172 let state = parts[2];
5173 let last = parts[3];
5174 let res = parts[4];
5175 let exec = parts.get(5).unwrap_or(&"").trim();
5176 let display_path = path.trim_matches('\\');
5177 let display_path = if display_path.is_empty() {
5178 "Root"
5179 } else {
5180 display_path
5181 };
5182 out.push_str(&format!(" {name} [{display_path}]\n"));
5183 out.push_str(&format!(
5184 " State: {state} | Last run: {last} | Result: {res}\n"
5185 ));
5186 if !exec.is_empty() && exec != "(no exec)" {
5187 let short = if exec.len() > 80 { &exec[..80] } else { exec };
5188 out.push_str(&format!(" Runs: {short}\n"));
5189 }
5190 }
5191 }
5192 }
5193 }
5194
5195 #[cfg(not(target_os = "windows"))]
5196 {
5197 if let Ok(o) = Command::new("systemctl")
5198 .args(["list-timers", "--no-pager", "--all"])
5199 .output()
5200 {
5201 let text = String::from_utf8_lossy(&o.stdout);
5202 out.push_str("Systemd timers:\n");
5203 for l in text
5204 .lines()
5205 .filter(|l| {
5206 !l.trim().is_empty() && !l.starts_with("NEXT") && !l.starts_with("timers")
5207 })
5208 .take(n)
5209 {
5210 out.push_str(&format!(" {l}\n"));
5211 }
5212 out.push('\n');
5213 }
5214 if let Ok(o) = Command::new("crontab").arg("-l").output() {
5215 let text = String::from_utf8_lossy(&o.stdout);
5216 let jobs: Vec<&str> = text
5217 .lines()
5218 .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
5219 .collect();
5220 if !jobs.is_empty() {
5221 out.push_str("User crontab:\n");
5222 for j in jobs.iter().take(n) {
5223 out.push_str(&format!(" {j}\n"));
5224 }
5225 }
5226 }
5227 }
5228
5229 Ok(out.trim_end().to_string())
5230}
5231
5232fn inspect_dev_conflicts() -> Result<String, String> {
5235 let mut out = String::from("Host inspection: dev_conflicts\n\n");
5236 let mut conflicts: Vec<String> = Vec::new();
5237 let mut notes: Vec<String> = Vec::new();
5238
5239 {
5241 let node_ver = Command::new("node")
5242 .arg("--version")
5243 .output()
5244 .ok()
5245 .and_then(|o| String::from_utf8(o.stdout).ok())
5246 .map(|s| s.trim().to_string());
5247 let nvm_active = Command::new("nvm")
5248 .arg("current")
5249 .output()
5250 .ok()
5251 .and_then(|o| String::from_utf8(o.stdout).ok())
5252 .map(|s| s.trim().to_string())
5253 .filter(|s| !s.is_empty() && !s.contains("none") && !s.contains("No current"));
5254 let fnm_active = Command::new("fnm")
5255 .arg("current")
5256 .output()
5257 .ok()
5258 .and_then(|o| String::from_utf8(o.stdout).ok())
5259 .map(|s| s.trim().to_string())
5260 .filter(|s| !s.is_empty() && !s.contains("none"));
5261 let volta_active = Command::new("volta")
5262 .args(["which", "node"])
5263 .output()
5264 .ok()
5265 .and_then(|o| String::from_utf8(o.stdout).ok())
5266 .map(|s| s.trim().to_string())
5267 .filter(|s| !s.is_empty());
5268
5269 out.push_str("Node.js:\n");
5270 if let Some(ref v) = node_ver {
5271 out.push_str(&format!(" Active: {v}\n"));
5272 } else {
5273 out.push_str(" Not installed\n");
5274 }
5275 let managers: Vec<&str> = [
5276 nvm_active.as_deref(),
5277 fnm_active.as_deref(),
5278 volta_active.as_deref(),
5279 ]
5280 .iter()
5281 .filter_map(|x| *x)
5282 .collect();
5283 if managers.len() > 1 {
5284 conflicts.push(format!(
5285 "Multiple Node.js version managers detected (nvm/fnm/volta). Only one should be active to avoid PATH conflicts."
5286 ));
5287 } else if !managers.is_empty() {
5288 out.push_str(&format!(" Version manager: {}\n", managers[0]));
5289 }
5290 out.push('\n');
5291 }
5292
5293 {
5295 let py3 = Command::new("python3")
5296 .arg("--version")
5297 .output()
5298 .ok()
5299 .and_then(|o| {
5300 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
5301 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
5302 let v = if stdout.is_empty() { stderr } else { stdout };
5303 if v.is_empty() {
5304 None
5305 } else {
5306 Some(v)
5307 }
5308 });
5309 let py = Command::new("python")
5310 .arg("--version")
5311 .output()
5312 .ok()
5313 .and_then(|o| {
5314 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
5315 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
5316 let v = if stdout.is_empty() { stderr } else { stdout };
5317 if v.is_empty() {
5318 None
5319 } else {
5320 Some(v)
5321 }
5322 });
5323 let pyenv = Command::new("pyenv")
5324 .arg("version")
5325 .output()
5326 .ok()
5327 .and_then(|o| String::from_utf8(o.stdout).ok())
5328 .map(|s| s.trim().to_string())
5329 .filter(|s| !s.is_empty());
5330 let conda_env = std::env::var("CONDA_DEFAULT_ENV").ok();
5331
5332 out.push_str("Python:\n");
5333 match (&py3, &py) {
5334 (Some(v3), Some(v)) if v3 != v => {
5335 out.push_str(&format!(" python3: {v3}\n python: {v}\n"));
5336 if v.contains("2.") {
5337 conflicts.push(
5338 "python and python3 point to different major versions (2.x vs 3.x). Scripts using 'python' may break unexpectedly.".to_string()
5339 );
5340 } else {
5341 notes.push(
5342 "python and python3 resolve to different minor versions.".to_string(),
5343 );
5344 }
5345 }
5346 (Some(v3), None) => out.push_str(&format!(" python3: {v3}\n")),
5347 (None, Some(v)) => out.push_str(&format!(" python: {v}\n")),
5348 (Some(v3), Some(_)) => out.push_str(&format!(" {v3}\n")),
5349 (None, None) => out.push_str(" Not installed\n"),
5350 }
5351 if let Some(ref pe) = pyenv {
5352 out.push_str(&format!(" pyenv: {pe}\n"));
5353 }
5354 if let Some(env) = conda_env {
5355 if env == "base" {
5356 notes.push("Conda base environment is active — may shadow system Python. Run 'conda deactivate' if unexpected.".to_string());
5357 } else {
5358 out.push_str(&format!(" conda env: {env}\n"));
5359 }
5360 }
5361 out.push('\n');
5362 }
5363
5364 {
5366 let toolchain = Command::new("rustup")
5367 .args(["show", "active-toolchain"])
5368 .output()
5369 .ok()
5370 .and_then(|o| String::from_utf8(o.stdout).ok())
5371 .map(|s| s.trim().to_string())
5372 .filter(|s| !s.is_empty());
5373 let cargo_ver = Command::new("cargo")
5374 .arg("--version")
5375 .output()
5376 .ok()
5377 .and_then(|o| String::from_utf8(o.stdout).ok())
5378 .map(|s| s.trim().to_string());
5379 let rustc_ver = Command::new("rustc")
5380 .arg("--version")
5381 .output()
5382 .ok()
5383 .and_then(|o| String::from_utf8(o.stdout).ok())
5384 .map(|s| s.trim().to_string());
5385
5386 out.push_str("Rust:\n");
5387 if let Some(ref t) = toolchain {
5388 out.push_str(&format!(" Active toolchain: {t}\n"));
5389 }
5390 if let Some(ref c) = cargo_ver {
5391 out.push_str(&format!(" {c}\n"));
5392 }
5393 if let Some(ref r) = rustc_ver {
5394 out.push_str(&format!(" {r}\n"));
5395 }
5396 if cargo_ver.is_none() && rustc_ver.is_none() {
5397 out.push_str(" Not installed\n");
5398 }
5399
5400 #[cfg(not(target_os = "windows"))]
5402 if let Ok(o) = Command::new("which").arg("rustc").output() {
5403 let path = String::from_utf8_lossy(&o.stdout).trim().to_string();
5404 if !path.is_empty() && !path.contains(".cargo") && !path.contains("rustup") {
5405 conflicts.push(format!(
5406 "rustc found at non-rustup path '{path}' — may conflict with rustup-managed toolchain"
5407 ));
5408 }
5409 }
5410 out.push('\n');
5411 }
5412
5413 {
5415 let git_ver = Command::new("git")
5416 .arg("--version")
5417 .output()
5418 .ok()
5419 .and_then(|o| String::from_utf8(o.stdout).ok())
5420 .map(|s| s.trim().to_string());
5421 out.push_str("Git:\n");
5422 if let Some(ref v) = git_ver {
5423 out.push_str(&format!(" {v}\n"));
5424 let email = Command::new("git")
5425 .args(["config", "--global", "user.email"])
5426 .output()
5427 .ok()
5428 .and_then(|o| String::from_utf8(o.stdout).ok())
5429 .map(|s| s.trim().to_string());
5430 if let Some(ref e) = email {
5431 if e.is_empty() {
5432 notes.push("Git user.email is not configured globally — commits may fail or use wrong identity.".to_string());
5433 } else {
5434 out.push_str(&format!(" user.email: {e}\n"));
5435 }
5436 }
5437 let gpg_sign = Command::new("git")
5438 .args(["config", "--global", "commit.gpgsign"])
5439 .output()
5440 .ok()
5441 .and_then(|o| String::from_utf8(o.stdout).ok())
5442 .map(|s| s.trim().to_string());
5443 if gpg_sign.as_deref() == Some("true") {
5444 let key = Command::new("git")
5445 .args(["config", "--global", "user.signingkey"])
5446 .output()
5447 .ok()
5448 .and_then(|o| String::from_utf8(o.stdout).ok())
5449 .map(|s| s.trim().to_string());
5450 if key.as_deref().map(|k| k.is_empty()).unwrap_or(true) {
5451 conflicts.push("Git commit signing is enabled but no signing key is configured — commits will fail.".to_string());
5452 }
5453 }
5454 } else {
5455 out.push_str(" Not installed\n");
5456 }
5457 out.push('\n');
5458 }
5459
5460 {
5462 let path_env = std::env::var("PATH").unwrap_or_default();
5463 let sep = if cfg!(windows) { ';' } else { ':' };
5464 let mut seen = HashSet::new();
5465 let mut dupes: Vec<String> = Vec::new();
5466 for p in path_env.split(sep) {
5467 let norm = p.trim().to_lowercase();
5468 if !norm.is_empty() && !seen.insert(norm) {
5469 dupes.push(p.to_string());
5470 }
5471 }
5472 if !dupes.is_empty() {
5473 let shown: Vec<&str> = dupes.iter().take(3).map(|s| s.as_str()).collect();
5474 notes.push(format!(
5475 "Duplicate PATH entries: {} {}",
5476 shown.join(", "),
5477 if dupes.len() > 3 {
5478 format!("+{} more", dupes.len() - 3)
5479 } else {
5480 String::new()
5481 }
5482 ));
5483 }
5484 }
5485
5486 if conflicts.is_empty() && notes.is_empty() {
5488 out.push_str("No conflicts detected — dev environment looks clean.\n");
5489 } else {
5490 if !conflicts.is_empty() {
5491 out.push_str("CONFLICTS:\n");
5492 for c in &conflicts {
5493 out.push_str(&format!(" [!] {c}\n"));
5494 }
5495 out.push('\n');
5496 }
5497 if !notes.is_empty() {
5498 out.push_str("NOTES:\n");
5499 for n in ¬es {
5500 out.push_str(&format!(" [-] {n}\n"));
5501 }
5502 }
5503 }
5504
5505 Ok(out.trim_end().to_string())
5506}
5507
5508fn inspect_connectivity() -> Result<String, String> {
5511 let mut out = String::from("Host inspection: connectivity\n\n");
5512
5513 #[cfg(target_os = "windows")]
5514 {
5515 let inet_script = r#"
5516try {
5517 $r = Test-NetConnection -ComputerName 8.8.8.8 -Port 53 -InformationLevel Quiet -WarningAction SilentlyContinue
5518 if ($r) { "REACHABLE" } else { "UNREACHABLE" }
5519} catch { "ERROR:" + $_.Exception.Message }
5520"#;
5521 if let Ok(o) = Command::new("powershell")
5522 .args(["-NoProfile", "-Command", inet_script])
5523 .output()
5524 {
5525 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5526 match text.as_str() {
5527 "REACHABLE" => out.push_str("Internet: reachable\n"),
5528 "UNREACHABLE" => out.push_str("Internet: unreachable [!]\n"),
5529 _ => out.push_str(&format!(
5530 "Internet: {}\n",
5531 text.trim_start_matches("ERROR:").trim()
5532 )),
5533 }
5534 }
5535
5536 let dns_script = r#"
5537try {
5538 Resolve-DnsName -Name "dns.google" -Type A -ErrorAction Stop | Out-Null
5539 "DNS:ok"
5540} catch { "DNS:fail:" + $_.Exception.Message }
5541"#;
5542 if let Ok(o) = Command::new("powershell")
5543 .args(["-NoProfile", "-Command", dns_script])
5544 .output()
5545 {
5546 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5547 if text == "DNS:ok" {
5548 out.push_str("DNS: resolving correctly\n");
5549 } else {
5550 let detail = text.trim_start_matches("DNS:fail:").trim();
5551 out.push_str(&format!("DNS: failed — {}\n", detail));
5552 }
5553 }
5554
5555 let gw_script = r#"
5556(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop
5557"#;
5558 if let Ok(o) = Command::new("powershell")
5559 .args(["-NoProfile", "-Command", gw_script])
5560 .output()
5561 {
5562 let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
5563 if !gw.is_empty() && gw != "0.0.0.0" {
5564 out.push_str(&format!("Default gateway: {}\n", gw));
5565 }
5566 }
5567 }
5568
5569 #[cfg(not(target_os = "windows"))]
5570 {
5571 let reachable = Command::new("ping")
5572 .args(["-c", "1", "-W", "2", "8.8.8.8"])
5573 .output()
5574 .map(|o| o.status.success())
5575 .unwrap_or(false);
5576 out.push_str(if reachable {
5577 "Internet: reachable\n"
5578 } else {
5579 "Internet: unreachable\n"
5580 });
5581 let dns_ok = Command::new("getent")
5582 .args(["hosts", "dns.google"])
5583 .output()
5584 .map(|o| o.status.success())
5585 .unwrap_or(false);
5586 out.push_str(if dns_ok {
5587 "DNS: resolving correctly\n"
5588 } else {
5589 "DNS: failed\n"
5590 });
5591 if let Ok(o) = Command::new("ip")
5592 .args(["route", "show", "default"])
5593 .output()
5594 {
5595 let text = String::from_utf8_lossy(&o.stdout);
5596 if let Some(line) = text.lines().next() {
5597 out.push_str(&format!("Default gateway: {}\n", line.trim()));
5598 }
5599 }
5600 }
5601
5602 Ok(out.trim_end().to_string())
5603}
5604
5605fn inspect_wifi() -> Result<String, String> {
5608 let mut out = String::from("Host inspection: wifi\n\n");
5609
5610 #[cfg(target_os = "windows")]
5611 {
5612 let output = Command::new("netsh")
5613 .args(["wlan", "show", "interfaces"])
5614 .output()
5615 .map_err(|e| format!("wifi: {e}"))?;
5616 let text = String::from_utf8_lossy(&output.stdout).to_string();
5617
5618 if text.contains("There is no wireless interface") || text.trim().is_empty() {
5619 out.push_str("No wireless interface detected on this machine.\n");
5620 return Ok(out.trim_end().to_string());
5621 }
5622
5623 let fields = [
5624 ("SSID", "SSID"),
5625 ("State", "State"),
5626 ("Signal", "Signal"),
5627 ("Radio type", "Radio type"),
5628 ("Channel", "Channel"),
5629 ("Receive rate (Mbps)", "Download speed (Mbps)"),
5630 ("Transmit rate (Mbps)", "Upload speed (Mbps)"),
5631 ("Authentication", "Authentication"),
5632 ("Network type", "Network type"),
5633 ];
5634
5635 let mut any = false;
5636 for line in text.lines() {
5637 let trimmed = line.trim();
5638 for (key, label) in &fields {
5639 if trimmed.starts_with(key) && trimmed.contains(':') {
5640 let val = trimmed.splitn(2, ':').nth(1).unwrap_or("").trim();
5641 if !val.is_empty() {
5642 out.push_str(&format!(" {label}: {val}\n"));
5643 any = true;
5644 }
5645 }
5646 }
5647 }
5648 if !any {
5649 out.push_str(" (Wi-Fi adapter disconnected or no active connection)\n");
5650 }
5651 }
5652
5653 #[cfg(not(target_os = "windows"))]
5654 {
5655 if let Ok(o) = Command::new("nmcli")
5656 .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
5657 .output()
5658 {
5659 let text = String::from_utf8_lossy(&o.stdout).to_string();
5660 let lines: Vec<&str> = text.lines().filter(|l| l.contains(":wifi:")).collect();
5661 if lines.is_empty() {
5662 out.push_str("No Wi-Fi devices found.\n");
5663 } else {
5664 for l in lines {
5665 out.push_str(&format!(" {l}\n"));
5666 }
5667 }
5668 } else if let Ok(o) = Command::new("iwconfig").output() {
5669 let text = String::from_utf8_lossy(&o.stdout).to_string();
5670 if !text.trim().is_empty() {
5671 out.push_str(text.trim());
5672 out.push('\n');
5673 }
5674 } else {
5675 out.push_str("No wireless tool available (install nmcli or wireless-tools).\n");
5676 }
5677 }
5678
5679 Ok(out.trim_end().to_string())
5680}
5681
5682fn inspect_connections(max_entries: usize) -> Result<String, String> {
5685 let mut out = String::from("Host inspection: connections\n\n");
5686 let n = max_entries.clamp(1, 25);
5687
5688 #[cfg(target_os = "windows")]
5689 {
5690 let script = format!(
5691 r#"
5692try {{
5693 $procs = @{{}}
5694 Get-Process -ErrorAction SilentlyContinue | ForEach-Object {{ $procs[$_.Id] = $_.Name }}
5695 $all = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
5696 Sort-Object OwningProcess
5697 "TOTAL:" + $all.Count
5698 $all | Select-Object -First {n} | ForEach-Object {{
5699 $pname = if ($procs.ContainsKey($_.OwningProcess)) {{ $procs[$_.OwningProcess] }} else {{ "unknown" }}
5700 $pname + "|" + $_.OwningProcess + "|" + $_.LocalAddress + ":" + $_.LocalPort + "|" + $_.RemoteAddress + ":" + $_.RemotePort
5701 }}
5702}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5703 );
5704
5705 let output = Command::new("powershell")
5706 .args(["-NoProfile", "-Command", &script])
5707 .output()
5708 .map_err(|e| format!("connections: {e}"))?;
5709
5710 let raw = String::from_utf8_lossy(&output.stdout);
5711 let text = raw.trim();
5712
5713 if text.starts_with("ERROR:") {
5714 out.push_str(&format!("Unable to query connections: {text}\n"));
5715 } else {
5716 let mut total = 0usize;
5717 let mut rows = Vec::new();
5718 for line in text.lines() {
5719 if let Some(rest) = line.strip_prefix("TOTAL:") {
5720 total = rest.trim().parse().unwrap_or(0);
5721 } else {
5722 rows.push(line);
5723 }
5724 }
5725 out.push_str(&format!("Established TCP connections: {total}\n\n"));
5726 for row in &rows {
5727 let parts: Vec<&str> = row.splitn(4, '|').collect();
5728 if parts.len() == 4 {
5729 out.push_str(&format!(
5730 " {:<15} (pid {:<5}) | {} → {}\n",
5731 parts[0], parts[1], parts[2], parts[3]
5732 ));
5733 }
5734 }
5735 if total > n {
5736 out.push_str(&format!(
5737 "\n ... {} more connections not shown\n",
5738 total.saturating_sub(n)
5739 ));
5740 }
5741 }
5742 }
5743
5744 #[cfg(not(target_os = "windows"))]
5745 {
5746 if let Ok(o) = Command::new("ss")
5747 .args(["-tnp", "state", "established"])
5748 .output()
5749 {
5750 let text = String::from_utf8_lossy(&o.stdout);
5751 let lines: Vec<&str> = text
5752 .lines()
5753 .skip(1)
5754 .filter(|l| !l.trim().is_empty())
5755 .collect();
5756 out.push_str(&format!("Established TCP connections: {}\n\n", lines.len()));
5757 for line in lines.iter().take(n) {
5758 out.push_str(&format!(" {}\n", line.trim()));
5759 }
5760 if lines.len() > n {
5761 out.push_str(&format!("\n ... {} more not shown\n", lines.len() - n));
5762 }
5763 } else {
5764 out.push_str("ss not available — install iproute2\n");
5765 }
5766 }
5767
5768 Ok(out.trim_end().to_string())
5769}
5770
5771fn inspect_vpn() -> Result<String, String> {
5774 let mut out = String::from("Host inspection: vpn\n\n");
5775
5776 #[cfg(target_os = "windows")]
5777 {
5778 let script = r#"
5779try {
5780 $vpn = Get-NetAdapter -ErrorAction Stop | Where-Object {
5781 $_.InterfaceDescription -match 'VPN|TAP|WireGuard|OpenVPN|Cisco|Palo Alto|GlobalProtect|Juniper|Pulse|NordVPN|ExpressVPN|Mullvad|ProtonVPN' -or
5782 $_.Name -match 'VPN|TAP|WireGuard|tun|ppp|wg\d'
5783 }
5784 if ($vpn) {
5785 foreach ($a in $vpn) {
5786 $a.Name + "|" + $a.InterfaceDescription + "|" + $a.Status + "|" + $a.MediaConnectionState
5787 }
5788 } else { "NONE" }
5789} catch { "ERROR:" + $_.Exception.Message }
5790"#;
5791 let output = Command::new("powershell")
5792 .args(["-NoProfile", "-Command", script])
5793 .output()
5794 .map_err(|e| format!("vpn: {e}"))?;
5795
5796 let raw = String::from_utf8_lossy(&output.stdout);
5797 let text = raw.trim();
5798
5799 if text == "NONE" {
5800 out.push_str("No VPN adapters detected — no active VPN connection found.\n");
5801 } else if text.starts_with("ERROR:") {
5802 out.push_str(&format!("Unable to query adapters: {text}\n"));
5803 } else {
5804 out.push_str("VPN adapters:\n\n");
5805 for line in text.lines() {
5806 let parts: Vec<&str> = line.splitn(4, '|').collect();
5807 if parts.len() >= 3 {
5808 let name = parts[0];
5809 let desc = parts[1];
5810 let status = parts[2];
5811 let media = parts.get(3).unwrap_or(&"unknown");
5812 let label = if status.trim() == "Up" {
5813 "CONNECTED"
5814 } else {
5815 "disconnected"
5816 };
5817 out.push_str(&format!(
5818 " {name} [{label}]\n {desc}\n Status: {status} | Media: {media}\n\n"
5819 ));
5820 }
5821 }
5822 }
5823
5824 let ras_script = r#"
5826try {
5827 $c = Get-VpnConnection -ErrorAction Stop
5828 if ($c) { foreach ($v in $c) { $v.Name + "|" + $v.ConnectionStatus + "|" + $v.ServerAddress } }
5829 else { "NO_RAS" }
5830} catch { "NO_RAS" }
5831"#;
5832 if let Ok(o) = Command::new("powershell")
5833 .args(["-NoProfile", "-Command", ras_script])
5834 .output()
5835 {
5836 let t = String::from_utf8_lossy(&o.stdout).trim().to_string();
5837 if t != "NO_RAS" && !t.is_empty() {
5838 out.push_str("Windows VPN connections:\n");
5839 for line in t.lines() {
5840 let parts: Vec<&str> = line.splitn(3, '|').collect();
5841 if parts.len() >= 2 {
5842 let name = parts[0];
5843 let status = parts[1];
5844 let server = parts.get(2).unwrap_or(&"");
5845 out.push_str(&format!(" {name} → {server} [{status}]\n"));
5846 }
5847 }
5848 }
5849 }
5850 }
5851
5852 #[cfg(not(target_os = "windows"))]
5853 {
5854 if let Ok(o) = Command::new("ip").args(["link", "show"]).output() {
5855 let text = String::from_utf8_lossy(&o.stdout);
5856 let vpn_ifaces: Vec<&str> = text
5857 .lines()
5858 .filter(|l| {
5859 l.contains("tun") || l.contains("tap") || l.contains(" wg") || l.contains("ppp")
5860 })
5861 .collect();
5862 if vpn_ifaces.is_empty() {
5863 out.push_str("No VPN interfaces (tun/tap/wg/ppp) detected.\n");
5864 } else {
5865 out.push_str(&format!("VPN-like interfaces ({}):\n", vpn_ifaces.len()));
5866 for l in vpn_ifaces {
5867 out.push_str(&format!(" {}\n", l.trim()));
5868 }
5869 }
5870 }
5871 }
5872
5873 Ok(out.trim_end().to_string())
5874}
5875
5876fn inspect_proxy() -> Result<String, String> {
5879 let mut out = String::from("Host inspection: proxy\n\n");
5880
5881 #[cfg(target_os = "windows")]
5882 {
5883 let script = r#"
5884$ie = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
5885if ($ie) {
5886 "ENABLE:" + $ie.ProxyEnable + "|SERVER:" + $ie.ProxyServer + "|OVERRIDE:" + $ie.ProxyOverride
5887} else { "NONE" }
5888"#;
5889 if let Ok(o) = Command::new("powershell")
5890 .args(["-NoProfile", "-Command", script])
5891 .output()
5892 {
5893 let raw = String::from_utf8_lossy(&o.stdout);
5894 let text = raw.trim();
5895 if text != "NONE" && !text.is_empty() {
5896 let get = |key: &str| -> &str {
5897 text.split('|')
5898 .find(|s| s.starts_with(key))
5899 .and_then(|s| s.splitn(2, ':').nth(1))
5900 .unwrap_or("")
5901 };
5902 let enabled = get("ENABLE");
5903 let server = get("SERVER");
5904 let overrides = get("OVERRIDE");
5905 out.push_str("WinINET / IE proxy:\n");
5906 out.push_str(&format!(
5907 " Enabled: {}\n",
5908 if enabled == "1" { "yes" } else { "no" }
5909 ));
5910 if !server.is_empty() && server != "None" {
5911 out.push_str(&format!(" Proxy server: {server}\n"));
5912 }
5913 if !overrides.is_empty() && overrides != "None" {
5914 out.push_str(&format!(" Bypass list: {overrides}\n"));
5915 }
5916 out.push('\n');
5917 }
5918 }
5919
5920 if let Ok(o) = Command::new("netsh")
5921 .args(["winhttp", "show", "proxy"])
5922 .output()
5923 {
5924 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5925 out.push_str("WinHTTP proxy:\n");
5926 for line in text.lines() {
5927 let l = line.trim();
5928 if !l.is_empty() {
5929 out.push_str(&format!(" {l}\n"));
5930 }
5931 }
5932 out.push('\n');
5933 }
5934
5935 let mut env_found = false;
5936 for var in &[
5937 "http_proxy",
5938 "https_proxy",
5939 "HTTP_PROXY",
5940 "HTTPS_PROXY",
5941 "no_proxy",
5942 "NO_PROXY",
5943 ] {
5944 if let Ok(val) = std::env::var(var) {
5945 if !env_found {
5946 out.push_str("Environment proxy variables:\n");
5947 env_found = true;
5948 }
5949 out.push_str(&format!(" {var}: {val}\n"));
5950 }
5951 }
5952 if !env_found {
5953 out.push_str("No proxy environment variables set.\n");
5954 }
5955 }
5956
5957 #[cfg(not(target_os = "windows"))]
5958 {
5959 let mut found = false;
5960 for var in &[
5961 "http_proxy",
5962 "https_proxy",
5963 "HTTP_PROXY",
5964 "HTTPS_PROXY",
5965 "no_proxy",
5966 "NO_PROXY",
5967 "ALL_PROXY",
5968 "all_proxy",
5969 ] {
5970 if let Ok(val) = std::env::var(var) {
5971 if !found {
5972 out.push_str("Proxy environment variables:\n");
5973 found = true;
5974 }
5975 out.push_str(&format!(" {var}: {val}\n"));
5976 }
5977 }
5978 if !found {
5979 out.push_str("No proxy environment variables set.\n");
5980 }
5981 if let Ok(content) = std::fs::read_to_string("/etc/environment") {
5982 let proxy_lines: Vec<&str> = content
5983 .lines()
5984 .filter(|l| l.to_lowercase().contains("proxy"))
5985 .collect();
5986 if !proxy_lines.is_empty() {
5987 out.push_str("\nSystem proxy (/etc/environment):\n");
5988 for l in proxy_lines {
5989 out.push_str(&format!(" {l}\n"));
5990 }
5991 }
5992 }
5993 }
5994
5995 Ok(out.trim_end().to_string())
5996}
5997
5998fn inspect_firewall_rules(max_entries: usize) -> Result<String, String> {
6001 let mut out = String::from("Host inspection: firewall_rules\n\n");
6002 let n = max_entries.clamp(1, 20);
6003
6004 #[cfg(target_os = "windows")]
6005 {
6006 let script = format!(
6007 r#"
6008try {{
6009 $rules = Get-NetFirewallRule -Enabled True -ErrorAction Stop |
6010 Where-Object {{
6011 $_.DisplayGroup -notmatch '^(@|Core Networking|Windows|File and Printer)' -and
6012 $_.Owner -eq $null
6013 }} | Select-Object -First {n} DisplayName, Direction, Action, Profile
6014 "TOTAL:" + $rules.Count
6015 $rules | ForEach-Object {{
6016 $dir = switch ($_.Direction) {{ 1 {{ "Inbound" }}; 2 {{ "Outbound" }}; default {{ "?" }} }}
6017 $act = switch ($_.Action) {{ 2 {{ "Allow" }}; 4 {{ "Block" }}; default {{ "?" }} }}
6018 $_.DisplayName + "|" + $dir + "|" + $act + "|" + $_.Profile
6019 }}
6020}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6021 );
6022
6023 let output = Command::new("powershell")
6024 .args(["-NoProfile", "-Command", &script])
6025 .output()
6026 .map_err(|e| format!("firewall_rules: {e}"))?;
6027
6028 let raw = String::from_utf8_lossy(&output.stdout);
6029 let text = raw.trim();
6030
6031 if text.starts_with("ERROR:") {
6032 out.push_str(&format!(
6033 "Unable to query firewall rules: {}\n",
6034 text.trim_start_matches("ERROR:").trim()
6035 ));
6036 out.push_str("This query may require running as administrator.\n");
6037 } else if text.is_empty() {
6038 out.push_str("No non-default enabled firewall rules found.\n");
6039 } else {
6040 let mut total = 0usize;
6041 for line in text.lines() {
6042 if let Some(rest) = line.strip_prefix("TOTAL:") {
6043 total = rest.trim().parse().unwrap_or(0);
6044 out.push_str(&format!(
6045 "Non-default enabled rules (showing up to {n}):\n\n"
6046 ));
6047 } else {
6048 let parts: Vec<&str> = line.splitn(4, '|').collect();
6049 if parts.len() >= 3 {
6050 let name = parts[0];
6051 let dir = parts[1];
6052 let action = parts[2];
6053 let profile = parts.get(3).unwrap_or(&"Any");
6054 let icon = if action == "Block" { "[!]" } else { " " };
6055 out.push_str(&format!(
6056 " {icon} [{dir}] {action}: {name} (profile: {profile})\n"
6057 ));
6058 }
6059 }
6060 }
6061 if total == 0 {
6062 out.push_str("No non-default enabled rules found.\n");
6063 }
6064 }
6065 }
6066
6067 #[cfg(not(target_os = "windows"))]
6068 {
6069 if let Ok(o) = Command::new("ufw").args(["status", "numbered"]).output() {
6070 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6071 if !text.is_empty() {
6072 out.push_str(&text);
6073 out.push('\n');
6074 }
6075 } else if let Ok(o) = Command::new("iptables")
6076 .args(["-L", "-n", "--line-numbers"])
6077 .output()
6078 {
6079 let text = String::from_utf8_lossy(&o.stdout);
6080 for l in text.lines().take(n * 2) {
6081 out.push_str(&format!(" {l}\n"));
6082 }
6083 } else {
6084 out.push_str("ufw and iptables not available or insufficient permissions.\n");
6085 }
6086 }
6087
6088 Ok(out.trim_end().to_string())
6089}
6090
6091fn inspect_traceroute(host: &str, max_entries: usize) -> Result<String, String> {
6094 let mut out = format!("Host inspection: traceroute\n\nTarget: {host}\n\n");
6095 let hops = max_entries.clamp(5, 30);
6096
6097 #[cfg(target_os = "windows")]
6098 {
6099 let output = Command::new("tracert")
6100 .args(["-d", "-h", &hops.to_string(), host])
6101 .output()
6102 .map_err(|e| format!("tracert: {e}"))?;
6103 let raw = String::from_utf8_lossy(&output.stdout);
6104 let mut hop_count = 0usize;
6105 for line in raw.lines() {
6106 let trimmed = line.trim();
6107 if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
6108 hop_count += 1;
6109 out.push_str(&format!(" {trimmed}\n"));
6110 } else if trimmed.starts_with("Tracing") || trimmed.starts_with("Trace complete") {
6111 out.push_str(&format!("{trimmed}\n"));
6112 }
6113 }
6114 if hop_count == 0 {
6115 out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
6116 }
6117 }
6118
6119 #[cfg(not(target_os = "windows"))]
6120 {
6121 let cmd = if std::path::Path::new("/usr/bin/traceroute").exists()
6122 || std::path::Path::new("/usr/sbin/traceroute").exists()
6123 {
6124 "traceroute"
6125 } else {
6126 "tracepath"
6127 };
6128 let output = Command::new(cmd)
6129 .args(["-m", &hops.to_string(), "-n", host])
6130 .output()
6131 .map_err(|e| format!("{cmd}: {e}"))?;
6132 let raw = String::from_utf8_lossy(&output.stdout);
6133 let mut hop_count = 0usize;
6134 for line in raw.lines().take(hops + 2) {
6135 let trimmed = line.trim();
6136 if !trimmed.is_empty() {
6137 hop_count += 1;
6138 out.push_str(&format!(" {trimmed}\n"));
6139 }
6140 }
6141 if hop_count == 0 {
6142 out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
6143 }
6144 }
6145
6146 Ok(out.trim_end().to_string())
6147}
6148
6149fn inspect_dns_cache(max_entries: usize) -> Result<String, String> {
6152 let mut out = String::from("Host inspection: dns_cache\n\n");
6153 let n = max_entries.clamp(10, 100);
6154
6155 #[cfg(target_os = "windows")]
6156 {
6157 let output = Command::new("powershell")
6158 .args([
6159 "-NoProfile",
6160 "-Command",
6161 "Get-DnsClientCache | Select-Object -First 200 Entry,RecordType,Data,TimeToLive | ConvertTo-Csv -NoTypeInformation",
6162 ])
6163 .output()
6164 .map_err(|e| format!("dns_cache: {e}"))?;
6165
6166 let raw = String::from_utf8_lossy(&output.stdout);
6167 let lines: Vec<&str> = raw.lines().skip(1).collect();
6168 let total = lines.len();
6169
6170 if total == 0 {
6171 out.push_str("DNS cache is empty or could not be read.\n");
6172 } else {
6173 out.push_str(&format!(
6174 "DNS cache entries (showing up to {n} of {total}):\n\n"
6175 ));
6176 let mut shown = 0usize;
6177 for line in lines.iter().take(n) {
6178 let cols: Vec<&str> = line.splitn(4, ',').collect();
6179 if cols.len() >= 3 {
6180 let entry = cols[0].trim_matches('"');
6181 let rtype = cols[1].trim_matches('"');
6182 let data = cols[2].trim_matches('"');
6183 let ttl = cols.get(3).map(|s| s.trim_matches('"')).unwrap_or("?");
6184 out.push_str(&format!(" {entry:<45} {rtype:<6} {data} (TTL {ttl}s)\n"));
6185 shown += 1;
6186 }
6187 }
6188 if total > shown {
6189 out.push_str(&format!("\n ... and {} more entries\n", total - shown));
6190 }
6191 }
6192 }
6193
6194 #[cfg(not(target_os = "windows"))]
6195 {
6196 if let Ok(o) = Command::new("resolvectl").args(["statistics"]).output() {
6197 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6198 if !text.is_empty() {
6199 out.push_str("systemd-resolved statistics:\n");
6200 for line in text.lines().take(n) {
6201 out.push_str(&format!(" {line}\n"));
6202 }
6203 out.push('\n');
6204 }
6205 }
6206 if let Ok(o) = Command::new("dscacheutil")
6207 .args(["-cachedump", "-entries", "Host"])
6208 .output()
6209 {
6210 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6211 if !text.is_empty() {
6212 out.push_str("DNS cache (macOS dscacheutil):\n");
6213 for line in text.lines().take(n) {
6214 out.push_str(&format!(" {line}\n"));
6215 }
6216 } else {
6217 out.push_str("DNS cache is empty or not accessible on this platform.\n");
6218 }
6219 } else {
6220 out.push_str(
6221 "DNS cache inspection not available (no resolvectl or dscacheutil found).\n",
6222 );
6223 }
6224 }
6225
6226 Ok(out.trim_end().to_string())
6227}
6228
6229fn inspect_arp() -> Result<String, String> {
6232 let mut out = String::from("Host inspection: arp\n\n");
6233
6234 #[cfg(target_os = "windows")]
6235 {
6236 let output = Command::new("arp")
6237 .args(["-a"])
6238 .output()
6239 .map_err(|e| format!("arp: {e}"))?;
6240 let raw = String::from_utf8_lossy(&output.stdout);
6241 let mut count = 0usize;
6242 for line in raw.lines() {
6243 let t = line.trim();
6244 if t.is_empty() {
6245 continue;
6246 }
6247 out.push_str(&format!(" {t}\n"));
6248 if t.contains("dynamic") || t.contains("static") {
6249 count += 1;
6250 }
6251 }
6252 out.push_str(&format!("\nTotal entries: {count}\n"));
6253 }
6254
6255 #[cfg(not(target_os = "windows"))]
6256 {
6257 if let Ok(o) = Command::new("arp").args(["-n"]).output() {
6258 let raw = String::from_utf8_lossy(&o.stdout);
6259 let mut count = 0usize;
6260 for line in raw.lines() {
6261 let t = line.trim();
6262 if !t.is_empty() {
6263 out.push_str(&format!(" {t}\n"));
6264 count += 1;
6265 }
6266 }
6267 out.push_str(&format!("\nTotal entries: {}\n", count.saturating_sub(1)));
6268 } else if let Ok(o) = Command::new("ip").args(["neigh"]).output() {
6269 let raw = String::from_utf8_lossy(&o.stdout);
6270 let mut count = 0usize;
6271 for line in raw.lines() {
6272 let t = line.trim();
6273 if !t.is_empty() {
6274 out.push_str(&format!(" {t}\n"));
6275 count += 1;
6276 }
6277 }
6278 out.push_str(&format!("\nTotal entries: {count}\n"));
6279 } else {
6280 out.push_str("arp and ip neigh not available.\n");
6281 }
6282 }
6283
6284 Ok(out.trim_end().to_string())
6285}
6286
6287fn inspect_route_table(max_entries: usize) -> Result<String, String> {
6290 let mut out = String::from("Host inspection: route_table\n\n");
6291 let n = max_entries.clamp(10, 50);
6292
6293 #[cfg(target_os = "windows")]
6294 {
6295 let script = r#"
6296try {
6297 $routes = Get-NetRoute -ErrorAction Stop |
6298 Where-Object { $_.RouteMetric -lt 9000 } |
6299 Sort-Object RouteMetric |
6300 Select-Object DestinationPrefix, NextHop, RouteMetric, InterfaceAlias
6301 "TOTAL:" + $routes.Count
6302 $routes | ForEach-Object {
6303 $_.DestinationPrefix + "|" + $_.NextHop + "|" + $_.RouteMetric + "|" + $_.InterfaceAlias
6304 }
6305} catch { "ERROR:" + $_.Exception.Message }
6306"#;
6307 let output = Command::new("powershell")
6308 .args(["-NoProfile", "-Command", script])
6309 .output()
6310 .map_err(|e| format!("route_table: {e}"))?;
6311 let raw = String::from_utf8_lossy(&output.stdout);
6312 let text = raw.trim();
6313
6314 if text.starts_with("ERROR:") {
6315 out.push_str(&format!(
6316 "Unable to read route table: {}\n",
6317 text.trim_start_matches("ERROR:").trim()
6318 ));
6319 } else {
6320 let mut shown = 0usize;
6321 for line in text.lines() {
6322 if let Some(rest) = line.strip_prefix("TOTAL:") {
6323 let total: usize = rest.trim().parse().unwrap_or(0);
6324 out.push_str(&format!(
6325 "Routing table (showing up to {n} of {total} routes):\n\n"
6326 ));
6327 out.push_str(&format!(
6328 " {:<22} {:<18} {:>8} Interface\n",
6329 "Destination", "Next Hop", "Metric"
6330 ));
6331 out.push_str(&format!(" {}\n", "-".repeat(70)));
6332 } else if shown < n {
6333 let parts: Vec<&str> = line.splitn(4, '|').collect();
6334 if parts.len() == 4 {
6335 let dest = parts[0];
6336 let hop =
6337 if parts[1].is_empty() || parts[1] == "0.0.0.0" || parts[1] == "::" {
6338 "on-link"
6339 } else {
6340 parts[1]
6341 };
6342 let metric = parts[2];
6343 let iface = parts[3];
6344 out.push_str(&format!(" {dest:<22} {hop:<18} {metric:>8} {iface}\n"));
6345 shown += 1;
6346 }
6347 }
6348 }
6349 }
6350 }
6351
6352 #[cfg(not(target_os = "windows"))]
6353 {
6354 if let Ok(o) = Command::new("ip").args(["route", "show"]).output() {
6355 let raw = String::from_utf8_lossy(&o.stdout);
6356 let lines: Vec<&str> = raw.lines().collect();
6357 let total = lines.len();
6358 out.push_str(&format!(
6359 "Routing table (showing up to {n} of {total} routes):\n\n"
6360 ));
6361 for line in lines.iter().take(n) {
6362 out.push_str(&format!(" {line}\n"));
6363 }
6364 if total > n {
6365 out.push_str(&format!("\n ... and {} more routes\n", total - n));
6366 }
6367 } else if let Ok(o) = Command::new("netstat").args(["-rn"]).output() {
6368 let raw = String::from_utf8_lossy(&o.stdout);
6369 for line in raw.lines().take(n) {
6370 out.push_str(&format!(" {line}\n"));
6371 }
6372 } else {
6373 out.push_str("ip route and netstat not available.\n");
6374 }
6375 }
6376
6377 Ok(out.trim_end().to_string())
6378}
6379
6380fn inspect_env(max_entries: usize) -> Result<String, String> {
6383 let mut out = String::from("Host inspection: env\n\n");
6384 let n = max_entries.clamp(10, 50);
6385
6386 fn looks_like_secret(name: &str) -> bool {
6387 let n = name.to_uppercase();
6388 n.contains("KEY")
6389 || n.contains("SECRET")
6390 || n.contains("TOKEN")
6391 || n.contains("PASSWORD")
6392 || n.contains("PASSWD")
6393 || n.contains("CREDENTIAL")
6394 || n.contains("AUTH")
6395 || n.contains("CERT")
6396 || n.contains("PRIVATE")
6397 }
6398
6399 let known_dev_vars: &[&str] = &[
6400 "CARGO_HOME",
6401 "RUSTUP_HOME",
6402 "GOPATH",
6403 "GOROOT",
6404 "GOBIN",
6405 "JAVA_HOME",
6406 "ANDROID_HOME",
6407 "ANDROID_SDK_ROOT",
6408 "PYTHONPATH",
6409 "PYTHONHOME",
6410 "VIRTUAL_ENV",
6411 "CONDA_DEFAULT_ENV",
6412 "CONDA_PREFIX",
6413 "NODE_PATH",
6414 "NVM_DIR",
6415 "NVM_BIN",
6416 "PNPM_HOME",
6417 "DENO_INSTALL",
6418 "DENO_DIR",
6419 "DOTNET_ROOT",
6420 "NUGET_PACKAGES",
6421 "CMAKE_HOME",
6422 "VCPKG_ROOT",
6423 "AWS_PROFILE",
6424 "AWS_REGION",
6425 "AWS_DEFAULT_REGION",
6426 "GCP_PROJECT",
6427 "GOOGLE_CLOUD_PROJECT",
6428 "GOOGLE_APPLICATION_CREDENTIALS",
6429 "AZURE_SUBSCRIPTION_ID",
6430 "DATABASE_URL",
6431 "REDIS_URL",
6432 "MONGO_URI",
6433 "EDITOR",
6434 "VISUAL",
6435 "SHELL",
6436 "TERM",
6437 "XDG_CONFIG_HOME",
6438 "XDG_DATA_HOME",
6439 "XDG_CACHE_HOME",
6440 "HOME",
6441 "USERPROFILE",
6442 "APPDATA",
6443 "LOCALAPPDATA",
6444 "TEMP",
6445 "TMP",
6446 "COMPUTERNAME",
6447 "USERNAME",
6448 "USERDOMAIN",
6449 "PROCESSOR_ARCHITECTURE",
6450 "NUMBER_OF_PROCESSORS",
6451 "OS",
6452 "HOMEDRIVE",
6453 "HOMEPATH",
6454 "HTTP_PROXY",
6455 "HTTPS_PROXY",
6456 "NO_PROXY",
6457 "ALL_PROXY",
6458 "http_proxy",
6459 "https_proxy",
6460 "no_proxy",
6461 "DOCKER_HOST",
6462 "DOCKER_BUILDKIT",
6463 "COMPOSE_PROJECT_NAME",
6464 "KUBECONFIG",
6465 "KUBE_CONTEXT",
6466 "CI",
6467 "GITHUB_ACTIONS",
6468 "GITLAB_CI",
6469 "LMSTUDIO_HOME",
6470 "HEMATITE_URL",
6471 ];
6472
6473 let mut all_vars: Vec<(String, String)> = std::env::vars().collect();
6474 all_vars.sort_by(|a, b| a.0.cmp(&b.0));
6475 let total = all_vars.len();
6476
6477 let mut dev_found: Vec<String> = Vec::new();
6478 let mut secret_found: Vec<String> = Vec::new();
6479
6480 for (k, v) in &all_vars {
6481 if k == "PATH" {
6482 continue;
6483 }
6484 if looks_like_secret(k) {
6485 secret_found.push(format!("{k} = [SET, {} chars]", v.len()));
6486 } else {
6487 let k_upper = k.to_uppercase();
6488 let is_known = known_dev_vars
6489 .iter()
6490 .any(|kv| k_upper.as_str() == kv.to_uppercase().as_str());
6491 if is_known {
6492 let display = if v.len() > 120 {
6493 format!("{k} = {}…", &v[..117])
6494 } else {
6495 format!("{k} = {v}")
6496 };
6497 dev_found.push(display);
6498 }
6499 }
6500 }
6501
6502 out.push_str(&format!("Total environment variables: {total}\n\n"));
6503
6504 if let Ok(p) = std::env::var("PATH") {
6505 let sep = if cfg!(target_os = "windows") {
6506 ';'
6507 } else {
6508 ':'
6509 };
6510 let count = p.split(sep).count();
6511 out.push_str(&format!(
6512 "PATH: {count} entries (use topic=path for full audit)\n\n"
6513 ));
6514 }
6515
6516 if !secret_found.is_empty() {
6517 out.push_str(&format!(
6518 "=== Secret/credential variables ({} detected, values hidden) ===\n",
6519 secret_found.len()
6520 ));
6521 for s in secret_found.iter().take(n) {
6522 out.push_str(&format!(" {s}\n"));
6523 }
6524 out.push('\n');
6525 }
6526
6527 if !dev_found.is_empty() {
6528 out.push_str(&format!(
6529 "=== Developer & tool variables ({}) ===\n",
6530 dev_found.len()
6531 ));
6532 for d in dev_found.iter().take(n) {
6533 out.push_str(&format!(" {d}\n"));
6534 }
6535 out.push('\n');
6536 }
6537
6538 let other_count = all_vars
6539 .iter()
6540 .filter(|(k, _)| {
6541 k != "PATH"
6542 && !looks_like_secret(k)
6543 && !known_dev_vars
6544 .iter()
6545 .any(|kv| k.to_uppercase().as_str() == kv.to_uppercase().as_str())
6546 })
6547 .count();
6548 if other_count > 0 {
6549 out.push_str(&format!(
6550 "Other variables: {other_count} (use 'env' in shell to see all)\n"
6551 ));
6552 }
6553
6554 Ok(out.trim_end().to_string())
6555}
6556
6557fn inspect_hosts_file() -> Result<String, String> {
6560 let mut out = String::from("Host inspection: hosts_file\n\n");
6561
6562 let hosts_path = if cfg!(target_os = "windows") {
6563 std::path::PathBuf::from(r"C:\Windows\System32\drivers\etc\hosts")
6564 } else {
6565 std::path::PathBuf::from("/etc/hosts")
6566 };
6567
6568 out.push_str(&format!("Path: {}\n\n", hosts_path.display()));
6569
6570 match fs::read_to_string(&hosts_path) {
6571 Ok(content) => {
6572 let mut active_entries: Vec<String> = Vec::new();
6573 let mut comment_lines = 0usize;
6574 let mut blank_lines = 0usize;
6575
6576 for line in content.lines() {
6577 let t = line.trim();
6578 if t.is_empty() {
6579 blank_lines += 1;
6580 } else if t.starts_with('#') {
6581 comment_lines += 1;
6582 } else {
6583 active_entries.push(line.to_string());
6584 }
6585 }
6586
6587 out.push_str(&format!(
6588 "Active entries: {} | Comment lines: {} | Blank lines: {}\n\n",
6589 active_entries.len(),
6590 comment_lines,
6591 blank_lines
6592 ));
6593
6594 if active_entries.is_empty() {
6595 out.push_str(
6596 "No active host entries (file contains only comments/blanks — standard default state).\n",
6597 );
6598 } else {
6599 out.push_str("=== Active entries ===\n");
6600 for entry in &active_entries {
6601 out.push_str(&format!(" {entry}\n"));
6602 }
6603 out.push('\n');
6604
6605 let custom: Vec<&String> = active_entries
6606 .iter()
6607 .filter(|e| {
6608 let t = e.trim_start();
6609 !t.starts_with("127.") && !t.starts_with("::1") && !t.starts_with("0.0.0.0")
6610 })
6611 .collect();
6612 if !custom.is_empty() {
6613 out.push_str(&format!(
6614 "[!] Custom (non-loopback) entries: {}\n",
6615 custom.len()
6616 ));
6617 for e in &custom {
6618 out.push_str(&format!(" {e}\n"));
6619 }
6620 } else {
6621 out.push_str("All active entries are standard loopback or block entries.\n");
6622 }
6623 }
6624
6625 out.push_str("\n=== Full file ===\n");
6626 for line in content.lines() {
6627 out.push_str(&format!(" {line}\n"));
6628 }
6629 }
6630 Err(e) => {
6631 out.push_str(&format!("Could not read hosts file: {e}\n"));
6632 if cfg!(target_os = "windows") {
6633 out.push_str(
6634 "On Windows, run Hematite as Administrator if permission is denied.\n",
6635 );
6636 }
6637 }
6638 }
6639
6640 Ok(out.trim_end().to_string())
6641}
6642
6643fn inspect_docker(max_entries: usize) -> Result<String, String> {
6646 let mut out = String::from("Host inspection: docker\n\n");
6647 let n = max_entries.clamp(5, 25);
6648
6649 let version_output = Command::new("docker")
6650 .args(["version", "--format", "{{.Server.Version}}"])
6651 .output();
6652
6653 match version_output {
6654 Err(_) => {
6655 out.push_str("Docker: not found on PATH.\n");
6656 out.push_str(
6657 "Install Docker Desktop: https://www.docker.com/products/docker-desktop\n",
6658 );
6659 return Ok(out.trim_end().to_string());
6660 }
6661 Ok(o) if !o.status.success() => {
6662 let stderr = String::from_utf8_lossy(&o.stderr);
6663 if stderr.contains("cannot connect")
6664 || stderr.contains("Is the docker daemon running")
6665 || stderr.contains("pipe")
6666 || stderr.contains("socket")
6667 {
6668 out.push_str("Docker: installed but daemon is NOT running.\n");
6669 out.push_str("Start Docker Desktop or run: sudo systemctl start docker\n");
6670 } else {
6671 out.push_str(&format!("Docker: error — {}\n", stderr.trim()));
6672 }
6673 return Ok(out.trim_end().to_string());
6674 }
6675 Ok(o) => {
6676 let version = String::from_utf8_lossy(&o.stdout).trim().to_string();
6677 out.push_str(&format!("Docker Engine: {version}\n"));
6678 }
6679 }
6680
6681 if let Ok(o) = Command::new("docker")
6682 .args([
6683 "info",
6684 "--format",
6685 "Containers: {{.Containers}} (running: {{.ContainersRunning}}, stopped: {{.ContainersStopped}})\nImages: {{.Images}}\nStorage driver: {{.Driver}}\nOS/Arch: {{.OSType}}/{{.Architecture}}\nCPUs: {{.NCPU}}",
6686 ])
6687 .output()
6688 {
6689 let info = String::from_utf8_lossy(&o.stdout);
6690 for line in info.lines() {
6691 let t = line.trim();
6692 if !t.is_empty() {
6693 out.push_str(&format!(" {t}\n"));
6694 }
6695 }
6696 out.push('\n');
6697 }
6698
6699 if let Ok(o) = Command::new("docker")
6700 .args([
6701 "ps",
6702 "--format",
6703 "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
6704 ])
6705 .output()
6706 {
6707 let raw = String::from_utf8_lossy(&o.stdout);
6708 let lines: Vec<&str> = raw.lines().collect();
6709 if lines.len() <= 1 {
6710 out.push_str("Running containers: none\n\n");
6711 } else {
6712 out.push_str(&format!(
6713 "=== Running containers ({}) ===\n",
6714 lines.len().saturating_sub(1)
6715 ));
6716 for line in lines.iter().take(n + 1) {
6717 out.push_str(&format!(" {line}\n"));
6718 }
6719 if lines.len() > n + 1 {
6720 out.push_str(&format!(" ... and {} more\n", lines.len() - n - 1));
6721 }
6722 out.push('\n');
6723 }
6724 }
6725
6726 if let Ok(o) = Command::new("docker")
6727 .args([
6728 "images",
6729 "--format",
6730 "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}",
6731 ])
6732 .output()
6733 {
6734 let raw = String::from_utf8_lossy(&o.stdout);
6735 let lines: Vec<&str> = raw.lines().collect();
6736 if lines.len() > 1 {
6737 out.push_str(&format!(
6738 "=== Local images ({}) ===\n",
6739 lines.len().saturating_sub(1)
6740 ));
6741 for line in lines.iter().take(n + 1) {
6742 out.push_str(&format!(" {line}\n"));
6743 }
6744 if lines.len() > n + 1 {
6745 out.push_str(&format!(" ... and {} more\n", lines.len() - n - 1));
6746 }
6747 out.push('\n');
6748 }
6749 }
6750
6751 if let Ok(o) = Command::new("docker")
6752 .args([
6753 "compose",
6754 "ls",
6755 "--format",
6756 "table {{.Name}}\t{{.Status}}\t{{.ConfigFiles}}",
6757 ])
6758 .output()
6759 {
6760 let raw = String::from_utf8_lossy(&o.stdout);
6761 let lines: Vec<&str> = raw.lines().collect();
6762 if lines.len() > 1 {
6763 out.push_str(&format!(
6764 "=== Compose projects ({}) ===\n",
6765 lines.len().saturating_sub(1)
6766 ));
6767 for line in lines.iter().take(n + 1) {
6768 out.push_str(&format!(" {line}\n"));
6769 }
6770 out.push('\n');
6771 }
6772 }
6773
6774 if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
6775 let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
6776 if !ctx.is_empty() {
6777 out.push_str(&format!("Active context: {ctx}\n"));
6778 }
6779 }
6780
6781 Ok(out.trim_end().to_string())
6782}
6783
6784fn inspect_wsl() -> Result<String, String> {
6787 let mut out = String::from("Host inspection: wsl\n\n");
6788
6789 #[cfg(target_os = "windows")]
6790 {
6791 if let Ok(o) = Command::new("wsl").args(["--version"]).output() {
6792 let raw = String::from_utf8_lossy(&o.stdout);
6793 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
6794 for line in cleaned.lines().take(4) {
6795 let t = line.trim();
6796 if !t.is_empty() {
6797 out.push_str(&format!(" {t}\n"));
6798 }
6799 }
6800 out.push('\n');
6801 }
6802
6803 let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
6804 match list_output {
6805 Err(e) => {
6806 out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
6807 out.push_str("WSL may not be installed. Enable with: wsl --install\n");
6808 }
6809 Ok(o) if !o.status.success() => {
6810 let stderr = String::from_utf8_lossy(&o.stderr);
6811 let cleaned: String = stderr.chars().filter(|c| *c != '\0').collect();
6812 out.push_str(&format!("WSL: error — {}\n", cleaned.trim()));
6813 out.push_str("Run: wsl --install\n");
6814 }
6815 Ok(o) => {
6816 let raw = String::from_utf8_lossy(&o.stdout);
6817 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
6818 let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
6819 let distro_lines: Vec<&str> = lines
6820 .iter()
6821 .filter(|l| {
6822 let t = l.trim();
6823 !t.is_empty()
6824 && !t.to_uppercase().starts_with("NAME")
6825 && !t.starts_with("---")
6826 })
6827 .copied()
6828 .collect();
6829
6830 if distro_lines.is_empty() {
6831 out.push_str("WSL: installed but no distributions found.\n");
6832 out.push_str("Install a distro: wsl --install -d Ubuntu\n");
6833 } else {
6834 out.push_str("=== WSL Distributions ===\n");
6835 for line in &lines {
6836 out.push_str(&format!(" {}\n", line.trim()));
6837 }
6838 out.push_str(&format!("\nTotal distributions: {}\n", distro_lines.len()));
6839 }
6840 }
6841 }
6842
6843 if let Ok(o) = Command::new("wsl").args(["--status"]).output() {
6844 let raw = String::from_utf8_lossy(&o.stdout);
6845 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
6846 let status_lines: Vec<&str> = cleaned
6847 .lines()
6848 .filter(|l| !l.trim().is_empty())
6849 .take(8)
6850 .collect();
6851 if !status_lines.is_empty() {
6852 out.push_str("\n=== WSL status ===\n");
6853 for line in status_lines {
6854 out.push_str(&format!(" {}\n", line.trim()));
6855 }
6856 }
6857 }
6858 }
6859
6860 #[cfg(not(target_os = "windows"))]
6861 {
6862 out.push_str("WSL (Windows Subsystem for Linux) is a Windows-only feature.\n");
6863 out.push_str("On Linux/macOS, use native virtualization (KVM, UTM, Parallels) instead.\n");
6864 }
6865
6866 Ok(out.trim_end().to_string())
6867}
6868
6869fn dirs_home() -> Option<PathBuf> {
6872 std::env::var("HOME")
6873 .ok()
6874 .map(PathBuf::from)
6875 .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
6876}
6877
6878fn inspect_ssh() -> Result<String, String> {
6879 let mut out = String::from("Host inspection: ssh\n\n");
6880
6881 if let Ok(o) = Command::new("ssh").args(["-V"]).output() {
6882 let ver = if o.stdout.is_empty() {
6883 String::from_utf8_lossy(&o.stderr).trim().to_string()
6884 } else {
6885 String::from_utf8_lossy(&o.stdout).trim().to_string()
6886 };
6887 if !ver.is_empty() {
6888 out.push_str(&format!("SSH client: {ver}\n"));
6889 }
6890 } else {
6891 out.push_str("SSH client: not found on PATH.\n");
6892 }
6893
6894 #[cfg(target_os = "windows")]
6895 {
6896 let script = r#"
6897$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue
6898if ($svc) { "SSHD:" + $svc.Status + " | StartType:" + $svc.StartType }
6899else { "SSHD:not_installed" }
6900"#;
6901 if let Ok(o) = Command::new("powershell")
6902 .args(["-NoProfile", "-Command", script])
6903 .output()
6904 {
6905 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6906 if text.contains("not_installed") {
6907 out.push_str("SSH server (sshd): not installed\n");
6908 } else {
6909 out.push_str(&format!(
6910 "SSH server (sshd): {}\n",
6911 text.trim_start_matches("SSHD:")
6912 ));
6913 }
6914 }
6915 }
6916
6917 #[cfg(not(target_os = "windows"))]
6918 {
6919 if let Ok(o) = Command::new("systemctl")
6920 .args(["is-active", "sshd"])
6921 .output()
6922 {
6923 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
6924 out.push_str(&format!("SSH server (sshd): {status}\n"));
6925 } else if let Ok(o) = Command::new("systemctl")
6926 .args(["is-active", "ssh"])
6927 .output()
6928 {
6929 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
6930 out.push_str(&format!("SSH server (ssh): {status}\n"));
6931 }
6932 }
6933
6934 out.push('\n');
6935
6936 if let Some(ssh_dir) = dirs_home().map(|h| h.join(".ssh")) {
6937 if ssh_dir.exists() {
6938 out.push_str(&format!("~/.ssh: {}\n", ssh_dir.display()));
6939
6940 let kh = ssh_dir.join("known_hosts");
6941 if kh.exists() {
6942 let count = fs::read_to_string(&kh)
6943 .map(|c| {
6944 c.lines()
6945 .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
6946 .count()
6947 })
6948 .unwrap_or(0);
6949 out.push_str(&format!(" known_hosts: {count} entries\n"));
6950 } else {
6951 out.push_str(" known_hosts: not present\n");
6952 }
6953
6954 let ak = ssh_dir.join("authorized_keys");
6955 if ak.exists() {
6956 let count = fs::read_to_string(&ak)
6957 .map(|c| {
6958 c.lines()
6959 .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
6960 .count()
6961 })
6962 .unwrap_or(0);
6963 out.push_str(&format!(" authorized_keys: {count} public keys\n"));
6964 } else {
6965 out.push_str(" authorized_keys: not present\n");
6966 }
6967
6968 let key_names = [
6969 "id_rsa",
6970 "id_ed25519",
6971 "id_ecdsa",
6972 "id_dsa",
6973 "id_ecdsa_sk",
6974 "id_ed25519_sk",
6975 ];
6976 let found_keys: Vec<&str> = key_names
6977 .iter()
6978 .filter(|k| ssh_dir.join(k).exists())
6979 .copied()
6980 .collect();
6981 if !found_keys.is_empty() {
6982 out.push_str(&format!(" Private keys: {}\n", found_keys.join(", ")));
6983 } else {
6984 out.push_str(" Private keys: none found\n");
6985 }
6986
6987 let config_path = ssh_dir.join("config");
6988 if config_path.exists() {
6989 out.push_str("\n=== SSH config hosts ===\n");
6990 match fs::read_to_string(&config_path) {
6991 Ok(content) => {
6992 let mut hosts: Vec<(String, Vec<String>)> = Vec::new();
6993 let mut current: Option<(String, Vec<String>)> = None;
6994 for line in content.lines() {
6995 let t = line.trim();
6996 if t.is_empty() || t.starts_with('#') {
6997 continue;
6998 }
6999 if let Some(host) = t.strip_prefix("Host ") {
7000 if let Some(prev) = current.take() {
7001 hosts.push(prev);
7002 }
7003 current = Some((host.trim().to_string(), Vec::new()));
7004 } else if let Some((_, ref mut details)) = current {
7005 let tu = t.to_uppercase();
7006 if tu.starts_with("HOSTNAME ")
7007 || tu.starts_with("USER ")
7008 || tu.starts_with("PORT ")
7009 || tu.starts_with("IDENTITYFILE ")
7010 {
7011 details.push(t.to_string());
7012 }
7013 }
7014 }
7015 if let Some(prev) = current {
7016 hosts.push(prev);
7017 }
7018
7019 if hosts.is_empty() {
7020 out.push_str(" No Host entries found.\n");
7021 } else {
7022 for (h, details) in &hosts {
7023 if details.is_empty() {
7024 out.push_str(&format!(" Host {h}\n"));
7025 } else {
7026 out.push_str(&format!(
7027 " Host {h} [{}]\n",
7028 details.join(", ")
7029 ));
7030 }
7031 }
7032 out.push_str(&format!("\n Total configured hosts: {}\n", hosts.len()));
7033 }
7034 }
7035 Err(e) => out.push_str(&format!(" Could not read config: {e}\n")),
7036 }
7037 } else {
7038 out.push_str(" SSH config: not present\n");
7039 }
7040 } else {
7041 out.push_str("~/.ssh: directory not found (no SSH keys configured).\n");
7042 }
7043 }
7044
7045 Ok(out.trim_end().to_string())
7046}
7047
7048fn inspect_installed_software(max_entries: usize) -> Result<String, String> {
7051 let mut out = String::from("Host inspection: installed_software\n\n");
7052 let n = max_entries.clamp(10, 50);
7053
7054 #[cfg(target_os = "windows")]
7055 {
7056 let winget_out = Command::new("winget")
7057 .args(["list", "--accept-source-agreements"])
7058 .output();
7059
7060 if let Ok(o) = winget_out {
7061 if o.status.success() {
7062 let raw = String::from_utf8_lossy(&o.stdout);
7063 let mut header_done = false;
7064 let mut packages: Vec<&str> = Vec::new();
7065 for line in raw.lines() {
7066 let t = line.trim();
7067 if t.starts_with("---") {
7068 header_done = true;
7069 continue;
7070 }
7071 if header_done && !t.is_empty() {
7072 packages.push(line);
7073 }
7074 }
7075 let total = packages.len();
7076 out.push_str(&format!(
7077 "=== Installed software via winget ({total} packages) ===\n\n"
7078 ));
7079 for line in packages.iter().take(n) {
7080 out.push_str(&format!(" {line}\n"));
7081 }
7082 if total > n {
7083 out.push_str(&format!("\n ... and {} more packages\n", total - n));
7084 }
7085 out.push_str("\nFor full list: winget list\n");
7086 return Ok(out.trim_end().to_string());
7087 }
7088 }
7089
7090 let script = format!(
7092 r#"
7093$apps = @()
7094$reg_paths = @(
7095 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
7096 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
7097 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
7098)
7099foreach ($p in $reg_paths) {{
7100 try {{
7101 $apps += Get-ItemProperty $p -ErrorAction SilentlyContinue |
7102 Where-Object {{ $_.DisplayName }} |
7103 Select-Object DisplayName, DisplayVersion, Publisher
7104 }} catch {{}}
7105}}
7106$sorted = $apps | Sort-Object DisplayName -Unique
7107"TOTAL:" + $sorted.Count
7108$sorted | Select-Object -First {n} | ForEach-Object {{
7109 $_.DisplayName + "|" + $_.DisplayVersion + "|" + $_.Publisher
7110}}
7111"#
7112 );
7113 if let Ok(o) = Command::new("powershell")
7114 .args(["-NoProfile", "-Command", &script])
7115 .output()
7116 {
7117 let raw = String::from_utf8_lossy(&o.stdout);
7118 out.push_str("=== Installed software (registry scan) ===\n");
7119 out.push_str(&format!(" {:<50} {:<18} Publisher\n", "Name", "Version"));
7120 out.push_str(&format!(" {}\n", "-".repeat(90)));
7121 for line in raw.lines() {
7122 if let Some(rest) = line.strip_prefix("TOTAL:") {
7123 let total: usize = rest.trim().parse().unwrap_or(0);
7124 out.push_str(&format!(" (Total: {total}, showing first {n})\n\n"));
7125 } else if !line.trim().is_empty() {
7126 let parts: Vec<&str> = line.splitn(3, '|').collect();
7127 let name = parts.first().map(|s| s.trim()).unwrap_or("");
7128 let ver = parts.get(1).map(|s| s.trim()).unwrap_or("");
7129 let pub_ = parts.get(2).map(|s| s.trim()).unwrap_or("");
7130 out.push_str(&format!(" {:<50} {:<18} {pub_}\n", name, ver));
7131 }
7132 }
7133 } else {
7134 out.push_str(
7135 "Could not query installed software (winget and registry scan both failed).\n",
7136 );
7137 }
7138 }
7139
7140 #[cfg(target_os = "linux")]
7141 {
7142 let mut found = false;
7143 if let Ok(o) = Command::new("dpkg").args(["--get-selections"]).output() {
7144 if o.status.success() {
7145 let raw = String::from_utf8_lossy(&o.stdout);
7146 let installed: Vec<&str> = raw.lines().filter(|l| l.contains("install")).collect();
7147 let total = installed.len();
7148 out.push_str(&format!("=== Installed packages via dpkg ({total}) ===\n"));
7149 for line in installed.iter().take(n) {
7150 out.push_str(&format!(" {}\n", line.trim()));
7151 }
7152 if total > n {
7153 out.push_str(&format!(" ... and {} more\n", total - n));
7154 }
7155 out.push_str("\nFor full list: dpkg --get-selections | grep install\n");
7156 found = true;
7157 }
7158 }
7159 if !found {
7160 if let Ok(o) = Command::new("rpm")
7161 .args(["-qa", "--queryformat", "%{NAME} %{VERSION}\n"])
7162 .output()
7163 {
7164 if o.status.success() {
7165 let raw = String::from_utf8_lossy(&o.stdout);
7166 let lines: Vec<&str> = raw.lines().collect();
7167 let total = lines.len();
7168 out.push_str(&format!("=== Installed packages via rpm ({total}) ===\n"));
7169 for line in lines.iter().take(n) {
7170 out.push_str(&format!(" {line}\n"));
7171 }
7172 if total > n {
7173 out.push_str(&format!(" ... and {} more\n", total - n));
7174 }
7175 found = true;
7176 }
7177 }
7178 }
7179 if !found {
7180 if let Ok(o) = Command::new("pacman").args(["-Q"]).output() {
7181 if o.status.success() {
7182 let raw = String::from_utf8_lossy(&o.stdout);
7183 let lines: Vec<&str> = raw.lines().collect();
7184 let total = lines.len();
7185 out.push_str(&format!(
7186 "=== Installed packages via pacman ({total}) ===\n"
7187 ));
7188 for line in lines.iter().take(n) {
7189 out.push_str(&format!(" {line}\n"));
7190 }
7191 if total > n {
7192 out.push_str(&format!(" ... and {} more\n", total - n));
7193 }
7194 found = true;
7195 }
7196 }
7197 }
7198 if !found {
7199 out.push_str("No package manager found (tried dpkg, rpm, pacman).\n");
7200 }
7201 }
7202
7203 #[cfg(target_os = "macos")]
7204 {
7205 if let Ok(o) = Command::new("brew").args(["list", "--versions"]).output() {
7206 if o.status.success() {
7207 let raw = String::from_utf8_lossy(&o.stdout);
7208 let lines: Vec<&str> = raw.lines().collect();
7209 let total = lines.len();
7210 out.push_str(&format!("=== Homebrew packages ({total}) ===\n"));
7211 for line in lines.iter().take(n) {
7212 out.push_str(&format!(" {line}\n"));
7213 }
7214 if total > n {
7215 out.push_str(&format!(" ... and {} more\n", total - n));
7216 }
7217 out.push_str("\nFor full list: brew list --versions\n");
7218 }
7219 } else {
7220 out.push_str("Homebrew not found.\n");
7221 }
7222 if let Ok(o) = Command::new("mas").args(["list"]).output() {
7223 if o.status.success() {
7224 let raw = String::from_utf8_lossy(&o.stdout);
7225 let lines: Vec<&str> = raw.lines().collect();
7226 out.push_str(&format!("\n=== Mac App Store apps ({}) ===\n", lines.len()));
7227 for line in lines.iter().take(n) {
7228 out.push_str(&format!(" {line}\n"));
7229 }
7230 }
7231 }
7232 }
7233
7234 Ok(out.trim_end().to_string())
7235}
7236
7237fn inspect_git_config() -> Result<String, String> {
7240 let mut out = String::from("Host inspection: git_config\n\n");
7241
7242 if let Ok(o) = Command::new("git").args(["--version"]).output() {
7243 let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
7244 out.push_str(&format!("Git: {ver}\n\n"));
7245 } else {
7246 out.push_str("Git: not found on PATH.\n");
7247 return Ok(out.trim_end().to_string());
7248 }
7249
7250 if let Ok(o) = Command::new("git")
7251 .args(["config", "--global", "--list"])
7252 .output()
7253 {
7254 if o.status.success() {
7255 let raw = String::from_utf8_lossy(&o.stdout);
7256 let mut pairs: Vec<(String, String)> = raw
7257 .lines()
7258 .filter_map(|l| {
7259 let mut parts = l.splitn(2, '=');
7260 let k = parts.next()?.trim().to_string();
7261 let v = parts.next().unwrap_or("").trim().to_string();
7262 Some((k, v))
7263 })
7264 .collect();
7265 pairs.sort_by(|a, b| a.0.cmp(&b.0));
7266
7267 out.push_str("=== Global git config ===\n");
7268
7269 let sections: &[(&str, &[&str])] = &[
7270 ("Identity", &["user.name", "user.email", "user.signingkey"]),
7271 (
7272 "Core",
7273 &[
7274 "core.editor",
7275 "core.autocrlf",
7276 "core.eol",
7277 "core.ignorecase",
7278 "core.filemode",
7279 ],
7280 ),
7281 (
7282 "Commit/Signing",
7283 &[
7284 "commit.gpgsign",
7285 "tag.gpgsign",
7286 "gpg.format",
7287 "gpg.ssh.allowedsignersfile",
7288 ],
7289 ),
7290 (
7291 "Push/Pull",
7292 &[
7293 "push.default",
7294 "push.autosetupremote",
7295 "pull.rebase",
7296 "pull.ff",
7297 ],
7298 ),
7299 ("Credential", &["credential.helper"]),
7300 ("Branch", &["init.defaultbranch", "branch.autosetuprebase"]),
7301 ];
7302
7303 let mut shown_keys: HashSet<String> = HashSet::new();
7304 for (section, keys) in sections {
7305 let mut section_lines: Vec<String> = Vec::new();
7306 for key in *keys {
7307 if let Some((k, v)) = pairs.iter().find(|(kk, _)| kk == key) {
7308 section_lines.push(format!(" {k} = {v}"));
7309 shown_keys.insert(k.clone());
7310 }
7311 }
7312 if !section_lines.is_empty() {
7313 out.push_str(&format!("\n[{section}]\n"));
7314 for line in section_lines {
7315 out.push_str(&format!("{line}\n"));
7316 }
7317 }
7318 }
7319
7320 let other: Vec<&(String, String)> = pairs
7321 .iter()
7322 .filter(|(k, _)| !shown_keys.contains(k) && !k.starts_with("alias."))
7323 .collect();
7324 if !other.is_empty() {
7325 out.push_str("\n[Other]\n");
7326 for (k, v) in other.iter().take(20) {
7327 out.push_str(&format!(" {k} = {v}\n"));
7328 }
7329 if other.len() > 20 {
7330 out.push_str(&format!(" ... and {} more\n", other.len() - 20));
7331 }
7332 }
7333
7334 out.push_str(&format!("\nTotal global config keys: {}\n", pairs.len()));
7335 } else {
7336 out.push_str("No global git config found.\n");
7337 out.push_str("Set up with:\n");
7338 out.push_str(" git config --global user.name \"Your Name\"\n");
7339 out.push_str(" git config --global user.email \"you@example.com\"\n");
7340 }
7341 }
7342
7343 if let Ok(o) = Command::new("git")
7344 .args(["config", "--local", "--list"])
7345 .output()
7346 {
7347 if o.status.success() {
7348 let raw = String::from_utf8_lossy(&o.stdout);
7349 let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
7350 if !lines.is_empty() {
7351 out.push_str(&format!(
7352 "\n=== Local repo config ({} keys) ===\n",
7353 lines.len()
7354 ));
7355 for line in lines.iter().take(15) {
7356 out.push_str(&format!(" {line}\n"));
7357 }
7358 if lines.len() > 15 {
7359 out.push_str(&format!(" ... and {} more\n", lines.len() - 15));
7360 }
7361 }
7362 }
7363 }
7364
7365 if let Ok(o) = Command::new("git")
7366 .args(["config", "--global", "--get-regexp", r"alias\."])
7367 .output()
7368 {
7369 if o.status.success() {
7370 let raw = String::from_utf8_lossy(&o.stdout);
7371 let aliases: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
7372 if !aliases.is_empty() {
7373 out.push_str(&format!("\n=== Git aliases ({}) ===\n", aliases.len()));
7374 for a in aliases.iter().take(20) {
7375 out.push_str(&format!(" {a}\n"));
7376 }
7377 if aliases.len() > 20 {
7378 out.push_str(&format!(" ... and {} more\n", aliases.len() - 20));
7379 }
7380 }
7381 }
7382 }
7383
7384 Ok(out.trim_end().to_string())
7385}
7386
7387fn inspect_databases() -> Result<String, String> {
7390 let mut out = String::from("Host inspection: databases\n\n");
7391 out.push_str("Scanning for local database engines (service state, port, version)...\n\n");
7392
7393 struct DbEngine {
7394 name: &'static str,
7395 service_names: &'static [&'static str],
7396 default_port: u16,
7397 cli_name: &'static str,
7398 cli_version_args: &'static [&'static str],
7399 }
7400
7401 let engines: &[DbEngine] = &[
7402 DbEngine {
7403 name: "PostgreSQL",
7404 service_names: &[
7405 "postgresql",
7406 "postgresql-x64-14",
7407 "postgresql-x64-15",
7408 "postgresql-x64-16",
7409 "postgresql-x64-17",
7410 ],
7411
7412 default_port: 5432,
7413 cli_name: "psql",
7414 cli_version_args: &["--version"],
7415 },
7416 DbEngine {
7417 name: "MySQL",
7418 service_names: &["mysql", "mysql80", "mysql57"],
7419
7420 default_port: 3306,
7421 cli_name: "mysql",
7422 cli_version_args: &["--version"],
7423 },
7424 DbEngine {
7425 name: "MariaDB",
7426 service_names: &["mariadb", "mariadb.exe"],
7427
7428 default_port: 3306,
7429 cli_name: "mariadb",
7430 cli_version_args: &["--version"],
7431 },
7432 DbEngine {
7433 name: "MongoDB",
7434 service_names: &["mongodb", "mongod"],
7435
7436 default_port: 27017,
7437 cli_name: "mongod",
7438 cli_version_args: &["--version"],
7439 },
7440 DbEngine {
7441 name: "Redis",
7442 service_names: &["redis", "redis-server"],
7443
7444 default_port: 6379,
7445 cli_name: "redis-server",
7446 cli_version_args: &["--version"],
7447 },
7448 DbEngine {
7449 name: "SQL Server",
7450 service_names: &["mssqlserver", "mssql$sqlexpress", "mssql$localdb"],
7451
7452 default_port: 1433,
7453 cli_name: "sqlcmd",
7454 cli_version_args: &["-?"],
7455 },
7456 DbEngine {
7457 name: "SQLite",
7458 service_names: &[], default_port: 0, cli_name: "sqlite3",
7462 cli_version_args: &["--version"],
7463 },
7464 DbEngine {
7465 name: "CouchDB",
7466 service_names: &["couchdb", "apache-couchdb"],
7467
7468 default_port: 5984,
7469 cli_name: "couchdb",
7470 cli_version_args: &["--version"],
7471 },
7472 DbEngine {
7473 name: "Cassandra",
7474 service_names: &["cassandra"],
7475
7476 default_port: 9042,
7477 cli_name: "cqlsh",
7478 cli_version_args: &["--version"],
7479 },
7480 DbEngine {
7481 name: "Elasticsearch",
7482 service_names: &["elasticsearch-service-x64", "elasticsearch"],
7483
7484 default_port: 9200,
7485 cli_name: "elasticsearch",
7486 cli_version_args: &["--version"],
7487 },
7488 ];
7489
7490 fn port_listening(port: u16) -> bool {
7492 if port == 0 {
7493 return false;
7494 }
7495 std::net::TcpStream::connect_timeout(
7497 &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
7498 std::time::Duration::from_millis(150),
7499 )
7500 .is_ok()
7501 }
7502
7503 let mut found_any = false;
7504
7505 for engine in engines {
7506 let mut status_parts: Vec<String> = Vec::new();
7507 let mut detected = false;
7508
7509 let version = Command::new(engine.cli_name)
7511 .args(engine.cli_version_args)
7512 .output()
7513 .ok()
7514 .and_then(|o| {
7515 let combined = if o.stdout.is_empty() {
7516 String::from_utf8_lossy(&o.stderr).trim().to_string()
7517 } else {
7518 String::from_utf8_lossy(&o.stdout).trim().to_string()
7519 };
7520 combined.lines().next().map(|l| l.trim().to_string())
7522 });
7523
7524 if let Some(ref ver) = version {
7525 if !ver.is_empty() {
7526 status_parts.push(format!("version: {ver}"));
7527 detected = true;
7528 }
7529 }
7530
7531 if engine.default_port > 0 && port_listening(engine.default_port) {
7533 status_parts.push(format!("listening on :{}", engine.default_port));
7534 detected = true;
7535 } else if engine.default_port > 0 && detected {
7536 status_parts.push(format!("not listening on :{}", engine.default_port));
7537 }
7538
7539 #[cfg(target_os = "windows")]
7541 {
7542 if !engine.service_names.is_empty() {
7543 let service_list = engine.service_names.join("','");
7544 let script = format!(
7545 r#"$names = @('{}'); foreach ($n in $names) {{ $s = Get-Service -Name $n -ErrorAction SilentlyContinue; if ($s) {{ $n + ':' + $s.Status; break }} }}"#,
7546 service_list
7547 );
7548 if let Ok(o) = Command::new("powershell")
7549 .args(["-NoProfile", "-Command", &script])
7550 .output()
7551 {
7552 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7553 if !text.is_empty() {
7554 let parts: Vec<&str> = text.splitn(2, ':').collect();
7555 let svc_name = parts.first().map(|s| s.trim()).unwrap_or("");
7556 let svc_state = parts.get(1).map(|s| s.trim()).unwrap_or("unknown");
7557 status_parts.push(format!("service '{svc_name}': {svc_state}"));
7558 detected = true;
7559 }
7560 }
7561 }
7562 }
7563
7564 #[cfg(not(target_os = "windows"))]
7566 {
7567 for svc in engine.service_names {
7568 if let Ok(o) = Command::new("systemctl").args(["is-active", svc]).output() {
7569 let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
7570 if !state.is_empty() && state != "inactive" {
7571 status_parts.push(format!("systemd '{svc}': {state}"));
7572 detected = true;
7573 break;
7574 }
7575 }
7576 }
7577 }
7578
7579 if detected {
7580 found_any = true;
7581 let label = if engine.default_port > 0 {
7582 format!("{} (default port: {})", engine.name, engine.default_port)
7583 } else {
7584 format!("{} (file-based, no port)", engine.name)
7585 };
7586 out.push_str(&format!("[FOUND] {label}\n"));
7587 for part in &status_parts {
7588 out.push_str(&format!(" {part}\n"));
7589 }
7590 out.push('\n');
7591 }
7592 }
7593
7594 if !found_any {
7595 out.push_str("No local database engines detected.\n");
7596 out.push_str("(Checked: PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQL Server, SQLite, CouchDB, Cassandra, Elasticsearch)\n\n");
7597 out.push_str(
7598 "Note: databases running inside Docker containers are listed under topic='docker'.\n",
7599 );
7600 } else {
7601 out.push_str("---\n");
7602 out.push_str(
7603 "Note: databases running inside Docker containers are listed under topic='docker'.\n",
7604 );
7605 out.push_str("This topic checks service state and port reachability only — no credentials or queries are used.\n");
7606 }
7607
7608 Ok(out.trim_end().to_string())
7609}
7610
7611fn inspect_user_accounts(max_entries: usize) -> Result<String, String> {
7614 let mut out = String::from("Host inspection: user_accounts\n\n");
7615
7616 #[cfg(target_os = "windows")]
7617 {
7618 let users_out = Command::new("powershell")
7619 .args([
7620 "-NoProfile", "-NonInteractive", "-Command",
7621 "Get-LocalUser | ForEach-Object { $logon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'never' }; \" $($_.Name) | Enabled: $($_.Enabled) | LastLogon: $logon | PwdRequired: $($_.PasswordRequired) | $($_.Description)\" }",
7622 ])
7623 .output()
7624 .ok()
7625 .and_then(|o| String::from_utf8(o.stdout).ok())
7626 .unwrap_or_default();
7627
7628 out.push_str("=== Local User Accounts ===\n");
7629 if users_out.trim().is_empty() {
7630 out.push_str(" (requires elevation or Get-LocalUser unavailable)\n");
7631 } else {
7632 for line in users_out.lines().take(max_entries) {
7633 if !line.trim().is_empty() {
7634 out.push_str(line);
7635 out.push('\n');
7636 }
7637 }
7638 }
7639
7640 let admins_out = Command::new("powershell")
7641 .args([
7642 "-NoProfile", "-NonInteractive", "-Command",
7643 "Get-LocalGroupMember -Group 'Administrators' 2>$null | ForEach-Object { \" $($_.ObjectClass): $($_.Name)\" }",
7644 ])
7645 .output()
7646 .ok()
7647 .and_then(|o| String::from_utf8(o.stdout).ok())
7648 .unwrap_or_default();
7649
7650 out.push_str("\n=== Administrators Group Members ===\n");
7651 if admins_out.trim().is_empty() {
7652 out.push_str(" (unable to retrieve)\n");
7653 } else {
7654 out.push_str(admins_out.trim());
7655 out.push('\n');
7656 }
7657
7658 let sessions_out = Command::new("powershell")
7659 .args([
7660 "-NoProfile",
7661 "-NonInteractive",
7662 "-Command",
7663 "query user 2>$null",
7664 ])
7665 .output()
7666 .ok()
7667 .and_then(|o| String::from_utf8(o.stdout).ok())
7668 .unwrap_or_default();
7669
7670 out.push_str("\n=== Active Logon Sessions ===\n");
7671 if sessions_out.trim().is_empty() {
7672 out.push_str(" (none or requires elevation)\n");
7673 } else {
7674 for line in sessions_out.lines().take(max_entries) {
7675 if !line.trim().is_empty() {
7676 out.push_str(&format!(" {}\n", line));
7677 }
7678 }
7679 }
7680
7681 let is_admin = Command::new("powershell")
7682 .args([
7683 "-NoProfile", "-NonInteractive", "-Command",
7684 "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
7685 ])
7686 .output()
7687 .ok()
7688 .and_then(|o| String::from_utf8(o.stdout).ok())
7689 .map(|s| s.trim().to_lowercase())
7690 .unwrap_or_default();
7691
7692 out.push_str("\n=== Current Session Elevation ===\n");
7693 out.push_str(&format!(
7694 " Running as Administrator: {}\n",
7695 if is_admin.contains("true") {
7696 "YES"
7697 } else {
7698 "no"
7699 }
7700 ));
7701 }
7702
7703 #[cfg(not(target_os = "windows"))]
7704 {
7705 let who_out = Command::new("who")
7706 .output()
7707 .ok()
7708 .and_then(|o| String::from_utf8(o.stdout).ok())
7709 .unwrap_or_default();
7710 out.push_str("=== Active Sessions ===\n");
7711 if who_out.trim().is_empty() {
7712 out.push_str(" (none)\n");
7713 } else {
7714 for line in who_out.lines().take(max_entries) {
7715 out.push_str(&format!(" {}\n", line));
7716 }
7717 }
7718 let id_out = Command::new("id")
7719 .output()
7720 .ok()
7721 .and_then(|o| String::from_utf8(o.stdout).ok())
7722 .unwrap_or_default();
7723 out.push_str(&format!("\n=== Current User ===\n {}\n", id_out.trim()));
7724 }
7725
7726 Ok(out.trim_end().to_string())
7727}
7728
7729fn inspect_audit_policy() -> Result<String, String> {
7732 let mut out = String::from("Host inspection: audit_policy\n\n");
7733
7734 #[cfg(target_os = "windows")]
7735 {
7736 let auditpol_out = Command::new("auditpol")
7737 .args(["/get", "/category:*"])
7738 .output()
7739 .ok()
7740 .and_then(|o| String::from_utf8(o.stdout).ok())
7741 .unwrap_or_default();
7742
7743 if auditpol_out.trim().is_empty()
7744 || auditpol_out.to_lowercase().contains("access is denied")
7745 {
7746 out.push_str("Audit policy requires Administrator elevation to read.\n");
7747 out.push_str(
7748 "Run Hematite as Administrator, or check manually: auditpol /get /category:*\n",
7749 );
7750 } else {
7751 out.push_str("=== Windows Audit Policy ===\n");
7752 let mut any_enabled = false;
7753 for line in auditpol_out.lines() {
7754 let trimmed = line.trim();
7755 if trimmed.is_empty() {
7756 continue;
7757 }
7758 if trimmed.contains("Success") || trimmed.contains("Failure") {
7759 out.push_str(&format!(" [ENABLED] {}\n", trimmed));
7760 any_enabled = true;
7761 } else {
7762 out.push_str(&format!(" {}\n", trimmed));
7763 }
7764 }
7765 if !any_enabled {
7766 out.push_str("\n[WARNING] No audit categories are enabled — security events will not be logged.\n");
7767 out.push_str(
7768 "Minimum recommended: enable Logon/Logoff and Account Logon success+failure.\n",
7769 );
7770 }
7771 }
7772
7773 let evtlog = Command::new("powershell")
7774 .args([
7775 "-NoProfile", "-NonInteractive", "-Command",
7776 "Get-Service EventLog -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Status",
7777 ])
7778 .output()
7779 .ok()
7780 .and_then(|o| String::from_utf8(o.stdout).ok())
7781 .map(|s| s.trim().to_string())
7782 .unwrap_or_default();
7783
7784 out.push_str(&format!(
7785 "\n=== Windows Event Log Service ===\n Status: {}\n",
7786 if evtlog.is_empty() {
7787 "unknown".to_string()
7788 } else {
7789 evtlog
7790 }
7791 ));
7792 }
7793
7794 #[cfg(not(target_os = "windows"))]
7795 {
7796 let auditd_status = Command::new("systemctl")
7797 .args(["is-active", "auditd"])
7798 .output()
7799 .ok()
7800 .and_then(|o| String::from_utf8(o.stdout).ok())
7801 .map(|s| s.trim().to_string())
7802 .unwrap_or_else(|| "not found".to_string());
7803
7804 out.push_str(&format!(
7805 "=== auditd service ===\n Status: {}\n",
7806 auditd_status
7807 ));
7808
7809 if auditd_status == "active" {
7810 let rules = Command::new("auditctl")
7811 .args(["-l"])
7812 .output()
7813 .ok()
7814 .and_then(|o| String::from_utf8(o.stdout).ok())
7815 .unwrap_or_default();
7816 out.push_str("\n=== Active Audit Rules ===\n");
7817 if rules.trim().is_empty() || rules.contains("No rules") {
7818 out.push_str(" No rules configured.\n");
7819 } else {
7820 for line in rules.lines() {
7821 out.push_str(&format!(" {}\n", line));
7822 }
7823 }
7824 }
7825 }
7826
7827 Ok(out.trim_end().to_string())
7828}
7829
7830fn inspect_shares(max_entries: usize) -> Result<String, String> {
7833 let mut out = String::from("Host inspection: shares\n\n");
7834
7835 #[cfg(target_os = "windows")]
7836 {
7837 let smb_out = Command::new("powershell")
7838 .args([
7839 "-NoProfile", "-NonInteractive", "-Command",
7840 "Get-SmbShare | ForEach-Object { \" $($_.Name) | Path: $($_.Path) | State: $($_.ShareState) | Encrypted: $($_.EncryptData) | $($_.Description)\" }",
7841 ])
7842 .output()
7843 .ok()
7844 .and_then(|o| String::from_utf8(o.stdout).ok())
7845 .unwrap_or_default();
7846
7847 out.push_str("=== SMB Shares (exposed by this machine) ===\n");
7848 let smb_lines: Vec<&str> = smb_out
7849 .lines()
7850 .filter(|l| !l.trim().is_empty())
7851 .take(max_entries)
7852 .collect();
7853 if smb_lines.is_empty() {
7854 out.push_str(" No SMB shares or unable to retrieve.\n");
7855 } else {
7856 for line in &smb_lines {
7857 let name = line.trim().split('|').next().unwrap_or("").trim();
7858 if name.ends_with('$') {
7859 out.push_str(&format!(" {}\n", line.trim()));
7860 } else {
7861 out.push_str(&format!(" [CUSTOM] {}\n", line.trim()));
7862 }
7863 }
7864 }
7865
7866 let smb_security = Command::new("powershell")
7867 .args([
7868 "-NoProfile", "-NonInteractive", "-Command",
7869 "Get-SmbServerConfiguration | ForEach-Object { \" SMB1: $($_.EnableSMB1Protocol) | SMB2: $($_.EnableSMB2Protocol) | Signing Required: $($_.RequireSecuritySignature) | Encryption: $($_.EncryptData)\" }",
7870 ])
7871 .output()
7872 .ok()
7873 .and_then(|o| String::from_utf8(o.stdout).ok())
7874 .unwrap_or_default();
7875
7876 out.push_str("\n=== SMB Server Security Settings ===\n");
7877 if smb_security.trim().is_empty() {
7878 out.push_str(" (unable to retrieve)\n");
7879 } else {
7880 out.push_str(smb_security.trim());
7881 out.push('\n');
7882 if smb_security.to_lowercase().contains("smb1: true") {
7883 out.push_str(" [WARNING] SMB1 is ENABLED — disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\n");
7884 }
7885 }
7886
7887 let drives_out = Command::new("powershell")
7888 .args([
7889 "-NoProfile", "-NonInteractive", "-Command",
7890 "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { \" $($_.Name): -> $($_.DisplayRoot)\" }",
7891 ])
7892 .output()
7893 .ok()
7894 .and_then(|o| String::from_utf8(o.stdout).ok())
7895 .unwrap_or_default();
7896
7897 out.push_str("\n=== Mapped Network Drives ===\n");
7898 if drives_out.trim().is_empty() {
7899 out.push_str(" None.\n");
7900 } else {
7901 for line in drives_out.lines().take(max_entries) {
7902 if !line.trim().is_empty() {
7903 out.push_str(line);
7904 out.push('\n');
7905 }
7906 }
7907 }
7908 }
7909
7910 #[cfg(not(target_os = "windows"))]
7911 {
7912 let smb_conf = std::fs::read_to_string("/etc/samba/smb.conf").unwrap_or_default();
7913 out.push_str("=== Samba Config (/etc/samba/smb.conf) ===\n");
7914 if smb_conf.is_empty() {
7915 out.push_str(" Not found or Samba not installed.\n");
7916 } else {
7917 for line in smb_conf.lines().take(max_entries) {
7918 out.push_str(&format!(" {}\n", line));
7919 }
7920 }
7921 let nfs_exports = std::fs::read_to_string("/etc/exports").unwrap_or_default();
7922 out.push_str("\n=== NFS Exports (/etc/exports) ===\n");
7923 if nfs_exports.is_empty() {
7924 out.push_str(" Not configured.\n");
7925 } else {
7926 for line in nfs_exports.lines().take(max_entries) {
7927 out.push_str(&format!(" {}\n", line));
7928 }
7929 }
7930 }
7931
7932 Ok(out.trim_end().to_string())
7933}
7934
7935fn inspect_dns_servers() -> Result<String, String> {
7938 let mut out = String::from("Host inspection: dns_servers\n\n");
7939
7940 #[cfg(target_os = "windows")]
7941 {
7942 let dns_out = Command::new("powershell")
7943 .args([
7944 "-NoProfile", "-NonInteractive", "-Command",
7945 "Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses.Count -gt 0 } | ForEach-Object { $addrs = $_.ServerAddresses -join ', '; \" $($_.InterfaceAlias) (AF $($_.AddressFamily)): $addrs\" }",
7946 ])
7947 .output()
7948 .ok()
7949 .and_then(|o| String::from_utf8(o.stdout).ok())
7950 .unwrap_or_default();
7951
7952 out.push_str("=== Configured DNS Resolvers (per adapter) ===\n");
7953 if dns_out.trim().is_empty() {
7954 out.push_str(" (unable to retrieve)\n");
7955 } else {
7956 for line in dns_out.lines() {
7957 if line.trim().is_empty() {
7958 continue;
7959 }
7960 let mut annotation = "";
7961 if line.contains("8.8.8.8") || line.contains("8.8.4.4") {
7962 annotation = " <- Google Public DNS";
7963 } else if line.contains("1.1.1.1") || line.contains("1.0.0.1") {
7964 annotation = " <- Cloudflare DNS";
7965 } else if line.contains("9.9.9.9") {
7966 annotation = " <- Quad9";
7967 } else if line.contains("208.67.222") || line.contains("208.67.220") {
7968 annotation = " <- OpenDNS";
7969 }
7970 out.push_str(line);
7971 out.push_str(annotation);
7972 out.push('\n');
7973 }
7974 }
7975
7976 let doh_out = Command::new("powershell")
7977 .args([
7978 "-NoProfile", "-NonInteractive", "-Command",
7979 "Get-DnsClientDohServerAddress 2>$null | ForEach-Object { \" $($_.ServerAddress): $($_.DohTemplate)\" }",
7980 ])
7981 .output()
7982 .ok()
7983 .and_then(|o| String::from_utf8(o.stdout).ok())
7984 .unwrap_or_default();
7985
7986 out.push_str("\n=== DNS over HTTPS (DoH) ===\n");
7987 if doh_out.trim().is_empty() {
7988 out.push_str(" Not configured (plain DNS).\n");
7989 } else {
7990 out.push_str(doh_out.trim());
7991 out.push('\n');
7992 }
7993
7994 let suffixes = Command::new("powershell")
7995 .args([
7996 "-NoProfile", "-NonInteractive", "-Command",
7997 "Get-DnsClientGlobalSetting | Select-Object -ExpandProperty SuffixSearchList | ForEach-Object { \" $_\" }",
7998 ])
7999 .output()
8000 .ok()
8001 .and_then(|o| String::from_utf8(o.stdout).ok())
8002 .unwrap_or_default();
8003
8004 if !suffixes.trim().is_empty() {
8005 out.push_str("\n=== DNS Search Suffix List ===\n");
8006 out.push_str(suffixes.trim());
8007 out.push('\n');
8008 }
8009 }
8010
8011 #[cfg(not(target_os = "windows"))]
8012 {
8013 let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
8014 out.push_str("=== /etc/resolv.conf ===\n");
8015 if resolv.is_empty() {
8016 out.push_str(" Not found.\n");
8017 } else {
8018 for line in resolv.lines() {
8019 if !line.trim().is_empty() && !line.starts_with('#') {
8020 out.push_str(&format!(" {}\n", line));
8021 }
8022 }
8023 }
8024 let resolved_out = Command::new("resolvectl")
8025 .args(["status", "--no-pager"])
8026 .output()
8027 .ok()
8028 .and_then(|o| String::from_utf8(o.stdout).ok())
8029 .unwrap_or_default();
8030 if !resolved_out.is_empty() {
8031 out.push_str("\n=== systemd-resolved ===\n");
8032 for line in resolved_out.lines().take(30) {
8033 out.push_str(&format!(" {}\n", line));
8034 }
8035 }
8036 }
8037
8038 Ok(out.trim_end().to_string())
8039}
8040
8041fn inspect_bitlocker() -> Result<String, String> {
8042 let mut out = String::from("Host inspection: bitlocker\n\n");
8043
8044 #[cfg(target_os = "windows")]
8045 {
8046 let ps_cmd = "Get-BitLockerVolume | Select-Object MountPoint, VolumeStatus, ProtectionStatus, EncryptionPercentage | ForEach-Object { \"$($_.MountPoint) [$($_.VolumeStatus)] Protection:$($_.ProtectionStatus) ($($_.EncryptionPercentage)%)\" }";
8047 let output = Command::new("powershell")
8048 .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
8049 .output()
8050 .map_err(|e| format!("Failed to execute PowerShell: {e}"))?;
8051
8052 let stdout = String::from_utf8(output.stdout).unwrap_or_default();
8053 let stderr = String::from_utf8(output.stderr).unwrap_or_default();
8054
8055 if !stdout.trim().is_empty() {
8056 out.push_str("=== BitLocker Volumes ===\n");
8057 for line in stdout.lines() {
8058 out.push_str(&format!(" {}\n", line));
8059 }
8060 } else if !stderr.trim().is_empty() {
8061 if stderr.contains("Access is denied") {
8062 out.push_str("Error: Access denied. BitLocker diagnostics require Administrator elevation.\n");
8063 } else {
8064 out.push_str(&format!(
8065 "Error retrieving BitLocker info: {}\n",
8066 stderr.trim()
8067 ));
8068 }
8069 } else {
8070 out.push_str("No BitLocker volumes detected or access denied.\n");
8071 }
8072 }
8073
8074 #[cfg(not(target_os = "windows"))]
8075 {
8076 out.push_str(
8077 "BitLocker is a Windows-specific technology. Checking for LUKS/dm-crypt...\n\n",
8078 );
8079 let lsblk = Command::new("lsblk")
8080 .args(["-f", "-o", "NAME,FSTYPE,MOUNTPOINT"])
8081 .output()
8082 .ok()
8083 .and_then(|o| String::from_utf8(o.stdout).ok())
8084 .unwrap_or_default();
8085 if lsblk.contains("crypto_LUKS") {
8086 out.push_str("=== LUKS Encrypted Volumes ===\n");
8087 for line in lsblk.lines().filter(|l| l.contains("crypto_LUKS")) {
8088 out.push_str(&format!(" {}\n", line));
8089 }
8090 } else {
8091 out.push_str("No LUKS encrypted volumes detected via lsblk.\n");
8092 }
8093 }
8094
8095 Ok(out.trim_end().to_string())
8096}
8097
8098fn inspect_rdp() -> Result<String, String> {
8099 let mut out = String::from("Host inspection: rdp\n\n");
8100
8101 #[cfg(target_os = "windows")]
8102 {
8103 let reg_path = "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server";
8104 let f_deny = Command::new("powershell")
8105 .args([
8106 "-NoProfile",
8107 "-Command",
8108 &format!("(Get-ItemProperty '{}').fDenyTSConnections", reg_path),
8109 ])
8110 .output()
8111 .ok()
8112 .and_then(|o| String::from_utf8(o.stdout).ok())
8113 .unwrap_or_default()
8114 .trim()
8115 .to_string();
8116
8117 let status = if f_deny == "0" { "ENABLED" } else { "DISABLED" };
8118 out.push_str(&format!("=== RDP Status: {} ===\n", status));
8119
8120 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"])
8121 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default().trim().to_string();
8122 out.push_str(&format!(
8123 " Port: {}\n",
8124 if port.is_empty() {
8125 "3389 (default)"
8126 } else {
8127 &port
8128 }
8129 ));
8130
8131 let nla = Command::new("powershell")
8132 .args([
8133 "-NoProfile",
8134 "-Command",
8135 &format!("(Get-ItemProperty '{}').UserAuthentication", reg_path),
8136 ])
8137 .output()
8138 .ok()
8139 .and_then(|o| String::from_utf8(o.stdout).ok())
8140 .unwrap_or_default()
8141 .trim()
8142 .to_string();
8143 out.push_str(&format!(
8144 " NLA Required: {}\n",
8145 if nla == "1" { "Yes" } else { "No" }
8146 ));
8147
8148 out.push_str("\n=== Active Sessions ===\n");
8149 let qwinsta = Command::new("qwinsta")
8150 .output()
8151 .ok()
8152 .and_then(|o| String::from_utf8(o.stdout).ok())
8153 .unwrap_or_default();
8154 if qwinsta.trim().is_empty() {
8155 out.push_str(" No active sessions listed.\n");
8156 } else {
8157 for line in qwinsta.lines() {
8158 out.push_str(&format!(" {}\n", line));
8159 }
8160 }
8161
8162 out.push_str("\n=== Firewall Rule Check ===\n");
8163 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))\" }"])
8164 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8165 if fw.trim().is_empty() {
8166 out.push_str(" No enabled RDP firewall rules found.\n");
8167 } else {
8168 out.push_str(fw.trim_end());
8169 out.push('\n');
8170 }
8171 }
8172
8173 #[cfg(not(target_os = "windows"))]
8174 {
8175 out.push_str("Checking for common RDP/VNC listeners (3389, 5900-5905)...\n");
8176 let ss = Command::new("ss")
8177 .args(["-tlnp"])
8178 .output()
8179 .ok()
8180 .and_then(|o| String::from_utf8(o.stdout).ok())
8181 .unwrap_or_default();
8182 let matches: Vec<&str> = ss
8183 .lines()
8184 .filter(|l| l.contains(":3389") || l.contains(":590"))
8185 .collect();
8186 if matches.is_empty() {
8187 out.push_str(" No RDP/VNC listeners detected via 'ss'.\n");
8188 } else {
8189 for m in matches {
8190 out.push_str(&format!(" {}\n", m));
8191 }
8192 }
8193 }
8194
8195 Ok(out.trim_end().to_string())
8196}
8197
8198fn inspect_shadow_copies() -> Result<String, String> {
8199 let mut out = String::from("Host inspection: shadow_copies\n\n");
8200
8201 #[cfg(target_os = "windows")]
8202 {
8203 let output = Command::new("vssadmin")
8204 .args(["list", "shadows"])
8205 .output()
8206 .map_err(|e| format!("Failed to run vssadmin: {e}"))?;
8207 let stdout = String::from_utf8(output.stdout).unwrap_or_default();
8208
8209 if stdout.contains("No items found") || stdout.trim().is_empty() {
8210 out.push_str("No Volume Shadow Copies found.\n");
8211 } else {
8212 out.push_str("=== Volume Shadow Copies ===\n");
8213 for line in stdout.lines().take(50) {
8214 if line.contains("Creation Time:")
8215 || line.contains("Contents:")
8216 || line.contains("Volume Name:")
8217 {
8218 out.push_str(&format!(" {}\n", line.trim()));
8219 }
8220 }
8221 }
8222
8223 out.push_str("\n=== Shadow Copy Storage ===\n");
8224 let storage_out = Command::new("vssadmin")
8225 .args(["list", "shadowstorage"])
8226 .output()
8227 .ok();
8228 if let Some(o) = storage_out {
8229 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8230 for line in stdout.lines() {
8231 if line.contains("Used Shadow Copy Storage space:")
8232 || line.contains("Max Shadow Copy Storage space:")
8233 {
8234 out.push_str(&format!(" {}\n", line.trim()));
8235 }
8236 }
8237 }
8238 }
8239
8240 #[cfg(not(target_os = "windows"))]
8241 {
8242 out.push_str("Checking for LVM snapshots or Btrfs subvolumes...\n\n");
8243 let lvs = Command::new("lvs")
8244 .output()
8245 .ok()
8246 .and_then(|o| String::from_utf8(o.stdout).ok())
8247 .unwrap_or_default();
8248 if !lvs.is_empty() {
8249 out.push_str("=== LVM Volumes (checking for snapshots) ===\n");
8250 out.push_str(&lvs);
8251 } else {
8252 out.push_str("No LVM volumes detected.\n");
8253 }
8254 }
8255
8256 Ok(out.trim_end().to_string())
8257}
8258
8259fn inspect_pagefile() -> Result<String, String> {
8260 let mut out = String::from("Host inspection: pagefile\n\n");
8261
8262 #[cfg(target_os = "windows")]
8263 {
8264 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)\" }";
8265 let output = Command::new("powershell")
8266 .args(["-NoProfile", "-Command", ps_cmd])
8267 .output()
8268 .ok()
8269 .and_then(|o| String::from_utf8(o.stdout).ok())
8270 .unwrap_or_default();
8271
8272 if output.trim().is_empty() {
8273 out.push_str("No page files detected (system may be running without a page file or managed differently).\n");
8274 let managed = Command::new("powershell")
8275 .args([
8276 "-NoProfile",
8277 "-Command",
8278 "(Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile",
8279 ])
8280 .output()
8281 .ok()
8282 .and_then(|o| String::from_utf8(o.stdout).ok())
8283 .unwrap_or_default()
8284 .trim()
8285 .to_string();
8286 out.push_str(&format!("Automatic Managed Pagefile: {}\n", managed));
8287 } else {
8288 out.push_str("=== Page File Usage ===\n");
8289 out.push_str(&output);
8290 }
8291 }
8292
8293 #[cfg(not(target_os = "windows"))]
8294 {
8295 out.push_str("=== Swap Usage (Linux/macOS) ===\n");
8296 let swap = Command::new("swapon")
8297 .args(["--show"])
8298 .output()
8299 .ok()
8300 .and_then(|o| String::from_utf8(o.stdout).ok())
8301 .unwrap_or_default();
8302 if swap.is_empty() {
8303 let free = Command::new("free")
8304 .args(["-h"])
8305 .output()
8306 .ok()
8307 .and_then(|o| String::from_utf8(o.stdout).ok())
8308 .unwrap_or_default();
8309 out.push_str(&free);
8310 } else {
8311 out.push_str(&swap);
8312 }
8313 }
8314
8315 Ok(out.trim_end().to_string())
8316}
8317
8318fn inspect_windows_features(max_entries: usize) -> Result<String, String> {
8319 let mut out = String::from("Host inspection: windows_features\n\n");
8320
8321 #[cfg(target_os = "windows")]
8322 {
8323 out.push_str("=== Quick Check: Notable Features ===\n");
8324 let quick_ps = "Get-WindowsOptionalFeature -Online | Where-Object { $_.FeatureName -match 'IIS|Hyper-V|VirtualMachinePlatform|Subsystem-Linux' -and $_.State -eq 'Enabled' } | Select-Object -ExpandProperty FeatureName";
8325 let output = Command::new("powershell")
8326 .args(["-NoProfile", "-Command", quick_ps])
8327 .output()
8328 .ok();
8329
8330 if let Some(o) = output {
8331 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8332 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
8333
8334 if !stdout.trim().is_empty() {
8335 for f in stdout.lines() {
8336 out.push_str(&format!(" [ENABLED] {}\n", f));
8337 }
8338 } else if stderr.contains("Access is denied") || stderr.contains("requires elevation") {
8339 out.push_str(" Error: Access denied. Listing Windows Features requires Administrator elevation.\n");
8340 } else if quick_ps.contains("-Online") && stdout.trim().is_empty() {
8341 out.push_str(
8342 " No major features (IIS, Hyper-V, WSL) appear enabled in the quick check.\n",
8343 );
8344 }
8345 }
8346
8347 out.push_str(&format!(
8348 "\n=== All Enabled Features (capped at {}) ===\n",
8349 max_entries
8350 ));
8351 let all_ps = format!("Get-WindowsOptionalFeature -Online | Where-Object {{$_.State -eq 'Enabled'}} | Select-Object -First {} -ExpandProperty FeatureName", max_entries);
8352 let all_out = Command::new("powershell")
8353 .args(["-NoProfile", "-Command", &all_ps])
8354 .output()
8355 .ok();
8356 if let Some(o) = all_out {
8357 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8358 if !stdout.trim().is_empty() {
8359 out.push_str(&stdout);
8360 }
8361 }
8362 }
8363
8364 #[cfg(not(target_os = "windows"))]
8365 {
8366 let _ = max_entries;
8367 out.push_str("Windows Optional Features are Windows-specific. On Linux, check your package manager.\n");
8368 }
8369
8370 Ok(out.trim_end().to_string())
8371}
8372
8373fn inspect_printers(max_entries: usize) -> Result<String, String> {
8374 let mut out = String::from("Host inspection: printers\n\n");
8375
8376 #[cfg(target_os = "windows")]
8377 {
8378 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)])
8379 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8380 if list.trim().is_empty() {
8381 out.push_str("No printers detected.\n");
8382 } else {
8383 out.push_str("=== Installed Printers ===\n");
8384 out.push_str(&list);
8385 }
8386
8387 let jobs = Command::new("powershell").args(["-NoProfile", "-Command", "Get-PrintJob | Select-Object PrinterName, ID, DocumentName, Status | ForEach-Object { \" [$($_.PrinterName)] Job $($_.ID): $($_.DocumentName) - $($_.Status)\" }"])
8388 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8389 if !jobs.trim().is_empty() {
8390 out.push_str("\n=== Active Print Jobs ===\n");
8391 out.push_str(&jobs);
8392 }
8393 }
8394
8395 #[cfg(not(target_os = "windows"))]
8396 {
8397 let _ = max_entries;
8398 out.push_str("Checking LPSTAT for printers...\n");
8399 let lpstat = Command::new("lpstat")
8400 .args(["-p", "-d"])
8401 .output()
8402 .ok()
8403 .and_then(|o| String::from_utf8(o.stdout).ok())
8404 .unwrap_or_default();
8405 if lpstat.is_empty() {
8406 out.push_str(" No CUPS/LP printers found.\n");
8407 } else {
8408 out.push_str(&lpstat);
8409 }
8410 }
8411
8412 Ok(out.trim_end().to_string())
8413}
8414
8415fn inspect_winrm() -> Result<String, String> {
8416 let mut out = String::from("Host inspection: winrm\n\n");
8417
8418 #[cfg(target_os = "windows")]
8419 {
8420 let svc = Command::new("powershell")
8421 .args(["-NoProfile", "-Command", "(Get-Service WinRM).Status"])
8422 .output()
8423 .ok()
8424 .and_then(|o| String::from_utf8(o.stdout).ok())
8425 .unwrap_or_default()
8426 .trim()
8427 .to_string();
8428 out.push_str(&format!(
8429 "WinRM Service Status: {}\n\n",
8430 if svc.is_empty() { "NOT_FOUND" } else { &svc }
8431 ));
8432
8433 out.push_str("=== WinRM Listeners ===\n");
8434 let output = Command::new("powershell")
8435 .args([
8436 "-NoProfile",
8437 "-Command",
8438 "winrm enumerate winrm/config/listener 2>$null",
8439 ])
8440 .output()
8441 .ok();
8442 if let Some(o) = output {
8443 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8444 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
8445
8446 if !stdout.trim().is_empty() {
8447 for line in stdout.lines() {
8448 if line.contains("Address =")
8449 || line.contains("Transport =")
8450 || line.contains("Port =")
8451 {
8452 out.push_str(&format!(" {}\n", line.trim()));
8453 }
8454 }
8455 } else if stderr.contains("Access is denied") {
8456 out.push_str(" Error: Access denied to WinRM configuration.\n");
8457 } else {
8458 out.push_str(" No listeners configured.\n");
8459 }
8460 }
8461
8462 out.push_str("\n=== PowerShell Remoting Test (Local) ===\n");
8463 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))\" }"])
8464 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8465 if test_out.trim().is_empty() {
8466 out.push_str(" WinRM not responding to local WS-Man requests.\n");
8467 } else {
8468 out.push_str(&test_out);
8469 }
8470 }
8471
8472 #[cfg(not(target_os = "windows"))]
8473 {
8474 out.push_str(
8475 "WinRM is primarily a Windows technology. Checking for listening port 5985/5986...\n",
8476 );
8477 let ss = Command::new("ss")
8478 .args(["-tln"])
8479 .output()
8480 .ok()
8481 .and_then(|o| String::from_utf8(o.stdout).ok())
8482 .unwrap_or_default();
8483 if ss.contains(":5985") || ss.contains(":5986") {
8484 out.push_str(" WinRM ports (5985/5986) are listening.\n");
8485 } else {
8486 out.push_str(" WinRM ports not detected.\n");
8487 }
8488 }
8489
8490 Ok(out.trim_end().to_string())
8491}
8492
8493fn inspect_network_stats(max_entries: usize) -> Result<String, String> {
8494 let mut out = String::from("Host inspection: network_stats\n\n");
8495
8496 #[cfg(target_os = "windows")]
8497 {
8498 let ps_cmd = format!(
8499 "$s1 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes; \
8500 Start-Sleep -Milliseconds 250; \
8501 $s2 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes, ReceivedPacketErrors, OutboundPacketErrors | Select-Object -First {}; \
8502 $s2 | ForEach-Object {{ \
8503 $name = $_.Name; \
8504 $prev = $s1 | Where-Object {{ $_.Name -eq $name }}; \
8505 if ($prev) {{ \
8506 $rb = ($_.ReceivedBytes - $prev.ReceivedBytes) / 0.25; \
8507 $sb = ($_.SentBytes - $prev.SentBytes) / 0.25; \
8508 $rmbps = [math]::Round(($rb * 8) / 1000000, 2); \
8509 $smbps = [math]::Round(($sb * 8) / 1000000, 2); \
8510 $tr = [math]::Round($_.ReceivedBytes / 1MB, 2); \
8511 $tt = [math]::Round($_.SentBytes / 1MB, 2); \
8512 \" $($name): Rate(RX/TX): $($rmbps)/$($smbps) Mbps | Total: $($tr)/$($tt) MB | Errors: $($_.ReceivedPacketErrors)/$($_.OutboundPacketErrors)\" \
8513 }} \
8514 }}",
8515 max_entries
8516 );
8517 let output = Command::new("powershell")
8518 .args(["-NoProfile", "-Command", &ps_cmd])
8519 .output()
8520 .ok()
8521 .and_then(|o| String::from_utf8(o.stdout).ok())
8522 .unwrap_or_default();
8523 if output.trim().is_empty() {
8524 out.push_str("No network adapter statistics available.\n");
8525 } else {
8526 out.push_str("=== Adapter Throughput (Mbps) & Health ===\n");
8527 out.push_str(&output);
8528 }
8529
8530 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)\" } }"])
8531 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8532 if !discards.trim().is_empty() {
8533 out.push_str("\n=== Packet Discards (Non-Zero Only) ===\n");
8534 out.push_str(&discards);
8535 }
8536 }
8537
8538 #[cfg(not(target_os = "windows"))]
8539 {
8540 let _ = max_entries;
8541 out.push_str("=== Network Stats (ip -s link) ===\n");
8542 let ip_s = Command::new("ip")
8543 .args(["-s", "link"])
8544 .output()
8545 .ok()
8546 .and_then(|o| String::from_utf8(o.stdout).ok())
8547 .unwrap_or_default();
8548 if ip_s.is_empty() {
8549 let netstat = Command::new("netstat")
8550 .args(["-i"])
8551 .output()
8552 .ok()
8553 .and_then(|o| String::from_utf8(o.stdout).ok())
8554 .unwrap_or_default();
8555 out.push_str(&netstat);
8556 } else {
8557 out.push_str(&ip_s);
8558 }
8559 }
8560
8561 Ok(out.trim_end().to_string())
8562}
8563
8564fn inspect_udp_ports(max_entries: usize) -> Result<String, String> {
8565 let mut out = String::from("Host inspection: udp_ports\n\n");
8566
8567 #[cfg(target_os = "windows")]
8568 {
8569 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);
8570 let output = Command::new("powershell")
8571 .args(["-NoProfile", "-Command", &ps_cmd])
8572 .output()
8573 .ok();
8574
8575 if let Some(o) = output {
8576 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8577 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
8578
8579 if !stdout.trim().is_empty() {
8580 out.push_str("=== UDP Listeners (Local:Port) ===\n");
8581 for line in stdout.lines() {
8582 let mut note = "";
8583 if line.contains(":53 ") {
8584 note = " [DNS]";
8585 } else if line.contains(":67 ") || line.contains(":68 ") {
8586 note = " [DHCP]";
8587 } else if line.contains(":123 ") {
8588 note = " [NTP]";
8589 } else if line.contains(":161 ") {
8590 note = " [SNMP]";
8591 } else if line.contains(":1900 ") {
8592 note = " [SSDP/UPnP]";
8593 } else if line.contains(":5353 ") {
8594 note = " [mDNS]";
8595 }
8596
8597 out.push_str(&format!("{}{}\n", line, note));
8598 }
8599 } else if stderr.contains("Access is denied") {
8600 out.push_str("Error: Access denied. Full UDP listener details require Administrator elevation.\n");
8601 } else {
8602 out.push_str("No UDP listeners detected.\n");
8603 }
8604 }
8605 }
8606
8607 #[cfg(not(target_os = "windows"))]
8608 {
8609 let ss_out = Command::new("ss")
8610 .args(["-ulnp"])
8611 .output()
8612 .ok()
8613 .and_then(|o| String::from_utf8(o.stdout).ok())
8614 .unwrap_or_default();
8615 out.push_str("=== UDP Listeners (ss -ulnp) ===\n");
8616 if ss_out.is_empty() {
8617 let netstat_out = Command::new("netstat")
8618 .args(["-ulnp"])
8619 .output()
8620 .ok()
8621 .and_then(|o| String::from_utf8(o.stdout).ok())
8622 .unwrap_or_default();
8623 if netstat_out.is_empty() {
8624 out.push_str(" Neither 'ss' nor 'netstat' available.\n");
8625 } else {
8626 for line in netstat_out.lines().take(max_entries) {
8627 out.push_str(&format!(" {}\n", line));
8628 }
8629 }
8630 } else {
8631 for line in ss_out.lines().take(max_entries) {
8632 out.push_str(&format!(" {}\n", line));
8633 }
8634 }
8635 }
8636
8637 Ok(out.trim_end().to_string())
8638}
8639
8640fn inspect_gpo() -> Result<String, String> {
8641 let mut out = String::from("Host inspection: gpo\n\n");
8642
8643 #[cfg(target_os = "windows")]
8644 {
8645 let output = Command::new("gpresult")
8646 .args(["/r", "/scope", "computer"])
8647 .output()
8648 .ok();
8649
8650 if let Some(o) = output {
8651 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8652 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
8653
8654 if stdout.contains("Applied Group Policy Objects") {
8655 out.push_str("=== Applied Group Policy Objects (Computer Scope) ===\n");
8656 let mut capture = false;
8657 for line in stdout.lines() {
8658 if line.contains("Applied Group Policy Objects") {
8659 capture = true;
8660 } else if capture && line.contains("The following GPOs were not applied") {
8661 break;
8662 }
8663 if capture && !line.trim().is_empty() {
8664 out.push_str(&format!(" {}\n", line.trim()));
8665 }
8666 }
8667 } else if stderr.contains("Access is denied") || stdout.contains("Access is denied") {
8668 out.push_str("Error: Access denied. Group Policy inspection requires Administrator elevation.\n");
8669 } else {
8670 out.push_str("No applied Group Policy Objects detected or insufficient permissions to query computer scope.\n");
8671 }
8672 }
8673 }
8674
8675 #[cfg(not(target_os = "windows"))]
8676 {
8677 out.push_str("Group Policy (GPO) is a Windows-only topic.\n");
8678 }
8679
8680 Ok(out.trim_end().to_string())
8681}
8682
8683fn inspect_certificates(max_entries: usize) -> Result<String, String> {
8684 let mut out = String::from("Host inspection: certificates\n\n");
8685
8686 #[cfg(target_os = "windows")]
8687 {
8688 let ps_cmd = format!(
8689 "Get-ChildItem -Path Cert:\\LocalMachine\\My | Select-Object Subject, NotAfter, Thumbprint | Select-Object -First {} | ForEach-Object {{ \
8690 $days = ($_.NotAfter - (Get-Date)).Days; \
8691 $status = if ($days -lt 0) {{ \"[EXPIRED]\" }} else if ($days -lt 30) {{ \"[EXPIRING SOON ($days days)]\" }} else {{ \"\" }}; \
8692 \" $($_.Subject) - Expires: $($_.NotAfter.ToString('yyyy-MM-dd')) $status (Thumb: $($_.Thumbprint.Substring(0,8))...)\" \
8693 }}",
8694 max_entries
8695 );
8696 let output = Command::new("powershell")
8697 .args(["-NoProfile", "-Command", &ps_cmd])
8698 .output()
8699 .ok();
8700
8701 if let Some(o) = output {
8702 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8703 if !stdout.trim().is_empty() {
8704 out.push_str("=== Local Machine Certificates (Personal Store) ===\n");
8705 out.push_str(&stdout);
8706 } else {
8707 out.push_str("No certificates found in the Local Machine Personal store.\n");
8708 }
8709 }
8710 }
8711
8712 #[cfg(not(target_os = "windows"))]
8713 {
8714 let _ = max_entries;
8715 out.push_str("Host inspection: certificates (Linux/macOS)\n\n");
8716 for path in ["/etc/ssl/certs", "/etc/pki/tls/certs"] {
8718 if Path::new(path).exists() {
8719 out.push_str(&format!(" Cert directory found: {}\n", path));
8720 }
8721 }
8722 }
8723
8724 Ok(out.trim_end().to_string())
8725}
8726
8727fn inspect_integrity() -> Result<String, String> {
8728 let mut out = String::from("Host inspection: integrity\n\n");
8729
8730 #[cfg(target_os = "windows")]
8731 {
8732 let ps_cmd = "Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing' | Select-Object Corrupt, AutoRepairNeeded, LastRepairAttempted | ConvertTo-Json";
8733 let output = Command::new("powershell")
8734 .args(["-NoProfile", "-Command", &ps_cmd])
8735 .output()
8736 .ok();
8737
8738 if let Some(o) = output {
8739 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8740 if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
8741 out.push_str("=== Windows Component Store Health (CBS) ===\n");
8742 let corrupt = val.get("Corrupt").and_then(|v| v.as_u64()).unwrap_or(0);
8743 let repair = val
8744 .get("AutoRepairNeeded")
8745 .and_then(|v| v.as_u64())
8746 .unwrap_or(0);
8747
8748 out.push_str(&format!(
8749 " Corruption Detected: {}\n",
8750 if corrupt != 0 {
8751 "YES (SFC/DISM recommended)"
8752 } else {
8753 "No"
8754 }
8755 ));
8756 out.push_str(&format!(
8757 " Auto-Repair Needed: {}\n",
8758 if repair != 0 { "YES" } else { "No" }
8759 ));
8760
8761 if let Some(last) = val.get("LastRepairAttempted").and_then(|v| v.as_u64()) {
8762 out.push_str(&format!(" Last Repair Attempt: (Raw code: {})\n", last));
8763 }
8764 } else {
8765 out.push_str("Could not retrieve CBS health from registry. System may be healthy or state is unknown.\n");
8766 }
8767 }
8768
8769 if Path::new("C:\\Windows\\Logs\\CBS\\CBS.log").exists() {
8770 out.push_str(
8771 "\nNote: Detailed integrity logs available at C:\\Windows\\Logs\\CBS\\CBS.log\n",
8772 );
8773 }
8774 }
8775
8776 #[cfg(not(target_os = "windows"))]
8777 {
8778 out.push_str("System integrity check (Linux)\n\n");
8779 let pkg_check = Command::new("rpm")
8780 .args(["-Va"])
8781 .output()
8782 .or_else(|_| Command::new("dpkg").args(["--verify"]).output())
8783 .ok();
8784 if let Some(o) = pkg_check {
8785 out.push_str(" Package verification system active.\n");
8786 if o.status.success() {
8787 out.push_str(" No major package integrity issues detected.\n");
8788 }
8789 }
8790 }
8791
8792 Ok(out.trim_end().to_string())
8793}
8794
8795fn inspect_domain() -> Result<String, String> {
8796 let mut out = String::from("Host inspection: domain\n\n");
8797
8798 #[cfg(target_os = "windows")]
8799 {
8800 let ps_cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Name, Domain, PartOfDomain, Workgroup | ConvertTo-Json";
8801 let output = Command::new("powershell")
8802 .args(["-NoProfile", "-Command", &ps_cmd])
8803 .output()
8804 .ok();
8805
8806 if let Some(o) = output {
8807 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8808 if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
8809 let part_of_domain = val
8810 .get("PartOfDomain")
8811 .and_then(|v| v.as_bool())
8812 .unwrap_or(false);
8813 let domain = val
8814 .get("Domain")
8815 .and_then(|v| v.as_str())
8816 .unwrap_or("Unknown");
8817 let workgroup = val
8818 .get("Workgroup")
8819 .and_then(|v| v.as_str())
8820 .unwrap_or("Unknown");
8821
8822 out.push_str("=== Windows Domain / Workgroup Identity ===\n");
8823 out.push_str(&format!(
8824 " Join Status: {}\n",
8825 if part_of_domain {
8826 "DOMAIN JOINED"
8827 } else {
8828 "WORKGROUP"
8829 }
8830 ));
8831 if part_of_domain {
8832 out.push_str(&format!(" Active Directory Domain: {}\n", domain));
8833 } else {
8834 out.push_str(&format!(" Workgroup Name: {}\n", workgroup));
8835 }
8836
8837 if let Some(name) = val.get("Name").and_then(|v| v.as_str()) {
8838 out.push_str(&format!(" NetBIOS Name: {}\n", name));
8839 }
8840 }
8841 }
8842 }
8843
8844 #[cfg(not(target_os = "windows"))]
8845 {
8846 let domainname = Command::new("domainname")
8847 .output()
8848 .ok()
8849 .and_then(|o| String::from_utf8(o.stdout).ok())
8850 .unwrap_or_default();
8851 out.push_str("=== Linux Domain Identity ===\n");
8852 if !domainname.trim().is_empty() && domainname.trim() != "(none)" {
8853 out.push_str(&format!(" NIS/YP Domain: {}\n", domainname.trim()));
8854 } else {
8855 out.push_str(" No NIS domain configured.\n");
8856 }
8857 }
8858
8859 Ok(out.trim_end().to_string())
8860}
8861
8862fn inspect_device_health() -> Result<String, String> {
8863 let mut out = String::from("Host inspection: device_health\n\n");
8864
8865 #[cfg(target_os = "windows")]
8866 {
8867 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)\" }";
8868 let output = Command::new("powershell")
8869 .args(["-NoProfile", "-Command", ps_cmd])
8870 .output()
8871 .ok()
8872 .and_then(|o| String::from_utf8(o.stdout).ok())
8873 .unwrap_or_default();
8874
8875 if output.trim().is_empty() {
8876 out.push_str("All PnP devices report as healthy (no ConfigManager errors detected).\n");
8877 } else {
8878 out.push_str("=== Malfunctioning Devices (Yellow Bangs) ===\n");
8879 out.push_str(&output);
8880 out.push_str(
8881 "\nTip: Error codes 10 and 28 usually indicate missing or incompatible drivers.\n",
8882 );
8883 }
8884 }
8885
8886 #[cfg(not(target_os = "windows"))]
8887 {
8888 out.push_str("Checking dmesg for hardware errors...\n");
8889 let dmesg = Command::new("dmesg")
8890 .args(["--level=err,crit,alert"])
8891 .output()
8892 .ok()
8893 .and_then(|o| String::from_utf8(o.stdout).ok())
8894 .unwrap_or_default();
8895 if dmesg.is_empty() {
8896 out.push_str(" No critical hardware errors found in dmesg.\n");
8897 } else {
8898 out.push_str(&dmesg.lines().take(20).collect::<Vec<_>>().join("\n"));
8899 }
8900 }
8901
8902 Ok(out.trim_end().to_string())
8903}
8904
8905fn inspect_drivers(max_entries: usize) -> Result<String, String> {
8906 let mut out = String::from("Host inspection: drivers\n\n");
8907
8908 #[cfg(target_os = "windows")]
8909 {
8910 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);
8911 let output = Command::new("powershell")
8912 .args(["-NoProfile", "-Command", &ps_cmd])
8913 .output()
8914 .ok()
8915 .and_then(|o| String::from_utf8(o.stdout).ok())
8916 .unwrap_or_default();
8917
8918 if output.trim().is_empty() {
8919 out.push_str("No drivers retrieved via WMI.\n");
8920 } else {
8921 out.push_str("=== Active System Drivers (CIM Snapshot) ===\n");
8922 out.push_str(&output);
8923 }
8924 }
8925
8926 #[cfg(not(target_os = "windows"))]
8927 {
8928 out.push_str("=== Loaded Kernel Modules (lsmod) ===\n");
8929 let lsmod = Command::new("lsmod")
8930 .output()
8931 .ok()
8932 .and_then(|o| String::from_utf8(o.stdout).ok())
8933 .unwrap_or_default();
8934 out.push_str(
8935 &lsmod
8936 .lines()
8937 .take(max_entries)
8938 .collect::<Vec<_>>()
8939 .join("\n"),
8940 );
8941 }
8942
8943 Ok(out.trim_end().to_string())
8944}
8945
8946fn inspect_peripherals(max_entries: usize) -> Result<String, String> {
8947 let mut out = String::from("Host inspection: peripherals\n\n");
8948
8949 #[cfg(target_os = "windows")]
8950 {
8951 let _ = max_entries;
8952 out.push_str("=== USB Controllers & Hubs ===\n");
8953 let usb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_USBController | ForEach-Object { \" $($_.Name) ($($_.Status))\" }"])
8954 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8955 out.push_str(if usb.is_empty() {
8956 " None detected.\n"
8957 } else {
8958 &usb
8959 });
8960
8961 out.push_str("\n=== Input Devices (Keyboard/Pointer) ===\n");
8962 let kb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_Keyboard | ForEach-Object { \" [KB] $($_.Name) ($($_.Status))\" }"])
8963 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8964 let mouse = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_PointingDevice | ForEach-Object { \" [PTR] $($_.Name) ($($_.Status))\" }"])
8965 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8966 out.push_str(&kb);
8967 out.push_str(&mouse);
8968
8969 out.push_str("\n=== Connected Monitors (WMI) ===\n");
8970 let mon = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams | ForEach-Object { \" Display ($($_.Active ? 'Active' : 'Inactive'))\" }"])
8971 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8972 out.push_str(if mon.is_empty() {
8973 " No active monitors identified via WMI.\n"
8974 } else {
8975 &mon
8976 });
8977 }
8978
8979 #[cfg(not(target_os = "windows"))]
8980 {
8981 out.push_str("=== Connected USB Devices (lsusb) ===\n");
8982 let lsusb = Command::new("lsusb")
8983 .output()
8984 .ok()
8985 .and_then(|o| String::from_utf8(o.stdout).ok())
8986 .unwrap_or_default();
8987 out.push_str(
8988 &lsusb
8989 .lines()
8990 .take(max_entries)
8991 .collect::<Vec<_>>()
8992 .join("\n"),
8993 );
8994 }
8995
8996 Ok(out.trim_end().to_string())
8997}
8998
8999fn inspect_sessions(max_entries: usize) -> Result<String, String> {
9000 let mut out = String::from("Host inspection: sessions\n\n");
9001
9002 #[cfg(target_os = "windows")]
9003 {
9004 let script = r#"Get-CimInstance Win32_LogonSession | ForEach-Object {
9005 "$($_.LogonId)|$($_.StartTime)|$($_.LogonType)|$($_.AuthenticationPackage)"
9006}"#;
9007 if let Ok(o) = Command::new("powershell")
9008 .args(["-NoProfile", "-Command", script])
9009 .output()
9010 {
9011 let text = String::from_utf8_lossy(&o.stdout);
9012 let lines: Vec<&str> = text.lines().collect();
9013 if lines.is_empty() {
9014 out.push_str("No active logon sessions enumerated via WMI.\n");
9015 } else {
9016 out.push_str("=== Active Logon Sessions (WMI Snapshot) ===\n");
9017 for line in lines
9018 .iter()
9019 .take(max_entries)
9020 .filter(|l| !l.trim().is_empty())
9021 {
9022 let parts: Vec<&str> = line.trim().split('|').collect();
9023 if parts.len() == 4 {
9024 let logon_type = match parts[2] {
9025 "2" => "Interactive",
9026 "3" => "Network",
9027 "4" => "Batch",
9028 "5" => "Service",
9029 "7" => "Unlock",
9030 "8" => "NetworkCleartext",
9031 "9" => "NewCredentials",
9032 "10" => "RemoteInteractive",
9033 "11" => "CachedInteractive",
9034 _ => "Other",
9035 };
9036 out.push_str(&format!(
9037 "- ID: {} | Type: {} | Started: {} | Auth: {}\n",
9038 parts[0], logon_type, parts[1], parts[3]
9039 ));
9040 }
9041 }
9042 }
9043 }
9044 }
9045
9046 #[cfg(not(target_os = "windows"))]
9047 {
9048 out.push_str("=== Logged-in Users (who) ===\n");
9049 let who = Command::new("who")
9050 .output()
9051 .ok()
9052 .and_then(|o| String::from_utf8(o.stdout).ok())
9053 .unwrap_or_default();
9054 out.push_str(&who.lines().take(max_entries).collect::<Vec<_>>().join("\n"));
9055 }
9056
9057 Ok(out.trim_end().to_string())
9058}
9059
9060async fn inspect_disk_benchmark(path: PathBuf) -> Result<String, String> {
9061 let mut out = String::from("Host inspection: disk_benchmark\n\n");
9062 let mut final_path = path;
9063
9064 if !final_path.exists() {
9065 if let Ok(current_exe) = std::env::current_exe() {
9066 out.push_str(&format!(
9067 "Note: Requested target '{}' not found. Falling back to current binary for silicon-aware intensity report.\n",
9068 final_path.display()
9069 ));
9070 final_path = current_exe;
9071 } else {
9072 return Err(format!("Target not found: {}", final_path.display()));
9073 }
9074 }
9075
9076 let target = if final_path.is_dir() {
9077 let mut target_file = final_path.join("Cargo.toml");
9079 if !target_file.exists() {
9080 target_file = final_path.join("README.md");
9081 }
9082 if !target_file.exists() {
9083 return Err("Target path is a directory but no representative file (Cargo.toml/README.md) found for benchmarking.".to_string());
9084 }
9085 target_file
9086 } else {
9087 final_path
9088 };
9089
9090 out.push_str(&format!("Target: {}\n", target.display()));
9091 out.push_str("Running diagnostic stress test (5s read-thrash + kernel counter trace)...\n\n");
9092
9093 #[cfg(target_os = "windows")]
9094 {
9095 let script = format!(
9096 r#"
9097$target = "{}"
9098if (-not (Test-Path $target)) {{ "ERROR:Target not found"; exit }}
9099
9100$diskQueue = @()
9101$readStats = @()
9102$startTime = Get-Date
9103$duration = 5
9104
9105# Background reader job
9106$job = Start-Job -ScriptBlock {{
9107 param($t, $d)
9108 $stop = (Get-Date).AddSeconds($d)
9109 while ((Get-Date) -lt $stop) {{
9110 try {{ [System.IO.File]::ReadAllBytes($t) | Out-Null }} catch {{ }}
9111 }}
9112}} -ArgumentList $target, $duration
9113
9114# Metrics collector loop
9115$stopTime = (Get-Date).AddSeconds($duration)
9116while ((Get-Date) -lt $stopTime) {{
9117 $q = Get-Counter '\PhysicalDisk(_Total)\Avg. Disk Queue Length' -ErrorAction SilentlyContinue
9118 if ($q) {{ $diskQueue += $q.CounterSamples[0].CookedValue }}
9119
9120 $r = Get-Counter '\PhysicalDisk(_Total)\Disk Reads/sec' -ErrorAction SilentlyContinue
9121 if ($r) {{ $readStats += $r.CounterSamples[0].CookedValue }}
9122
9123 Start-Sleep -Milliseconds 250
9124}}
9125
9126Stop-Job $job
9127Receive-Job $job | Out-Null
9128Remove-Job $job
9129
9130$avgQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Average).Average }} else {{ 0 }}
9131$maxQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Maximum).Maximum }} else {{ 0 }}
9132$avgR = if ($readStats) {{ ($readStats | Measure-Object -Average).Average }} else {{ 0 }}
9133
9134"AVG_Q:$([math]::Round($avgQ, 4))|MAX_Q:$([math]::Round($maxQ, 4))|AVG_R:$([math]::Round($avgR, 2))"
9135"#,
9136 target.display()
9137 );
9138
9139 let output = Command::new("powershell")
9140 .args(["-NoProfile", "-Command", &script])
9141 .output()
9142 .map_err(|e| format!("Benchmark failed: {e}"))?;
9143
9144 let raw = String::from_utf8_lossy(&output.stdout);
9145 let text = raw.trim();
9146
9147 if text.starts_with("ERROR") {
9148 return Err(text.to_string());
9149 }
9150
9151 let mut lines = text.lines();
9152 if let Some(metrics_line) = lines.next() {
9153 let parts: Vec<&str> = metrics_line.split('|').collect();
9154 let mut avg_q = "unknown".to_string();
9155 let mut max_q = "unknown".to_string();
9156 let mut avg_r = "unknown".to_string();
9157
9158 for p in parts {
9159 if let Some((k, v)) = p.split_once(':') {
9160 match k {
9161 "AVG_Q" => avg_q = v.to_string(),
9162 "MAX_Q" => max_q = v.to_string(),
9163 "AVG_R" => avg_r = v.to_string(),
9164 _ => {}
9165 }
9166 }
9167 }
9168
9169 out.push_str("=== WORKSTATION INTENSITY REPORT ===\n");
9170 out.push_str(&format!("- Active Disk Queue (Avg): {}\n", avg_q));
9171 out.push_str(&format!("- Active Disk Queue (Max): {}\n", max_q));
9172 out.push_str(&format!("- Disk Throughput (Avg): {} reads/sec\n", avg_r));
9173 out.push_str("\nVerdict: ");
9174 let q_num = avg_q.parse::<f64>().unwrap_or(0.0);
9175 if q_num > 1.0 {
9176 out.push_str(
9177 "HIGH INTENSITY — the disk stack is saturated. Hardware bottleneck confirmed.",
9178 );
9179 } else if q_num > 0.1 {
9180 out.push_str("MODERATE LOAD — significant I/O pressure detected.");
9181 } else {
9182 out.push_str("LIGHT LOAD — the hardware is handling this volume comfortably.");
9183 }
9184 }
9185 }
9186
9187 #[cfg(not(target_os = "windows"))]
9188 {
9189 out.push_str("Note: Native silicon benchmarking is currently optimized for Windows performance counters.\n");
9190 out.push_str("Generic disk load simulated.\n");
9191 }
9192
9193 Ok(out)
9194}
9195
9196fn inspect_permissions(path: PathBuf, _max_entries: usize) -> Result<String, String> {
9197 let mut out = String::from("Host inspection: permissions\n\n");
9198 out.push_str(&format!(
9199 "Auditing access control for: {}\n\n",
9200 path.display()
9201 ));
9202
9203 #[cfg(target_os = "windows")]
9204 {
9205 let script = format!(
9206 "Get-Acl -Path '{}' | Select-Object Owner, AccessToString | ForEach-Object {{ \"OWNER:$($_.Owner)\"; \"RULES:$($_.AccessToString)\" }}",
9207 path.display()
9208 );
9209 let output = Command::new("powershell")
9210 .args(["-NoProfile", "-Command", &script])
9211 .output()
9212 .map_err(|e| format!("ACL check failed: {e}"))?;
9213
9214 let text = String::from_utf8_lossy(&output.stdout);
9215 if text.trim().is_empty() {
9216 out.push_str("No ACL information returned. Ensure the path exists and you have permission to query it.\n");
9217 } else {
9218 out.push_str("=== Windows NTFS Permissions ===\n");
9219 out.push_str(&text);
9220 }
9221 }
9222
9223 #[cfg(not(target_os = "windows"))]
9224 {
9225 let output = Command::new("ls")
9226 .args(["-ld", &path.to_string_lossy()])
9227 .output()
9228 .map_err(|e| format!("ls check failed: {e}"))?;
9229 out.push_str("=== Unix File Permissions ===\n");
9230 out.push_str(&String::from_utf8_lossy(&output.stdout));
9231 }
9232
9233 Ok(out.trim_end().to_string())
9234}
9235
9236fn inspect_login_history(max_entries: usize) -> Result<String, String> {
9237 let mut out = String::from("Host inspection: login_history\n\n");
9238
9239 #[cfg(target_os = "windows")]
9240 {
9241 out.push_str("Checking recent Logon events (Event ID 4624) from the Security Log...\n");
9242 out.push_str("Note: This typically requires Administrator elevation.\n\n");
9243
9244 let n = max_entries.clamp(1, 50);
9245 let script = format!(
9246 r#"try {{
9247 $events = Get-WinEvent -FilterHashtable @{{LogName='Security'; ID=4624}} -MaxEvents {n} -ErrorAction Stop
9248 $events | ForEach-Object {{
9249 $time = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm')
9250 # Extract target user name from the XML/Properties if possible
9251 $user = $_.Properties[5].Value
9252 $type = $_.Properties[8].Value
9253 "[$time] User: $user | Type: $type"
9254 }}
9255}} catch {{ "ERROR:" + $_.Exception.Message }}"#
9256 );
9257
9258 let output = Command::new("powershell")
9259 .args(["-NoProfile", "-Command", &script])
9260 .output()
9261 .map_err(|e| format!("Login history query failed: {e}"))?;
9262
9263 let text = String::from_utf8_lossy(&output.stdout);
9264 if text.starts_with("ERROR:") {
9265 out.push_str(&format!("Unable to query Security Log: {}\n", text));
9266 } else if text.trim().is_empty() {
9267 out.push_str("No recent logon events found or access denied.\n");
9268 } else {
9269 out.push_str("=== Recent Logons (Event ID 4624) ===\n");
9270 out.push_str(&text);
9271 }
9272 }
9273
9274 #[cfg(not(target_os = "windows"))]
9275 {
9276 let output = Command::new("last")
9277 .args(["-n", &max_entries.to_string()])
9278 .output()
9279 .map_err(|e| format!("last command failed: {e}"))?;
9280 out.push_str("=== Unix Login History (last) ===\n");
9281 out.push_str(&String::from_utf8_lossy(&output.stdout));
9282 }
9283
9284 Ok(out.trim_end().to_string())
9285}
9286
9287fn inspect_share_access(path: PathBuf) -> Result<String, String> {
9288 let mut out = String::from("Host inspection: share_access\n\n");
9289 out.push_str(&format!("Testing accessibility of: {}\n\n", path.display()));
9290
9291 #[cfg(target_os = "windows")]
9292 {
9293 let script = format!(
9294 r#"
9295$p = '{}'
9296$res = @{{ Reachable = $false; Readable = $false; Error = "" }}
9297if (Test-Connection -ComputerName ($p.Split('\')[2]) -Count 1 -Quiet -ErrorAction SilentlyContinue) {{
9298 $res.Reachable = $true
9299 try {{
9300 $null = Get-ChildItem -Path $p -ErrorAction Stop
9301 $res.Readable = $true
9302 }} catch {{
9303 $res.Error = $_.Exception.Message
9304 }}
9305}} else {{
9306 $res.Error = "Server unreachable (Ping failed)"
9307}}
9308"REACHABLE:$($res.Reachable)|READABLE:$($res.Readable)|ERROR:$($res.Error)""#,
9309 path.display()
9310 );
9311
9312 let output = Command::new("powershell")
9313 .args(["-NoProfile", "-Command", &script])
9314 .output()
9315 .map_err(|e| format!("Share test failed: {e}"))?;
9316
9317 let text = String::from_utf8_lossy(&output.stdout);
9318 out.push_str("=== Share Triage Results ===\n");
9319 out.push_str(&text);
9320 }
9321
9322 #[cfg(not(target_os = "windows"))]
9323 {
9324 out.push_str("Share access testing is primarily optimized for Windows UNC paths.\n");
9325 }
9326
9327 Ok(out.trim_end().to_string())
9328}
9329
9330fn inspect_dns_fix_plan(issue: &str) -> Result<String, String> {
9331 let mut out = String::from("Host inspection: fix_plan (DNS Resolution)\n\n");
9332 out.push_str(&format!("Issue: {}\n\n", issue));
9333 out.push_str("Proposed Remediation Steps:\n");
9334 out.push_str("1. **Flush DNS Cache**: Clear local resolver cache.\n");
9335 out.push_str(" `ipconfig /flushdns` (Windows) or `sudo resolvectl flush-caches` (Linux)\n");
9336 out.push_str("2. **Validate Hosts File**: Check for static overrides.\n");
9337 out.push_str(" `Get-Content C:\\Windows\\System32\\drivers\\etc\\hosts` (Windows)\n");
9338 out.push_str("3. **Test Name Resolution**: Use nslookup to query a specific server.\n");
9339 out.push_str(" `nslookup google.com 8.8.8.8` (Tests if external DNS works)\n");
9340 out.push_str("4. **Check Adapter DNS**: Ensure local settings match expected nameservers.\n");
9341 out.push_str(
9342 " `Get-NetIPConfiguration | Select-Object InterfaceAlias, DNSServer` (Windows)\n",
9343 );
9344
9345 Ok(out)
9346}
9347
9348fn inspect_registry_audit() -> Result<String, String> {
9349 let mut out = String::from("Host inspection: registry_audit\n\n");
9350 out.push_str("Auditing advanced persistence points and shell integrity overrides...\n\n");
9351
9352 #[cfg(target_os = "windows")]
9353 {
9354 let script = r#"
9355$findings = @()
9356
9357# 1. Image File Execution Options (Debugger Hijacking)
9358$ifeo = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options"
9359if (Test-Path $ifeo) {
9360 Get-ChildItem $ifeo | ForEach-Object {
9361 $p = Get-ItemProperty $_.PSPath
9362 if ($p.debugger) { $findings += "[IFEO Hijack] $($_.PSChildName) -> Debugger defined: $($p.debugger)" }
9363 }
9364}
9365
9366# 2. Winlogon Shell Integrity
9367$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
9368$shell = (Get-ItemProperty $winlogon -Name Shell -ErrorAction SilentlyContinue).Shell
9369if ($shell -and $shell -ne "explorer.exe") {
9370 $findings += "[Winlogon Overlook] Non-standard shell defined: $shell"
9371}
9372
9373# 3. Session Manager BootExecute
9374$sm = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager"
9375$boot = (Get-ItemProperty $sm -Name BootExecute -ErrorAction SilentlyContinue).BootExecute
9376if ($boot -and $boot -notcontains "autocheck autochk *") {
9377 $findings += "[Boot Integrity] Non-standard BootExecute defined: $($boot -join ', ')"
9378}
9379
9380if ($findings.Count -eq 0) {
9381 "PASS: No common registry hijacking or shell overrides detected."
9382} else {
9383 $findings -join "`n"
9384}
9385"#;
9386 let output = Command::new("powershell")
9387 .args(["-NoProfile", "-Command", &script])
9388 .output()
9389 .map_err(|e| format!("Registry audit failed: {e}"))?;
9390
9391 let text = String::from_utf8_lossy(&output.stdout);
9392 out.push_str("=== Persistence & Integrity Check ===\n");
9393 out.push_str(&text);
9394 }
9395
9396 #[cfg(not(target_os = "windows"))]
9397 {
9398 out.push_str("Registry auditing is specific to Windows environments.\n");
9399 }
9400
9401 Ok(out.trim_end().to_string())
9402}
9403
9404fn inspect_thermal() -> Result<String, String> {
9405 let mut out = String::from("Host inspection: thermal\n\n");
9406 out.push_str("Checking CPU thermal state and active throttling indicators...\n\n");
9407
9408 #[cfg(target_os = "windows")]
9409 {
9410 let script = r#"
9411$thermal = Get-CimInstance -ClassName Win32_PerfRawData_Counters_ThermalZoneInformation -ErrorAction SilentlyContinue
9412if ($thermal) {
9413 $thermal | ForEach-Object {
9414 $temp = [math]::Round(($_.Temperature - 273.15), 1)
9415 "Zone: $($_.Name) | Temp: $temp °C | Throttling: $($_.HighPrecisionTemperature -eq 0 ? 'NO' : 'ACTIVE')"
9416 }
9417} else {
9418 "Thermal counters not directly available via WMI. Checking for system throttling indicators..."
9419 $throttling = Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty LoadPercentage
9420 "Current CPU Load: $throttling%"
9421}
9422"#;
9423 let output = Command::new("powershell")
9424 .args(["-NoProfile", "-Command", script])
9425 .output()
9426 .map_err(|e| format!("Thermal check failed: {e}"))?;
9427 out.push_str("=== Windows Thermal State ===\n");
9428 out.push_str(&String::from_utf8_lossy(&output.stdout));
9429 }
9430
9431 #[cfg(not(target_os = "windows"))]
9432 {
9433 out.push_str(
9434 "Thermal inspection is currently optimized for Windows performance counters.\n",
9435 );
9436 }
9437
9438 Ok(out.trim_end().to_string())
9439}
9440
9441fn inspect_activation() -> Result<String, String> {
9442 let mut out = String::from("Host inspection: activation\n\n");
9443 out.push_str("Auditing Windows activation and license state...\n\n");
9444
9445 #[cfg(target_os = "windows")]
9446 {
9447 let script = r#"
9448$xpr = cscript //nologo C:\Windows\System32\slmgr.vbs /xpr
9449$dli = cscript //nologo C:\Windows\System32\slmgr.vbs /dli
9450"Status: $($xpr.Trim())"
9451"Details: $($dli -join ' ' | Select-String -Pattern 'License Status|Name' -AllMatches | ForEach-Object { $_.ToString().Trim() })"
9452"#;
9453 let output = Command::new("powershell")
9454 .args(["-NoProfile", "-Command", script])
9455 .output()
9456 .map_err(|e| format!("Activation check failed: {e}"))?;
9457 out.push_str("=== Windows License Report ===\n");
9458 out.push_str(&String::from_utf8_lossy(&output.stdout));
9459 }
9460
9461 #[cfg(not(target_os = "windows"))]
9462 {
9463 out.push_str("Windows activation check is specific to the Windows platform.\n");
9464 }
9465
9466 Ok(out.trim_end().to_string())
9467}
9468
9469fn inspect_patch_history(max_entries: usize) -> Result<String, String> {
9470 let mut out = String::from("Host inspection: patch_history\n\n");
9471 out.push_str(&format!(
9472 "Listing the last {} installed Windows updates (KBs)...\n\n",
9473 max_entries
9474 ));
9475
9476 #[cfg(target_os = "windows")]
9477 {
9478 let n = max_entries.clamp(1, 50);
9479 let script = format!(
9480 "Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First {} | ForEach-Object {{ \"[$($_.InstalledOn.ToString('yyyy-MM-dd'))] $($_.HotFixID) - $($_.Description)\" }}",
9481 n
9482 );
9483 let output = Command::new("powershell")
9484 .args(["-NoProfile", "-Command", &script])
9485 .output()
9486 .map_err(|e| format!("Patch history query failed: {e}"))?;
9487 out.push_str("=== Recent HotFixes (KBs) ===\n");
9488 out.push_str(&String::from_utf8_lossy(&output.stdout));
9489 }
9490
9491 #[cfg(not(target_os = "windows"))]
9492 {
9493 out.push_str("Patch history is currently focused on Windows HotFixes.\n");
9494 }
9495
9496 Ok(out.trim_end().to_string())
9497}
9498
9499fn inspect_ad_user(identity: &str) -> Result<String, String> {
9502 let mut out = String::from("Host inspection: ad_user\n\n");
9503 let ident = identity.trim();
9504 if ident.is_empty() {
9505 out.push_str("Status: No identity specified. Performing self-discovery...\n");
9506 #[cfg(target_os = "windows")]
9507 {
9508 let script = r#"
9509$u = [System.Security.Principal.WindowsIdentity]::GetCurrent()
9510"USER: " + $u.Name
9511"SID: " + $u.User.Value
9512"GROUPS: " + (($u.Groups | ForEach-Object { try { $_.Translate([System.Security.Principal.NTAccount]).Value } catch { $_.Value } }) -join ', ')
9513"ELEVATED: " + (New-Object System.Security.Principal.WindowsPrincipal($u)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
9514"#;
9515 let output = Command::new("powershell")
9516 .args(["-NoProfile", "-Command", script])
9517 .output()
9518 .ok();
9519 if let Some(o) = output {
9520 out.push_str(&String::from_utf8_lossy(&o.stdout));
9521 }
9522 }
9523 return Ok(out);
9524 }
9525
9526 #[cfg(target_os = "windows")]
9527 {
9528 let script = format!(
9529 r#"
9530try {{
9531 $u = Get-ADUser -Identity "{ident}" -Properties MemberOf, LastLogonDate, Enabled, PasswordExpired -ErrorAction Stop
9532 "NAME: " + $u.Name
9533 "SID: " + $u.SID
9534 "ENABLED: " + $u.Enabled
9535 "EXPIRED: " + $u.PasswordExpired
9536 "LOGON: " + $u.LastLogonDate
9537 "GROUPS: " + ($u.MemberOf -replace "CN=([^,]+),.*", "$1" -join ", ")
9538}} catch {{
9539 # Fallback to net user if AD module is missing or fails
9540 $net = net user "{ident}" /domain 2>&1
9541 if ($LASTEXITCODE -eq 0) {{
9542 $net | Select-String "User name", "Full Name", "Account active", "Password expires", "Last logon", "Local Group Memberships", "Global Group memberships" | ForEach-Object {{ $_.ToString().Trim() }}
9543 }} else {{
9544 "ERROR: " + $_.Exception.Message
9545 }}
9546}}"#
9547 );
9548
9549 let output = Command::new("powershell")
9550 .args(["-NoProfile", "-Command", &script])
9551 .output()
9552 .ok();
9553
9554 if let Some(o) = output {
9555 let stdout = String::from_utf8_lossy(&o.stdout);
9556 if stdout.contains("ERROR:") && stdout.contains("Get-ADUser") {
9557 out.push_str("Active Directory PowerShell module not found. Showing basic domain user info:\n\n");
9558 }
9559 out.push_str(&stdout);
9560 }
9561 }
9562
9563 #[cfg(not(target_os = "windows"))]
9564 {
9565 let _ = ident;
9566 out.push_str("(AD User lookup only available on Windows nodes)\n");
9567 }
9568
9569 Ok(out.trim_end().to_string())
9570}
9571
9572fn inspect_dns_lookup(name: &str, record_type: &str) -> Result<String, String> {
9575 let mut out = String::from("Host inspection: dns_lookup\n\n");
9576 let target = name.trim();
9577 if target.is_empty() {
9578 return Err("Missing required target name for dns_lookup.".to_string());
9579 }
9580
9581 #[cfg(target_os = "windows")]
9582 {
9583 let script = format!("Resolve-DnsName -Name '{target}' -Type {record_type} -ErrorAction SilentlyContinue | Select-Object Name, Type, TTL, Section, NameHost, Strings, IPAddress, Address | Format-List");
9584 let output = Command::new("powershell")
9585 .args(["-NoProfile", "-Command", &script])
9586 .output()
9587 .ok();
9588 if let Some(o) = output {
9589 let stdout = String::from_utf8_lossy(&o.stdout);
9590 if stdout.trim().is_empty() {
9591 out.push_str(&format!("No {record_type} records found for {target}.\n"));
9592 } else {
9593 out.push_str(&stdout);
9594 }
9595 }
9596 }
9597
9598 #[cfg(not(target_os = "windows"))]
9599 {
9600 let output = Command::new("dig")
9601 .args([target, record_type, "+short"])
9602 .output()
9603 .ok();
9604 if let Some(o) = output {
9605 out.push_str(&String::from_utf8_lossy(&o.stdout));
9606 }
9607 }
9608
9609 Ok(out.trim_end().to_string())
9610}
9611
9612fn inspect_hyperv() -> Result<String, String> {
9615 let mut out = String::from("Host inspection: hyperv\n\n");
9616
9617 #[cfg(target_os = "windows")]
9618 {
9619 let script = "Get-VM -ErrorAction SilentlyContinue | Select-Object Name, State, Uptime, Status, CPUUsage, MemoryAssigned | Format-Table -AutoSize";
9620 let output = Command::new("powershell")
9621 .args(["-NoProfile", "-Command", script])
9622 .output()
9623 .ok();
9624 if let Some(o) = output {
9625 let stdout = String::from_utf8_lossy(&o.stdout);
9626 if stdout.trim().is_empty() {
9627 out.push_str(
9628 "No Hyper-V Virtual Machines found or Hyper-V module not installed.\n",
9629 );
9630 } else {
9631 out.push_str(&stdout);
9632 }
9633 }
9634 }
9635
9636 #[cfg(not(target_os = "windows"))]
9637 {
9638 out.push_str("(Hyper-V lookup only available on Windows hosts)\n");
9639 }
9640
9641 Ok(out.trim_end().to_string())
9642}
9643
9644fn inspect_ip_config() -> Result<String, String> {
9647 let mut out = String::from("Host inspection: ip_config\n\n");
9648
9649 #[cfg(target_os = "windows")]
9650 {
9651 let script = "Get-NetIPConfiguration -Detailed | ForEach-Object { \
9652 $_.InterfaceAlias + ' [' + $_.InterfaceDescription + ']' + \
9653 '\\n Status: ' + $_.NetAdapter.Status + \
9654 '\\n Initial IPv4: ' + ($_.IPv4Address.IPAddress -join ', ') + \
9655 '\\n DHCP Enabled: ' + $_.NetAdapter.DhcpStatus + \
9656 '\\n DHCP Server: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
9657 '\\n IPv4 Default Gateway: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
9658 '\\n DNSServer: ' + ($_.DNSServer.ServerAddresses -join ', ') + '\\n' \
9659 }";
9660 let output = Command::new("powershell")
9661 .args(["-NoProfile", "-Command", script])
9662 .output()
9663 .ok();
9664 if let Some(o) = output {
9665 out.push_str(&String::from_utf8_lossy(&o.stdout));
9666 }
9667 }
9668
9669 #[cfg(not(target_os = "windows"))]
9670 {
9671 let output = Command::new("ip").args(["addr", "show"]).output().ok();
9672 if let Some(o) = output {
9673 out.push_str(&String::from_utf8_lossy(&o.stdout));
9674 }
9675 }
9676
9677 Ok(out.trim_end().to_string())
9678}
9679
9680async fn inspect_overclocker() -> Result<String, String> {
9681 let mut out = String::from("Host inspection: overclocker\n\n");
9682
9683 #[cfg(target_os = "windows")]
9684 {
9685 out.push_str(
9686 "Gathering real-time silicon telemetry (2-second high-fidelity average)...\n\n",
9687 );
9688
9689 let nvidia = Command::new("nvidia-smi")
9691 .args([
9692 "--query-gpu=name,clocks.current.graphics,clocks.current.memory,fan.speed,power.draw,temperature.gpu,clocks_throttle_reasons.active",
9693 "--format=csv,noheader,nounits",
9694 ])
9695 .output();
9696
9697 if let Ok(o) = nvidia {
9698 let stdout = String::from_utf8_lossy(&o.stdout);
9699 if !stdout.trim().is_empty() {
9700 out.push_str("=== GPU SENSE (NVIDIA) ===\n");
9701 let parts: Vec<&str> = stdout.trim().split(',').map(|s| s.trim()).collect();
9702 if parts.len() >= 6 {
9703 out.push_str(&format!("- Model: {}\n", parts[0]));
9704 out.push_str(&format!("- Graphics: {} MHz\n", parts[1]));
9705 out.push_str(&format!("- Memory: {} MHz\n", parts[2]));
9706 out.push_str(&format!("- Fan Speed: {}%\n", parts[3]));
9707 out.push_str(&format!("- Power Draw: {} W\n", parts[4]));
9708 out.push_str(&format!("- Temperature: {}°C\n", parts[5]));
9709
9710 if parts.len() > 6 {
9711 let throttle_hex = parts[6];
9712 let reasons = decode_nvidia_throttle_reasons(throttle_hex);
9713 if !reasons.is_empty() {
9714 out.push_str(&format!("- Throttling: YES [Reason: {}]\n", reasons));
9715 } else {
9716 out.push_str("- Throttling: None (Performance State: Max)\n");
9717 }
9718 }
9719 }
9720 out.push_str("\n");
9721 }
9722 }
9723
9724 let gpu_state = &crate::ui::gpu_monitor::GLOBAL_GPU_STATE;
9726 let history = gpu_state.history.lock().unwrap();
9727 if history.len() >= 2 {
9728 out.push_str("=== SILICON TRENDS (Session) ===\n");
9729 let first = history.front().unwrap();
9730 let last = history.back().unwrap();
9731
9732 let temp_diff = last.temperature as i32 - first.temperature as i32;
9733 let clock_diff = last.core_clock as i32 - first.core_clock as i32;
9734
9735 let temp_trend = if temp_diff > 1 {
9736 "Rising"
9737 } else if temp_diff < -1 {
9738 "Falling"
9739 } else {
9740 "Stable"
9741 };
9742 let clock_trend = if clock_diff > 10 {
9743 "Increasing"
9744 } else if clock_diff < -10 {
9745 "Decreasing"
9746 } else {
9747 "Stable"
9748 };
9749
9750 out.push_str(&format!(
9751 "- Temperature: {} ({}°C anomaly)\n",
9752 temp_trend, temp_diff
9753 ));
9754 out.push_str(&format!(
9755 "- Core Clock: {} ({} MHz delta)\n",
9756 clock_trend, clock_diff
9757 ));
9758 out.push_str("\n");
9759 }
9760
9761 let ps_cmd = "Get-Counter -Counter '\\Processor Information(_Total)\\Processor Frequency', '\\Processor Information(_Total)\\% of Maximum Frequency' -SampleInterval 1 -MaxSamples 2 | ForEach-Object { $_.CounterSamples } | Group-Object Path | ForEach-Object { \"$($_.Name):$([math]::Round(($_.Group | Measure-Object CookedValue -Average).Average, 0))\" }";
9763 let cpu_stats = Command::new("powershell")
9764 .args(["-NoProfile", "-Command", ps_cmd])
9765 .output();
9766
9767 if let Ok(o) = cpu_stats {
9768 let stdout = String::from_utf8_lossy(&o.stdout);
9769 if !stdout.trim().is_empty() {
9770 out.push_str("=== SILICON CORE (CPU) ===\n");
9771 for line in stdout.lines() {
9772 if let Some((path, val)) = line.split_once(':') {
9773 if path.to_lowercase().contains("processor frequency") {
9774 out.push_str(&format!("- Current Freq: {} MHz (2s Avg)\n", val));
9775 } else if path.to_lowercase().contains("% of maximum frequency") {
9776 out.push_str(&format!("- Throttling: {}% of Max Capacity\n", val));
9777 let throttle_num = val.parse::<f64>().unwrap_or(100.0);
9778 if throttle_num < 95.0 {
9779 out.push_str(
9780 " [WARNING] Active downclocking or power-saving detected.\n",
9781 );
9782 }
9783 }
9784 }
9785 }
9786 }
9787 }
9788
9789 let thermal = Command::new("powershell")
9791 .args([
9792 "-NoProfile",
9793 "-Command",
9794 "Get-CimInstance -Namespace root\\wmi -ClassName MSAcpi_ThermalZoneTemperature | Select-Object @{N='Temp';E={($_.CurrentTemperature - 2732) / 10}} | ConvertTo-Json",
9795 ])
9796 .output();
9797 if let Ok(o) = thermal {
9798 let stdout = String::from_utf8_lossy(&o.stdout);
9799 if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
9800 let temp = if v.is_array() {
9801 v[0].get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
9802 } else {
9803 v.get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
9804 };
9805 if temp > 1.0 {
9806 out.push_str(&format!("- CPU Package: {}°C (ACPI Zone)\n", temp));
9807 }
9808 }
9809 }
9810
9811 let wmi = Command::new("powershell")
9813 .args([
9814 "-NoProfile",
9815 "-Command",
9816 "Get-CimInstance Win32_Processor | Select-Object Name, MaxClockSpeed, CurrentVoltage | ConvertTo-Json",
9817 ])
9818 .output();
9819
9820 if let Ok(o) = wmi {
9821 let stdout = String::from_utf8_lossy(&o.stdout);
9822 if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
9823 out.push_str("\n=== HARDWARE DNA ===\n");
9824 out.push_str(&format!(
9825 "- Rated Max: {} MHz\n",
9826 v.get("MaxClockSpeed").and_then(|x| x.as_u64()).unwrap_or(0)
9827 ));
9828 out.push_str(&format!(
9829 "- Voltage: {} (Raw WMI)\n",
9830 v.get("CurrentVoltage")
9831 .and_then(|x| x.as_u64())
9832 .unwrap_or(0)
9833 ));
9834 }
9835 }
9836 }
9837
9838 #[cfg(not(target_os = "windows"))]
9839 {
9840 out.push_str("Overclocker telemetry is currently optimized for Windows performance counters and NVIDIA drivers.\n");
9841 }
9842
9843 Ok(out.trim_end().to_string())
9844}
9845
9846fn decode_nvidia_throttle_reasons(hex: &str) -> String {
9848 let hex = hex.trim().trim_start_matches("0x");
9849 let val = match u64::from_str_radix(hex, 16) {
9850 Ok(v) => v,
9851 Err(_) => return String::new(),
9852 };
9853
9854 if val == 0 {
9855 return String::new();
9856 }
9857
9858 let mut reasons = Vec::new();
9859 if val & 0x01 != 0 {
9860 reasons.push("GPU Idle");
9861 }
9862 if val & 0x02 != 0 {
9863 reasons.push("Applications Clocks Setting");
9864 }
9865 if val & 0x04 != 0 {
9866 reasons.push("SW Power Cap (PL1/PL2)");
9867 }
9868 if val & 0x08 != 0 {
9869 reasons.push("HW Slowdown (Thermal/Power)");
9870 }
9871 if val & 0x10 != 0 {
9872 reasons.push("Sync Boost");
9873 }
9874 if val & 0x20 != 0 {
9875 reasons.push("SW Thermal Slowdown");
9876 }
9877 if val & 0x40 != 0 {
9878 reasons.push("HW Thermal Slowdown");
9879 }
9880 if val & 0x80 != 0 {
9881 reasons.push("HW Power Brake Slowdown");
9882 }
9883 if val & 0x100 != 0 {
9884 reasons.push("Display Clock Setting");
9885 }
9886
9887 reasons.join(", ")
9888}