Skip to main content

hematite/tools/
host_inspect.rs

1use crate::agent::truncation::safe_head;
2use serde_json::Value;
3use std::collections::HashSet;
4use std::fmt::Write as _;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9const DEFAULT_MAX_ENTRIES: usize = 10;
10const MAX_ENTRIES_CAP: usize = 25;
11const DIRECTORY_SCAN_NODE_BUDGET: usize = 25_000;
12
13pub async fn inspect_host(args: &Value) -> Result<String, String> {
14    let mut topic = args
15        .get("topic")
16        .and_then(|v| v.as_str())
17        .unwrap_or("summary")
18        .to_string();
19    let max_entries = parse_max_entries(args);
20    let filter = parse_name_filter(args).unwrap_or_default().to_lowercase();
21
22    // Topic Interceptor: Force ad_user for AD-related queries to resolve model variance
23    if (topic == "processes" || topic == "network" || topic == "summary")
24        && (filter.contains("ad")
25            || filter.contains("sid")
26            || filter.contains("administrator")
27            || filter.contains("domain"))
28    {
29        topic = "ad_user".to_string();
30    }
31
32    let result = match topic.as_str() {
33        "summary" => inspect_summary(max_entries),
34        "toolchains" => inspect_toolchains(),
35        "path" => inspect_path(max_entries),
36        "env_doctor" => inspect_env_doctor(max_entries),
37        "fix_plan" => inspect_fix_plan(parse_issue_text(args), parse_port_filter(args), max_entries).await,
38        "network" => inspect_network(max_entries),
39        "lan_discovery" | "network_neighborhood" | "upnp" | "neighborhood" => {
40            inspect_lan_discovery(max_entries)
41        }
42        "audio" | "sound" | "microphone" | "speakers" | "speaker" | "mic" => {
43            inspect_audio(max_entries)
44        }
45        "bluetooth" | "bt" | "paired_devices" | "wireless_audio" => {
46            inspect_bluetooth(max_entries)
47        }
48        "camera" | "webcam" | "camera_privacy" => inspect_camera(max_entries),
49        "sign_in" | "windows_hello" | "hello" | "pin" | "login_issues" | "signin" => {
50            inspect_sign_in(max_entries)
51        }
52        "installer_health" | "installer" | "msi" | "msiexec" | "app_installer" => {
53            inspect_installer_health(max_entries)
54        }
55        "onedrive" | "sync_client" | "cloud_sync" | "known_folder_backup" => {
56            inspect_onedrive(max_entries)
57        }
58        "browser_health" | "browser" | "webview2" | "default_browser" => {
59            inspect_browser_health(max_entries)
60        }
61        "identity_auth"
62        | "office_auth"
63        | "m365_auth"
64        | "microsoft_365_auth"
65        | "auth_broker" => inspect_identity_auth(max_entries),
66        "outlook" | "outlook_health" | "ms_outlook" => inspect_outlook(max_entries),
67        "teams" | "ms_teams" | "teams_health" => inspect_teams(max_entries),
68        "windows_backup" | "backup" | "file_history" | "wbadmin" | "system_restore" => {
69            inspect_windows_backup(max_entries)
70        }
71        "search_index" | "windows_search" | "indexing" | "search" => {
72            inspect_search_index(max_entries)
73        }
74        "services" => inspect_services(parse_name_filter(args), max_entries),
75        "processes" => inspect_processes(parse_name_filter(args), max_entries),
76        "desktop" => inspect_known_directory("Desktop", desktop_dir(), max_entries).await,
77        "downloads" => inspect_known_directory("Downloads", downloads_dir(), max_entries).await,
78        "disk" => {
79            let path = resolve_optional_path(args)?;
80            inspect_disk(path, max_entries).await
81        }
82        "ports" => inspect_ports(parse_port_filter(args), max_entries),
83        "log_check" => inspect_log_check(parse_lookback_hours(args), max_entries),
84        "startup_items" | "startup" | "boot" | "autorun" => inspect_startup_items(max_entries),
85        "health_report" | "system_health" => inspect_health_report(),
86        "storage" => inspect_storage(max_entries),
87        "hardware" => inspect_hardware(),
88        "updates" | "windows_update" => inspect_updates(),
89        "security" | "antivirus" | "defender" => inspect_security(),
90        "pending_reboot" | "reboot_required" => inspect_pending_reboot(),
91        "disk_health" | "smart" | "drive_health" => inspect_disk_health(),
92        "battery" => inspect_battery(),
93        "recent_crashes" | "crashes" | "bsod" => inspect_recent_crashes(max_entries),
94        "scheduled_tasks" | "tasks" => inspect_scheduled_tasks(max_entries),
95        "dev_conflicts" | "dev_environment" => inspect_dev_conflicts(),
96        "connectivity" | "internet" | "internet_check" => inspect_connectivity(),
97        "wifi" | "wi-fi" | "wireless" | "wlan" => inspect_wifi(),
98        "connections" | "tcp_connections" | "active_connections" => inspect_connections(max_entries),
99        "vpn" => inspect_vpn(),
100        "public_ip" | "external_ip" | "myip" => inspect_public_ip().await,
101        "ssl_cert" | "tls_cert" | "cert_audit" | "website_cert" => {
102            let host = args.get("host").and_then(|v| v.as_str()).unwrap_or("google.com");
103            inspect_ssl_cert(host)
104        }
105        "proxy" | "proxy_settings" => inspect_proxy(),
106        "firewall_rules" | "firewall-rules" => inspect_firewall_rules(max_entries),
107        "traceroute" | "tracert" | "trace_route" | "trace" => {
108            let host = args
109                .get("host")
110                .and_then(|v| v.as_str())
111                .unwrap_or("8.8.8.8")
112                .to_string();
113            inspect_traceroute(&host, max_entries)
114        }
115        "dns_cache" | "dnscache" | "dns-cache" => inspect_dns_cache(max_entries),
116        "arp" | "arp_table" => inspect_arp(),
117        "route_table" | "routes" | "routing_table" => inspect_route_table(max_entries),
118        "os_config" | "system_config" => inspect_os_config(),
119        "resource_load" | "performance" | "system_load" | "performance_diagnosis" => inspect_resource_load(),
120        "env" | "environment" | "environment_variables" | "env_vars" => inspect_env(max_entries),
121        "hosts_file" | "hosts" | "etc_hosts" => inspect_hosts_file(),
122        "docker" | "containers" | "docker_status" => inspect_docker(max_entries),
123        "docker_filesystems" | "docker_mounts" | "docker_storage" | "container_mounts" => {
124            inspect_docker_filesystems(max_entries)
125        }
126        "wsl" | "wsl_distros" | "subsystem" => inspect_wsl(),
127        "wsl_filesystems" | "wsl_storage" | "wsl_mounts" => inspect_wsl_filesystems(max_entries),
128        "ssh" | "ssh_config" | "ssh_status" => inspect_ssh(),
129        "installed_software" | "installed" | "programs" | "software" | "packages" => inspect_installed_software(max_entries),
130        "git_config" | "git_global" => inspect_git_config(),
131        "databases" | "database" | "db_services" | "db" => inspect_databases(),
132        "user_accounts" | "users" | "local_users" | "accounts" => inspect_user_accounts(max_entries),
133        "audit_policy" | "audit" | "auditpol" => inspect_audit_policy(),
134        "shares" | "smb_shares" | "network_shares" | "mapped_drives" => inspect_shares(max_entries),
135        "dns_servers" | "dns_config" | "dns_resolver" | "nameservers" => inspect_dns_servers(),
136        "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => inspect_bitlocker(),
137        "rdp" | "remote_desktop" | "rdp_status" => inspect_rdp(),
138        "shadow_copies" | "vss" | "volume_shadow" | "backups" | "snapshots" => inspect_shadow_copies(),
139        "pagefile" | "page_file" | "virtual_memory" | "swap" => inspect_pagefile(),
140        "windows_features" | "optional_features" | "installed_features" | "features" => inspect_windows_features(max_entries),
141        "printers" | "printer" | "print_queue" | "printing" => inspect_printers(max_entries),
142        "winrm" | "remote_management" | "psremoting" => inspect_winrm(),
143        "network_stats" | "adapter_stats" | "nic_stats" | "interface_stats" => inspect_network_stats(max_entries),
144        "udp_ports" | "udp_listeners" | "udp" => inspect_udp_ports(max_entries),
145        "gpo" | "group_policy" | "applied_policies" => inspect_gpo(),
146        "certificates" | "certs" | "ssl_certs" => inspect_certificates(max_entries),
147        "integrity" | "sfc" | "dism" | "system_health_deep" => inspect_integrity(),
148        "domain" | "active_directory" | "ad_context" | "workgroup" => inspect_domain(),
149        "device_health" | "hardware_errors" | "yellow_bangs" => inspect_device_health(),
150        "drivers" | "system_drivers" | "driver_list" => inspect_drivers(max_entries),
151        "peripherals" | "usb" | "input_devices" | "connected_hardware" => inspect_peripherals(max_entries),
152        "sessions" | "logins" | "active_sessions" => inspect_sessions(max_entries),
153        "data_audit" | "csv_audit" | "file_audit" => {
154            let path = resolve_optional_path(args)?;
155            inspect_data_audit(path, max_entries).await
156        }
157        "repo_doctor" => {
158            let path = resolve_optional_path(args)?;
159            inspect_repo_doctor(path, max_entries)
160        }
161        "directory" => {
162            let raw_path = args
163                .get("path")
164                .and_then(|v| v.as_str())
165                .ok_or_else(|| {
166                    "Missing required argument: 'path' for inspect_host(topic: \"directory\")"
167                        .to_string()
168                })?;
169            let resolved = resolve_path(raw_path)?;
170            inspect_directory("Directory", resolved, max_entries).await
171        }
172        "disk_benchmark" | "stress_test" | "io_intensity" => {
173            let path = resolve_optional_path(args)?;
174            inspect_disk_benchmark(path).await
175        }
176        "permissions" | "acl" | "access_control" => {
177            let path = resolve_optional_path(args)?;
178            inspect_permissions(path, max_entries)
179        }
180        "login_history" | "logon_history" | "user_logins" => {
181            inspect_login_history(max_entries)
182        }
183        "share_access" | "unc_access" | "remote_share" => {
184            let path = resolve_path(args.get("path").and_then(|v| v.as_str()).unwrap_or(""))?;
185            inspect_share_access(path)
186        }
187        "registry_audit" | "persistence" | "integrity_audit" => inspect_registry_audit(),
188        "thermal" | "throttling" | "overheating" => inspect_thermal(),
189        "activation" | "license_status" | "slmgr" => inspect_activation(),
190        "patch_history" | "hotfixes" | "recent_patches" => inspect_patch_history(max_entries),
191        "ad_user" | "ad" | "domain_user" => {
192            let identity = parse_name_filter(args).unwrap_or_default();
193            inspect_ad_user(&identity)
194        }
195        "dns_lookup" | "dig" | "nslookup" => {
196            let name = parse_name_filter(args).unwrap_or_default();
197            let record_type = args.get("type").and_then(|v| v.as_str()).unwrap_or("A");
198            inspect_dns_lookup(&name, record_type)
199        }
200        "hyperv" | "hyper-v" | "vms" => inspect_hyperv(),
201        "ip_config" | "ip_detail" => inspect_ip_config(),
202        "dhcp" | "dhcp_lease" | "lease" | "dhcp_detail" => inspect_dhcp(),
203        "mtu" | "path_mtu" | "pmtu" | "frame_size" | "mtu_discovery" => inspect_mtu(),
204        "ipv6" | "ipv6_status" | "ipv6_address" | "ipv6_prefix" | "ipv6_config" | "slaac" | "dhcpv6" => inspect_ipv6(),
205        "tcp_params" | "tcp_settings" | "tcp_autotuning" | "tcp_config" | "tcp_tuning" | "tcp_window" => inspect_tcp_params(),
206        "wlan_profiles" | "wifi_profiles" | "wireless_profiles" | "saved_wifi" | "saved_networks" => inspect_wlan_profiles(),
207        "ipsec" | "ipsec_sa" | "ipsec_policy" | "ipsec_rules" | "ipsec_tunnel" | "ike" => inspect_ipsec(),
208        "netbios" | "netbios_status" | "wins" | "nbtstat" | "netbios_config" => inspect_netbios(),
209        "nic_teaming" | "nic_team" | "teaming" | "lacp" | "bonding" | "link_aggregation" => inspect_nic_teaming(),
210        "snmp" | "snmp_agent" | "snmp_service" | "snmp_config" => inspect_snmp(),
211        "port_test" | "port_check" | "test_port" | "check_port" | "tcp_test" | "reachable" => {
212            let pt_host = args.get("host").and_then(|v| v.as_str()).map(|s| s.to_string());
213            let pt_port = args.get("port").and_then(|v| v.as_u64()).and_then(|p| u16::try_from(p).ok());
214            inspect_port_test(pt_host.as_deref(), pt_port)
215        }
216        "network_profile" | "network_location" | "net_profile" | "network_category" => inspect_network_profile(),
217        "overclocker" | "thermal_deep" | "clocks" | "voltage" => inspect_overclocker().await,
218        "display_config" | "display" | "monitor" | "monitors" | "resolution" | "refresh_rate" | "screen" => {
219            inspect_display_config(max_entries)
220        }
221        "ntp" | "time_sync" | "time_synchronization" | "clock_sync" | "w32tm" | "clock" => {
222            inspect_ntp()
223        }
224        "cpu_power" | "turbo_boost" | "cpu_frequency" | "cpu_freq" | "processor_power" | "boost" => {
225            inspect_cpu_power()
226        }
227        "credentials" | "credential_manager" | "saved_passwords" | "stored_credentials" => {
228            inspect_credentials(max_entries)
229        }
230        "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
231            inspect_tpm()
232        }
233        "latency" | "ping" | "ping_test" | "rtt" | "packet_loss" | "reachability" => {
234            inspect_latency()
235        }
236        "network_adapter" | "nic" | "nic_settings" | "adapter_settings" | "nic_offload" | "nic_advanced" => {
237            inspect_network_adapter()
238        }
239        "event_query" | "event_log" | "events" | "event_search" | "eventlog" => {
240            let event_id = args.get("event_id").and_then(|v| v.as_u64()).and_then(|n| u32::try_from(n).ok());
241            let log_name = args.get("log").and_then(|v| v.as_str()).map(|s| s.to_string());
242            let source = args.get("source").and_then(|v| v.as_str()).map(|s| s.to_string());
243            let hours = args.get("hours").and_then(|v| v.as_u64()).and_then(|h| u32::try_from(h).ok()).unwrap_or(24u32);
244            let level = args.get("level").and_then(|v| v.as_str()).map(|s| s.to_string());
245            inspect_event_query(event_id, log_name.as_deref(), source.as_deref(), hours, level.as_deref(), max_entries)
246        }
247        "app_crashes" | "application_crashes" | "app_errors" | "application_errors" | "faulting_application" => {
248            let process_filter = args.get("process").and_then(|v| v.as_str()).map(|s| s.to_string());
249            inspect_app_crashes(process_filter.as_deref(), max_entries)
250        }
251        "mdm_enrollment" | "mdm" | "intune" | "intune_enrollment" | "device_enrollment" | "autopilot" => {
252            inspect_mdm_enrollment()
253        }
254        "storage_spaces" | "storage_pool" | "storage_pools" | "virtual_disk" | "virtual_disks" | "windows_raid" => {
255            inspect_storage_spaces()
256        }
257        "defender_quarantine" | "quarantine" | "threat_history" | "malware_history" | "defender_history" | "detected_threats" => {
258            inspect_defender_quarantine(max_entries)
259        }
260        "domain_health" | "dc_connectivity" | "ad_connectivity" | "kerberos_health" => {
261            inspect_domain_health()
262        }
263        "service_dependencies" | "svc_deps" | "service_deps" => {
264            inspect_service_dependencies(max_entries)
265        }
266        "wmi_health" | "wmi_repository" | "wmi_status" => {
267            inspect_wmi_health()
268        }
269        "local_security_policy" | "password_policy" | "account_policy" | "ntlm_policy" | "lm_policy" => {
270            inspect_local_security_policy()
271        }
272        "usb_history" | "usb_devices" | "usb_forensics" | "usbstor" => {
273            inspect_usb_history(max_entries)
274        }
275        "print_spooler" | "spooler" | "printnightmare" | "print_security" | "printer_security" => {
276            inspect_print_spooler()
277        }
278        other => Err(format!(
279            "Unknown inspect_host topic '{}'. Use one of: summary, toolchains, path, env_doctor, fix_plan, network, lan_discovery, audio, bluetooth, camera, sign_in, installer_health, onedrive, browser_health, identity_auth, outlook, teams, windows_backup, search_index, display_config, ntp, cpu_power, credentials, tpm, latency, network_adapter, dhcp, mtu, ipv6, tcp_params, wlan_profiles, ipsec, netbios, nic_teaming, snmp, port_test, public_ip, ssl_cert, data_audit, network_profile, 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, app_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, docker_filesystems, wsl, wsl_filesystems, 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, domain_health, device_health, drivers, peripherals, sessions, permissions, login_history, share_access, registry_audit, thermal, activation, patch_history, ad_user, dns_lookup, hyperv, ip_config, overclocker, event_query, mdm_enrollment, storage_spaces, defender_quarantine, service_dependencies, wmi_health, local_security_policy, usb_history, print_spooler.",
280            other
281        )),
282
283    };
284
285    result.map(|body| annotate_privilege_limited_output(topic.as_str(), body))
286}
287
288fn annotate_privilege_limited_output(topic: &str, body: String) -> String {
289    let Some(scope) = admin_sensitive_topic_scope(topic) else {
290        return body;
291    };
292    let lower = body.to_lowercase();
293    let privilege_limited = lower.contains("access denied")
294        || lower.contains("administrator privilege is required")
295        || lower.contains("administrator privileges required")
296        || lower.contains("requires administrator")
297        || lower.contains("requires elevation")
298        || lower.contains("non-admin session")
299        || lower.contains("could not be fully determined from this session");
300    if !privilege_limited || lower.contains("=== elevation note ===") {
301        return body;
302    }
303
304    let mut annotated = body;
305    annotated.push_str("\n=== Elevation note ===\n");
306    annotated.push_str("- Hematite should stay non-admin by default.\n");
307    annotated.push_str(
308        "- This result may be partial because Windows restricted one or more read-only provider calls in the current session.\n",
309    );
310    let _ = writeln!(
311        annotated,
312        "- Rerun Hematite as Administrator only if you need a definitive {scope} answer."
313    );
314    annotated
315}
316
317fn admin_sensitive_topic_scope(topic: &str) -> Option<&'static str> {
318    match topic {
319        "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
320            Some("TPM / Secure Boot / firmware")
321        }
322        "gpo" | "group_policy" | "applied_policies" => Some("Group Policy"),
323        "audit_policy" | "audit" | "auditpol" => Some("audit policy"),
324        "winrm" | "remote_management" | "psremoting" => Some("WinRM"),
325        "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => Some("BitLocker"),
326        "windows_features" | "optional_features" | "installed_features" | "features" => {
327            Some("Windows Features")
328        }
329        "udp_ports" | "udp_listeners" | "udp" => Some("UDP listener"),
330        _ => None,
331    }
332}
333
334#[cfg(test)]
335mod privilege_hint_tests {
336    use super::annotate_privilege_limited_output;
337
338    #[test]
339    fn annotate_privilege_limited_output_only_tags_admin_sensitive_topics() {
340        let body = "Host inspection: network\nError: Access denied.\n".to_string();
341        let annotated = annotate_privilege_limited_output("network", body.clone());
342        assert_eq!(annotated, body);
343    }
344
345    #[test]
346    fn annotate_privilege_limited_output_adds_targeted_note_for_tpm() {
347        let body = "Host inspection: tpm\n\n=== Findings ===\n- Finding: TPM / Secure Boot state could not be fully determined from this session - firmware mode, privileges, or Windows TPM providers may be limiting visibility.\n".to_string();
348        let annotated = annotate_privilege_limited_output("tpm", body);
349        assert!(annotated.contains("=== Elevation note ==="));
350        assert!(annotated.contains("stay non-admin by default"));
351        assert!(annotated.contains("definitive TPM / Secure Boot / firmware answer"));
352    }
353}
354
355#[cfg(test)]
356mod event_query_tests {
357    use super::is_event_query_no_results_message;
358
359    #[cfg(target_os = "windows")]
360    #[test]
361    fn treats_windows_no_results_message_as_empty_query() {
362        assert!(is_event_query_no_results_message(
363            "No events were found that match the specified selection criteria."
364        ));
365    }
366
367    #[cfg(target_os = "windows")]
368    #[test]
369    fn does_not_treat_real_errors_as_empty_query() {
370        assert!(!is_event_query_no_results_message("Access is denied."));
371    }
372}
373
374fn parse_max_entries(args: &Value) -> usize {
375    args.get("max_entries")
376        .and_then(|v| v.as_u64())
377        .map(|n| n as usize)
378        .unwrap_or(DEFAULT_MAX_ENTRIES)
379        .clamp(1, MAX_ENTRIES_CAP)
380}
381
382fn parse_port_filter(args: &Value) -> Option<u16> {
383    args.get("port")
384        .and_then(|v| v.as_u64())
385        .and_then(|n| u16::try_from(n).ok())
386}
387
388fn parse_name_filter(args: &Value) -> Option<String> {
389    args.get("name")
390        .and_then(|v| v.as_str())
391        .map(str::trim)
392        .filter(|value| !value.is_empty())
393        .map(|value| value.to_string())
394}
395
396fn parse_lookback_hours(args: &Value) -> Option<u32> {
397    args.get("lookback_hours")
398        .and_then(|v| v.as_u64())
399        .map(|n| n as u32)
400}
401
402fn parse_issue_text(args: &Value) -> Option<String> {
403    args.get("issue")
404        .and_then(|v| v.as_str())
405        .map(str::trim)
406        .filter(|value| !value.is_empty())
407        .map(|value| value.to_string())
408}
409
410#[cfg(target_os = "windows")]
411fn is_event_query_no_results_message(message: &str) -> bool {
412    let lower = message.to_ascii_lowercase();
413    lower.contains("no events were found")
414        || lower.contains("no events match the specified selection criteria")
415}
416
417fn resolve_optional_path(args: &Value) -> Result<PathBuf, String> {
418    match args.get("path").and_then(|v| v.as_str()) {
419        Some(raw_path) => resolve_path(raw_path),
420        None => {
421            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))
422        }
423    }
424}
425
426fn inspect_summary(max_entries: usize) -> Result<String, String> {
427    let current_dir =
428        std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
429    let workspace_root = crate::tools::file_ops::workspace_root();
430    let workspace_mode = workspace_mode_label(&workspace_root);
431    let path_stats = analyze_path_env();
432    let toolchains = collect_toolchains();
433
434    let mut out = String::from("Host inspection: summary\n\n");
435    let _ = writeln!(out, "- OS: {}", std::env::consts::OS);
436    let _ = writeln!(out, "- Current directory: {}", current_dir.display());
437    let _ = writeln!(out, "- Workspace root: {}", workspace_root.display());
438    let _ = writeln!(out, "- Workspace mode: {}", workspace_mode);
439    let _ = writeln!(out, "- Preferred shell: {}", preferred_shell_label());
440    let _ = writeln!(
441        out,
442        "- PATH entries: {} total, {} unique, {} duplicates, {} missing",
443        path_stats.total_entries,
444        path_stats.unique_entries,
445        path_stats.duplicate_entries.len(),
446        path_stats.missing_entries.len()
447    );
448
449    if toolchains.found.is_empty() {
450        out.push_str(
451            "- Toolchains found: none of the common developer tools were detected on PATH\n",
452        );
453    } else {
454        out.push_str("- Toolchains found:\n");
455        for (label, version) in toolchains.found.iter().take(max_entries.min(8)) {
456            let _ = writeln!(out, "  - {}: {}", label, version);
457        }
458        if toolchains.found.len() > max_entries.min(8) {
459            let _ = writeln!(
460                out,
461                "  - ... {} more found tools omitted",
462                toolchains.found.len() - max_entries.min(8)
463            );
464        }
465    }
466
467    if !toolchains.missing.is_empty() {
468        let _ = writeln!(
469            out,
470            "- Common tools not detected on PATH: {}",
471            toolchains.missing.join(", ")
472        );
473    }
474
475    for (label, path) in [("Desktop", desktop_dir()), ("Downloads", downloads_dir())] {
476        match path {
477            Some(path) if path.exists() => match count_top_level_items(&path) {
478                Ok(count) => {
479                    let _ = writeln!(
480                        out,
481                        "- {}: {} top-level items at {}",
482                        label,
483                        count,
484                        path.display()
485                    );
486                }
487                Err(e) => {
488                    let _ = writeln!(
489                        out,
490                        "- {}: exists at {} but could not inspect ({})",
491                        label,
492                        path.display(),
493                        e
494                    );
495                }
496            },
497            Some(path) => {
498                let _ = writeln!(
499                    out,
500                    "- {}: expected at {} but not found",
501                    label,
502                    path.display()
503                );
504            }
505            None => {
506                let _ = writeln!(out, "- {}: location unavailable on this host", label);
507            }
508        }
509    }
510
511    Ok(out.trim_end().to_string())
512}
513
514fn inspect_toolchains() -> Result<String, String> {
515    let report = collect_toolchains();
516    let mut out = String::from("Host inspection: toolchains\n\n");
517
518    if report.found.is_empty() {
519        out.push_str("- No common developer tools were detected on PATH.");
520    } else {
521        out.push_str("Detected developer tools:\n");
522        for (label, version) in report.found {
523            let _ = writeln!(out, "- {}: {}", label, version);
524        }
525    }
526
527    if !report.missing.is_empty() {
528        out.push_str("\nNot detected on PATH:\n");
529        for label in report.missing {
530            let _ = writeln!(out, "- {}", label);
531        }
532    }
533
534    Ok(out.trim_end().to_string())
535}
536
537fn inspect_path(max_entries: usize) -> Result<String, String> {
538    let path_stats = analyze_path_env();
539    let mut out = String::from("Host inspection: PATH\n\n");
540    let _ = writeln!(out, "- Total entries: {}", path_stats.total_entries);
541    let _ = writeln!(out, "- Unique entries: {}", path_stats.unique_entries);
542    let _ = writeln!(
543        out,
544        "- Duplicate entries: {}",
545        path_stats.duplicate_entries.len()
546    );
547    let _ = writeln!(out, "- Missing paths: {}", path_stats.missing_entries.len());
548
549    out.push_str("\nPATH entries:\n");
550    for entry in path_stats.entries.iter().take(max_entries) {
551        let _ = writeln!(out, "- {}", entry);
552    }
553    if path_stats.entries.len() > max_entries {
554        let _ = writeln!(
555            out,
556            "- ... {} more entries omitted",
557            path_stats.entries.len() - max_entries
558        );
559    }
560
561    if !path_stats.duplicate_entries.is_empty() {
562        out.push_str("\nDuplicate entries:\n");
563        for entry in path_stats.duplicate_entries.iter().take(max_entries) {
564            let _ = writeln!(out, "- {}", entry);
565        }
566        if path_stats.duplicate_entries.len() > max_entries {
567            let _ = writeln!(
568                out,
569                "- ... {} more duplicates omitted",
570                path_stats.duplicate_entries.len() - max_entries
571            );
572        }
573    }
574
575    if !path_stats.missing_entries.is_empty() {
576        out.push_str("\nMissing directories:\n");
577        for entry in path_stats.missing_entries.iter().take(max_entries) {
578            let _ = writeln!(out, "- {}", entry);
579        }
580        if path_stats.missing_entries.len() > max_entries {
581            let _ = writeln!(
582                out,
583                "- ... {} more missing entries omitted",
584                path_stats.missing_entries.len() - max_entries
585            );
586        }
587    }
588
589    Ok(out.trim_end().to_string())
590}
591
592fn inspect_env_doctor(max_entries: usize) -> Result<String, String> {
593    let path_stats = analyze_path_env();
594    let toolchains = collect_toolchains();
595    let package_managers = collect_package_managers();
596    let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
597
598    let mut out = String::from("Host inspection: env_doctor\n\n");
599    let _ = writeln!(
600        out,
601        "- PATH health: {} duplicates, {} missing entries",
602        path_stats.duplicate_entries.len(),
603        path_stats.missing_entries.len()
604    );
605    let _ = writeln!(out, "- Toolchains found: {}", toolchains.found.len());
606    let _ = writeln!(
607        out,
608        "- Package managers found: {}",
609        package_managers.found.len()
610    );
611
612    if !package_managers.found.is_empty() {
613        out.push_str("\nPackage managers:\n");
614        for (label, version) in package_managers.found.iter().take(max_entries) {
615            let _ = writeln!(out, "- {}: {}", label, version);
616        }
617        if package_managers.found.len() > max_entries {
618            let _ = writeln!(
619                out,
620                "- ... {} more package managers omitted",
621                package_managers.found.len() - max_entries
622            );
623        }
624    }
625
626    if !path_stats.duplicate_entries.is_empty() {
627        out.push_str("\nDuplicate PATH entries:\n");
628        for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
629            let _ = writeln!(out, "- {}", entry);
630        }
631        if path_stats.duplicate_entries.len() > max_entries.min(5) {
632            let _ = writeln!(
633                out,
634                "- ... {} more duplicate entries omitted",
635                path_stats.duplicate_entries.len() - max_entries.min(5)
636            );
637        }
638    }
639
640    if !path_stats.missing_entries.is_empty() {
641        out.push_str("\nMissing PATH entries:\n");
642        for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
643            let _ = writeln!(out, "- {}", entry);
644        }
645        if path_stats.missing_entries.len() > max_entries.min(5) {
646            let _ = writeln!(
647                out,
648                "- ... {} more missing entries omitted",
649                path_stats.missing_entries.len() - max_entries.min(5)
650            );
651        }
652    }
653
654    if !findings.is_empty() {
655        out.push_str("\nFindings:\n");
656        for finding in findings.iter().take(max_entries.max(5)) {
657            let _ = writeln!(out, "- {}", finding);
658        }
659        if findings.len() > max_entries.max(5) {
660            let _ = writeln!(
661                out,
662                "- ... {} more findings omitted",
663                findings.len() - max_entries.max(5)
664            );
665        }
666    } else {
667        out.push_str("\nFindings:\n- No obvious environment drift was detected from PATH and package-manager checks.");
668    }
669
670    out.push_str(
671        "\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.",
672    );
673
674    Ok(out.trim_end().to_string())
675}
676
677#[derive(Clone, Copy, Debug, Eq, PartialEq)]
678enum FixPlanKind {
679    EnvPath,
680    PortConflict,
681    LmStudio,
682    DriverInstall,
683    GroupPolicy,
684    FirewallRule,
685    SshKey,
686    WslSetup,
687    ServiceConfig,
688    WindowsActivation,
689    RegistryEdit,
690    ScheduledTaskCreate,
691    DiskCleanup,
692    DnsResolution,
693    Generic,
694}
695
696async fn inspect_fix_plan(
697    issue: Option<String>,
698    port_filter: Option<u16>,
699    max_entries: usize,
700) -> Result<String, String> {
701    let issue = issue.unwrap_or_else(|| {
702        "Help me fix PATH, toolchain, port-conflict, or LM Studio connectivity problems."
703            .to_string()
704    });
705    let plan_kind = classify_fix_plan_kind(&issue, port_filter);
706    match plan_kind {
707        FixPlanKind::EnvPath => inspect_env_fix_plan(&issue, max_entries),
708        FixPlanKind::PortConflict => inspect_port_fix_plan(&issue, port_filter, max_entries),
709        FixPlanKind::LmStudio => inspect_lm_studio_fix_plan(&issue, max_entries).await,
710        FixPlanKind::DriverInstall => inspect_driver_install_fix_plan(&issue),
711        FixPlanKind::GroupPolicy => inspect_group_policy_fix_plan(&issue),
712        FixPlanKind::FirewallRule => inspect_firewall_rule_fix_plan(&issue),
713        FixPlanKind::SshKey => inspect_ssh_key_fix_plan(&issue),
714        FixPlanKind::WslSetup => inspect_wsl_setup_fix_plan(&issue),
715        FixPlanKind::ServiceConfig => inspect_service_config_fix_plan(&issue),
716        FixPlanKind::WindowsActivation => inspect_windows_activation_fix_plan(&issue),
717        FixPlanKind::RegistryEdit => inspect_registry_edit_fix_plan(&issue),
718        FixPlanKind::ScheduledTaskCreate => inspect_scheduled_task_fix_plan(&issue),
719        FixPlanKind::DiskCleanup => inspect_disk_cleanup_fix_plan(&issue),
720        FixPlanKind::DnsResolution => inspect_dns_fix_plan(&issue),
721        FixPlanKind::Generic => inspect_generic_fix_plan(&issue),
722    }
723}
724
725fn classify_fix_plan_kind(issue: &str, port_filter: Option<u16>) -> FixPlanKind {
726    let lower = issue.to_ascii_lowercase();
727    // FirewallRule must be checked before PortConflict — "open port 80 in the firewall"
728    // is firewall rule creation, not a port ownership conflict.
729    if lower.contains("firewall rule")
730        || lower.contains("inbound rule")
731        || lower.contains("outbound rule")
732        || (lower.contains("firewall")
733            && (lower.contains("allow")
734                || lower.contains("block")
735                || lower.contains("create")
736                || lower.contains("open")))
737    {
738        FixPlanKind::FirewallRule
739    } else if port_filter.is_some()
740        || lower.contains("port ")
741        || lower.contains("address already in use")
742        || lower.contains("already in use")
743        || lower.contains("what owns port")
744        || lower.contains("listening on port")
745    {
746        FixPlanKind::PortConflict
747    } else if lower.contains("lm studio")
748        || lower.contains("localhost:1234")
749        || lower.contains("/v1/models")
750        || lower.contains("no coding model loaded")
751        || lower.contains("embedding model")
752        || lower.contains("server on port 1234")
753        || lower.contains("runtime refresh")
754    {
755        FixPlanKind::LmStudio
756    } else if lower.contains("driver")
757        || lower.contains("gpu driver")
758        || lower.contains("nvidia driver")
759        || lower.contains("amd driver")
760        || lower.contains("install driver")
761        || lower.contains("update driver")
762    {
763        FixPlanKind::DriverInstall
764    } else if lower.contains("group policy")
765        || lower.contains("gpedit")
766        || lower.contains("local policy")
767        || lower.contains("secpol")
768        || lower.contains("administrative template")
769    {
770        FixPlanKind::GroupPolicy
771    } else if lower.contains("ssh key")
772        || lower.contains("ssh-keygen")
773        || lower.contains("generate ssh")
774        || lower.contains("authorized_keys")
775        || lower.contains("id_rsa")
776        || lower.contains("id_ed25519")
777    {
778        FixPlanKind::SshKey
779    } else if lower.contains("wsl")
780        || lower.contains("windows subsystem for linux")
781        || lower.contains("install ubuntu")
782        || lower.contains("install linux on windows")
783        || lower.contains("wsl2")
784    {
785        FixPlanKind::WslSetup
786    } else if lower.contains("service")
787        && (lower.contains("start ")
788            || lower.contains("stop ")
789            || lower.contains("restart ")
790            || lower.contains("enable ")
791            || lower.contains("disable ")
792            || lower.contains("configure service"))
793    {
794        FixPlanKind::ServiceConfig
795    } else if lower.contains("activate windows")
796        || lower.contains("windows activation")
797        || lower.contains("product key")
798        || lower.contains("kms")
799        || lower.contains("not activated")
800    {
801        FixPlanKind::WindowsActivation
802    } else if lower.contains("registry")
803        || lower.contains("regedit")
804        || lower.contains("hklm")
805        || lower.contains("hkcu")
806        || lower.contains("reg add")
807        || lower.contains("reg delete")
808        || lower.contains("registry key")
809    {
810        FixPlanKind::RegistryEdit
811    } else if lower.contains("scheduled task")
812        || lower.contains("task scheduler")
813        || lower.contains("schtasks")
814        || lower.contains("create task")
815        || lower.contains("run on startup")
816        || lower.contains("run on schedule")
817        || lower.contains("cron")
818    {
819        FixPlanKind::ScheduledTaskCreate
820    } else if lower.contains("disk cleanup")
821        || lower.contains("free up disk")
822        || lower.contains("free up space")
823        || lower.contains("clear cache")
824        || lower.contains("disk full")
825        || lower.contains("low disk space")
826        || lower.contains("reclaim space")
827    {
828        FixPlanKind::DiskCleanup
829    } else if lower.contains("cargo")
830        || lower.contains("rustc")
831        || lower.contains("path")
832        || lower.contains("package manager")
833        || lower.contains("package managers")
834        || lower.contains("toolchain")
835        || lower.contains("winget")
836        || lower.contains("choco")
837        || lower.contains("scoop")
838        || lower.contains("python")
839        || lower.contains("node")
840    {
841        FixPlanKind::EnvPath
842    } else if lower.contains("dns ")
843        || lower.contains("nameserver")
844        || lower.contains("cannot resolve")
845        || lower.contains("nslookup")
846        || lower.contains("flushdns")
847    {
848        FixPlanKind::DnsResolution
849    } else {
850        FixPlanKind::Generic
851    }
852}
853
854fn inspect_env_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
855    let path_stats = analyze_path_env();
856    let toolchains = collect_toolchains();
857    let package_managers = collect_package_managers();
858    let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
859    let found_tools = toolchains
860        .found
861        .iter()
862        .map(|(label, _)| label.as_str())
863        .collect::<HashSet<_>>();
864    let found_managers = package_managers
865        .found
866        .iter()
867        .map(|(label, _)| label.as_str())
868        .collect::<HashSet<_>>();
869
870    let mut out = String::from("Host inspection: fix_plan\n\n");
871    let _ = writeln!(out, "- Requested issue: {}", issue);
872    out.push_str("- Fix-plan type: environment/path\n");
873    let _ = writeln!(
874        out,
875        "- PATH health: {} duplicates, {} missing entries",
876        path_stats.duplicate_entries.len(),
877        path_stats.missing_entries.len()
878    );
879    let _ = writeln!(out, "- Toolchains found: {}", toolchains.found.len());
880    let _ = writeln!(
881        out,
882        "- Package managers found: {}",
883        package_managers.found.len()
884    );
885
886    out.push_str("\nLikely causes:\n");
887    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
888        out.push_str(
889            "- 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",
890        );
891    }
892    if path_stats.duplicate_entries.is_empty()
893        && path_stats.missing_entries.is_empty()
894        && !findings.is_empty()
895    {
896        for finding in findings.iter().take(max_entries.max(4)) {
897            let _ = writeln!(out, "- {}", finding);
898        }
899    } else {
900        if !path_stats.duplicate_entries.is_empty() {
901            out.push_str("- Duplicate PATH rows create clutter and can hide which install path is actually winning.\n");
902        }
903        if !path_stats.missing_entries.is_empty() {
904            out.push_str("- Stale PATH rows point at directories that no longer exist, which makes environment drift harder to reason about.\n");
905        }
906    }
907    if found_tools.contains("node")
908        && !found_managers.contains("npm")
909        && !found_managers.contains("pnpm")
910    {
911        out.push_str("- Node is present without a detected package manager. That usually means a partial install or PATH drift.\n");
912    }
913    if found_tools.contains("python")
914        && !found_managers.contains("pip")
915        && !found_managers.contains("uv")
916        && !found_managers.contains("pipx")
917    {
918        out.push_str("- Python is present without a detected package manager. That usually means the launcher works but Scripts/bin is not discoverable.\n");
919    }
920
921    out.push_str("\nFix plan:\n");
922    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");
923    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
924        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");
925    } else if !found_tools.contains("rustc") && !found_managers.contains("cargo") {
926        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");
927    }
928    if !path_stats.duplicate_entries.is_empty() || !path_stats.missing_entries.is_empty() {
929        out.push_str("- Clean duplicate or dead PATH rows in Environment Variables so the winning toolchain path is obvious and stable.\n");
930    }
931    if found_tools.contains("node")
932        && !found_managers.contains("npm")
933        && !found_managers.contains("pnpm")
934    {
935        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");
936    }
937    if found_tools.contains("python")
938        && !found_managers.contains("pip")
939        && !found_managers.contains("uv")
940        && !found_managers.contains("pipx")
941    {
942        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");
943    }
944
945    if !path_stats.duplicate_entries.is_empty() {
946        out.push_str("\nExample duplicate PATH rows:\n");
947        for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
948            let _ = writeln!(out, "- {}", entry);
949        }
950    }
951    if !path_stats.missing_entries.is_empty() {
952        out.push_str("\nExample missing PATH rows:\n");
953        for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
954            let _ = writeln!(out, "- {}", entry);
955        }
956    }
957
958    out.push_str(
959        "\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.",
960    );
961    Ok(out.trim_end().to_string())
962}
963
964fn inspect_port_fix_plan(
965    issue: &str,
966    port_filter: Option<u16>,
967    max_entries: usize,
968) -> Result<String, String> {
969    let requested_port = port_filter.or_else(|| first_port_in_text(issue));
970    let listeners = collect_listening_ports().unwrap_or_default();
971    let mut matching = listeners;
972    if let Some(port) = requested_port {
973        matching.retain(|entry| entry.port == port);
974    }
975    let processes = collect_processes().unwrap_or_default();
976
977    let mut out = String::from("Host inspection: fix_plan\n\n");
978    let _ = writeln!(out, "- Requested issue: {}", issue);
979    out.push_str("- Fix-plan type: port_conflict\n");
980    if let Some(port) = requested_port {
981        let _ = writeln!(out, "- Requested port: {}", port);
982    } else {
983        out.push_str("- Requested port: not parsed from the issue text\n");
984    }
985    let _ = writeln!(out, "- Matching listeners found: {}", matching.len());
986
987    if !matching.is_empty() {
988        out.push_str("\nCurrent listeners:\n");
989        for entry in matching.iter().take(max_entries.min(5)) {
990            let process_name = entry
991                .pid
992                .as_deref()
993                .and_then(|pid| pid.parse::<u32>().ok())
994                .and_then(|pid| {
995                    processes
996                        .iter()
997                        .find(|process| process.pid == pid)
998                        .map(|process| process.name.as_str())
999                })
1000                .unwrap_or("unknown");
1001            let pid = entry.pid.as_deref().unwrap_or("unknown");
1002            let _ = writeln!(
1003                out,
1004                "- {} {} ({}) pid {} process {}",
1005                entry.protocol, entry.local, entry.state, pid, process_name
1006            );
1007        }
1008    }
1009
1010    out.push_str("\nFix plan:\n");
1011    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");
1012    if !matching.is_empty() {
1013        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");
1014    } else {
1015        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");
1016    }
1017    out.push_str("- If the port is intentionally occupied, move your app to another port instead of fighting the existing process.\n");
1018    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");
1019    out.push_str(
1020        "\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.",
1021    );
1022    Ok(out.trim_end().to_string())
1023}
1024
1025async fn inspect_lm_studio_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
1026    let config = crate::agent::config::load_config();
1027    let configured_api = config
1028        .api_url
1029        .unwrap_or_else(|| "http://localhost:1234/v1".to_string());
1030    let models_url = format!("{}/models", configured_api.trim_end_matches('/'));
1031    let reachability = probe_http_endpoint(&models_url).await;
1032    let embed_model = detect_loaded_embed_model(&configured_api).await;
1033
1034    let mut out = String::from("Host inspection: fix_plan\n\n");
1035    let _ = writeln!(out, "- Requested issue: {}", issue);
1036    out.push_str("- Fix-plan type: lm_studio\n");
1037    let _ = writeln!(out, "- Configured API URL: {}", configured_api);
1038    let _ = writeln!(out, "- Probe URL: {}", models_url);
1039    match &reachability {
1040        EndpointProbe::Reachable(status) => {
1041            let _ = writeln!(out, "- Endpoint reachable: yes (HTTP {})", status);
1042        }
1043        EndpointProbe::Unreachable(detail) => {
1044            let _ = writeln!(out, "- Endpoint reachable: no ({})", detail);
1045        }
1046    }
1047    let _ = writeln!(
1048        out,
1049        "- Embedding model loaded: {}",
1050        embed_model.as_deref().unwrap_or("none detected")
1051    );
1052
1053    out.push_str("\nFix plan:\n");
1054    match reachability {
1055        EndpointProbe::Reachable(_) => {
1056            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");
1057        }
1058        EndpointProbe::Unreachable(_) => {
1059            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");
1060        }
1061    }
1062    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");
1063    out.push_str("- If chat works but semantic search does not, load an embedding model as a second resident local model. Hematite expects a `nomic-embed` or similar embedding model there.\n");
1064    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");
1065    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");
1066    if let Some(model) = embed_model {
1067        let _ = writeln!(out,
1068            "- Current embedding model already visible: {}. That means the embeddings lane is configured, so focus on the chat model or endpoint next.",
1069            model
1070        );
1071    }
1072    if max_entries > 0 {
1073        out.push_str(
1074            "\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.",
1075        );
1076    }
1077    Ok(out.trim_end().to_string())
1078}
1079
1080fn inspect_driver_install_fix_plan(issue: &str) -> Result<String, String> {
1081    // Read GPU info from the hardware topic output for grounding
1082    #[cfg(target_os = "windows")]
1083    let gpu_info = {
1084        let out = Command::new("powershell")
1085            .args([
1086                "-NoProfile",
1087                "-NonInteractive",
1088                "-Command",
1089                "Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate | ForEach-Object { \"GPU: $($_.Name) | Driver: $($_.DriverVersion) | Date: $($_.DriverDate)\" }",
1090            ])
1091            .output()
1092            .ok()
1093            .and_then(|o| String::from_utf8(o.stdout).ok())
1094            .unwrap_or_default();
1095        out.trim().to_string()
1096    };
1097    #[cfg(not(target_os = "windows"))]
1098    let gpu_info = String::from("(GPU detection not available on this platform)");
1099
1100    let mut out = String::from("Host inspection: fix_plan\n\n");
1101    let _ = writeln!(out, "- Requested issue: {}", issue);
1102    out.push_str("- Fix-plan type: driver_install\n");
1103    if !gpu_info.is_empty() {
1104        let _ = write!(out, "\nDetected GPU(s):\n{}\n", gpu_info);
1105    }
1106    out.push_str("\nFix plan — Installing or updating GPU drivers:\n");
1107    out.push_str("1. Identify your GPU make from the detection above (NVIDIA, AMD, or Intel).\n");
1108    out.push_str(
1109        "2. Open Device Manager: press Win+X → Device Manager → expand Display Adapters.\n",
1110    );
1111    out.push_str("3. Right-click your GPU → Properties → Driver tab — note the current driver version and date.\n");
1112    out.push_str("4. Download the latest driver directly from the manufacturer:\n");
1113    out.push_str("   - NVIDIA: geforce.com/drivers (use GeForce Experience for auto-detection)\n");
1114    out.push_str("   - AMD: amd.com/support (use Auto-Detect tool)\n");
1115    out.push_str("   - Intel: intel.com/content/www/us/en/download-center/home.html\n");
1116    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");
1117    out.push_str("6. Reboot when prompted — driver installs always require a restart.\n");
1118    out.push_str("\nVerification:\n");
1119    out.push_str("- After reboot, run in PowerShell:\n  Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate\n");
1120    out.push_str("- The DriverVersion should match what you installed.\n");
1121    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.");
1122    Ok(out.trim_end().to_string())
1123}
1124
1125fn inspect_group_policy_fix_plan(issue: &str) -> Result<String, String> {
1126    // Check Windows edition — Group Policy editor is not available on Home editions
1127    #[cfg(target_os = "windows")]
1128    let edition = {
1129        Command::new("powershell")
1130            .args([
1131                "-NoProfile",
1132                "-NonInteractive",
1133                "-Command",
1134                "(Get-CimInstance Win32_OperatingSystem).Caption",
1135            ])
1136            .output()
1137            .ok()
1138            .and_then(|o| String::from_utf8(o.stdout).ok())
1139            .unwrap_or_default()
1140            .trim()
1141            .to_string()
1142    };
1143    #[cfg(not(target_os = "windows"))]
1144    let edition = String::from("(Windows edition detection not available)");
1145
1146    let is_home = edition.to_lowercase().contains("home");
1147
1148    let mut out = String::from("Host inspection: fix_plan\n\n");
1149    let _ = writeln!(out, "- Requested issue: {}", issue);
1150    out.push_str("- Fix-plan type: group_policy\n");
1151    let _ = writeln!(
1152        out,
1153        "- Windows edition detected: {}",
1154        if edition.is_empty() {
1155            "unknown".to_string()
1156        } else {
1157            edition.clone()
1158        }
1159    );
1160
1161    if is_home {
1162        out.push_str("\nWARNING: Windows Home does not include the Local Group Policy Editor (gpedit.msc).\n");
1163        out.push_str("Options on Home edition:\n");
1164        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");
1165        out.push_str(
1166            "2. Install the gpedit.msc enabler script (third-party — use with caution).\n",
1167        );
1168        out.push_str("3. Upgrade to Windows Pro if you need full Group Policy support.\n");
1169    } else {
1170        out.push_str("\nFix plan — Editing Local Group Policy:\n");
1171        out.push_str("1. Press Win+R → type gpedit.msc → press Enter (requires administrator).\n");
1172        out.push_str("2. Navigate the tree: Computer Configuration (machine-wide) or User Configuration (current user).\n");
1173        out.push_str("3. Drill into Administrative Templates → find the policy you want.\n");
1174        out.push_str("4. Double-click a policy → set to Enabled, Disabled, or Not Configured.\n");
1175        out.push_str("5. Click OK — most policies apply on next logon or after gpupdate.\n");
1176        out.push_str("6. To force immediate application, run in an elevated PowerShell:\n  gpupdate /force\n");
1177    }
1178    out.push_str("\nVerification:\n");
1179    out.push_str("- Run `gpresult /r` in an elevated command prompt to see applied policies.\n");
1180    out.push_str(
1181        "- Or: `Get-GPResultantSetOfPolicy` in PowerShell (requires RSAT on domain machines).\n",
1182    );
1183    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.");
1184    Ok(out.trim_end().to_string())
1185}
1186
1187fn inspect_firewall_rule_fix_plan(issue: &str) -> Result<String, String> {
1188    #[cfg(target_os = "windows")]
1189    let profile_state = {
1190        Command::new("powershell")
1191            .args([
1192                "-NoProfile",
1193                "-NonInteractive",
1194                "-Command",
1195                "Get-NetFirewallProfile | Select-Object Name,Enabled | ForEach-Object { \"$($_.Name): $($_.Enabled)\" }",
1196            ])
1197            .output()
1198            .ok()
1199            .and_then(|o| String::from_utf8(o.stdout).ok())
1200            .unwrap_or_default()
1201            .trim()
1202            .to_string()
1203    };
1204    #[cfg(not(target_os = "windows"))]
1205    let profile_state = String::new();
1206
1207    let mut out = String::from("Host inspection: fix_plan\n\n");
1208    let _ = writeln!(out, "- Requested issue: {}", issue);
1209    out.push_str("- Fix-plan type: firewall_rule\n");
1210    if !profile_state.is_empty() {
1211        let _ = write!(out, "\nFirewall profile state:\n{}\n", profile_state);
1212    }
1213    out.push_str("\nFix plan — Creating or modifying a Windows Firewall rule (PowerShell, run as Administrator):\n");
1214    out.push_str("\nTo ALLOW inbound traffic on a port:\n");
1215    out.push_str("  New-NetFirewallRule -DisplayName \"My App Port 8080\" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any\n");
1216    out.push_str("\nTo BLOCK outbound traffic to an address:\n");
1217    out.push_str("  New-NetFirewallRule -DisplayName \"Block Example\" -Direction Outbound -RemoteAddress 1.2.3.4 -Action Block\n");
1218    out.push_str("\nTo ALLOW an application through the firewall:\n");
1219    out.push_str("  New-NetFirewallRule -DisplayName \"My App\" -Direction Inbound -Program \"C:\\Path\\To\\App.exe\" -Action Allow\n");
1220    out.push_str("\nTo REMOVE a rule you created:\n");
1221    out.push_str("  Remove-NetFirewallRule -DisplayName \"My App Port 8080\"\n");
1222    out.push_str("\nTo see existing custom rules:\n");
1223    out.push_str("  Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.PolicyStoreSourceType -ne 'GroupPolicy' } | Select-Object DisplayName,Direction,Action\n");
1224    out.push_str("\nVerification:\n");
1225    out.push_str("- After creating the rule, test reachability from another machine or use:\n  Test-NetConnection -ComputerName localhost -Port 8080\n");
1226    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.");
1227    Ok(out.trim_end().to_string())
1228}
1229
1230fn inspect_ssh_key_fix_plan(issue: &str) -> Result<String, String> {
1231    let home = dirs_home().unwrap_or_else(|| std::path::PathBuf::from("~"));
1232    let ssh_dir = home.join(".ssh");
1233    let has_ssh_dir = ssh_dir.exists();
1234    let has_ed25519 = ssh_dir.join("id_ed25519").exists();
1235    let has_rsa = ssh_dir.join("id_rsa").exists();
1236    let has_authorized_keys = ssh_dir.join("authorized_keys").exists();
1237
1238    let mut out = String::from("Host inspection: fix_plan\n\n");
1239    let _ = writeln!(out, "- Requested issue: {}", issue);
1240    out.push_str("- Fix-plan type: ssh_key\n");
1241    let _ = writeln!(out, "- ~/.ssh directory exists: {}", has_ssh_dir);
1242    let _ = writeln!(out, "- id_ed25519 key found: {}", has_ed25519);
1243    let _ = writeln!(out, "- id_rsa key found: {}", has_rsa);
1244    let _ = writeln!(out, "- authorized_keys found: {}", has_authorized_keys);
1245
1246    if has_ed25519 {
1247        out.push_str("\nYou already have an Ed25519 key. If you want to use it, skip to the 'Add to agent' step.\n");
1248    }
1249
1250    out.push_str("\nFix plan — Generating an SSH key pair:\n");
1251    out.push_str("1. Open PowerShell (or Terminal) — no elevation needed.\n");
1252    out.push_str("2. Generate an Ed25519 key (preferred over RSA):\n");
1253    out.push_str("   ssh-keygen -t ed25519 -C \"your@email.com\"\n");
1254    out.push_str(
1255        "   - Accept the default path (~/.ssh/id_ed25519) unless you need a custom name.\n",
1256    );
1257    out.push_str("   - Set a passphrase (recommended) or press Enter twice for no passphrase.\n");
1258    out.push_str("3. Start the SSH agent and add your key:\n");
1259    out.push_str("   # Windows (PowerShell, run as Admin once to enable the service):\n");
1260    out.push_str("   Set-Service -Name ssh-agent -StartupType Automatic\n");
1261    out.push_str("   Start-Service ssh-agent\n");
1262    out.push_str("   # Then add the key (normal PowerShell):\n");
1263    out.push_str("   ssh-add ~/.ssh/id_ed25519\n");
1264    out.push_str("4. Copy your PUBLIC key to the target server's authorized_keys:\n");
1265    out.push_str("   # Print your public key:\n");
1266    out.push_str("   cat ~/.ssh/id_ed25519.pub\n");
1267    out.push_str("   # On the target server, append it:\n");
1268    out.push_str("   echo \"<paste public key>\" >> ~/.ssh/authorized_keys\n");
1269    out.push_str("   chmod 600 ~/.ssh/authorized_keys\n");
1270    out.push_str("5. Test the connection:\n");
1271    out.push_str("   ssh user@server-address\n");
1272    out.push_str("\nFor GitHub/GitLab:\n");
1273    out.push_str("- Copy the public key: Get-Content ~/.ssh/id_ed25519.pub | Set-Clipboard\n");
1274    out.push_str("- Paste it into GitHub Settings → SSH and GPG keys → New SSH key\n");
1275    out.push_str("- Test: ssh -T git@github.com\n");
1276    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.");
1277    Ok(out.trim_end().to_string())
1278}
1279
1280fn inspect_wsl_setup_fix_plan(issue: &str) -> Result<String, String> {
1281    #[cfg(target_os = "windows")]
1282    let wsl_status = {
1283        let out = Command::new("wsl")
1284            .args(["--status"])
1285            .output()
1286            .ok()
1287            .map(|o| {
1288                let stdout = String::from_utf8(o.stdout).unwrap_or_default();
1289                let stderr = String::from_utf8(o.stderr).unwrap_or_default();
1290                format!("{}{}", stdout, stderr)
1291            })
1292            .unwrap_or_default();
1293        out.trim().to_string()
1294    };
1295    #[cfg(not(target_os = "windows"))]
1296    let wsl_status = String::new();
1297
1298    let wsl_installed =
1299        !wsl_status.is_empty() && !wsl_status.to_lowercase().contains("not installed");
1300
1301    let mut out = String::from("Host inspection: fix_plan\n\n");
1302    let _ = writeln!(out, "- Requested issue: {}", issue);
1303    out.push_str("- Fix-plan type: wsl_setup\n");
1304    let _ = writeln!(out, "- WSL already installed: {}", wsl_installed);
1305    if !wsl_status.is_empty() {
1306        let _ = write!(out, "- WSL status:\n{}\n", wsl_status);
1307    }
1308
1309    if wsl_installed {
1310        out.push_str("\nWSL is already installed. To install a new Linux distro:\n");
1311        out.push_str("1. Run in PowerShell (Admin): wsl --install -d Ubuntu\n");
1312        out.push_str("   Available distros: wsl --list --online\n");
1313        out.push_str("2. After install, launch from Start menu or type 'ubuntu' in PowerShell.\n");
1314        out.push_str("3. Create your Linux username and password when prompted.\n");
1315    } else {
1316        out.push_str("\nFix plan — Installing WSL2 (Windows Subsystem for Linux):\n");
1317        out.push_str("1. Open PowerShell as Administrator.\n");
1318        out.push_str("2. Install WSL with the default Ubuntu distro:\n");
1319        out.push_str("   wsl --install\n");
1320        out.push_str("   (This enables the required Windows features, downloads WSL2, and installs Ubuntu)\n");
1321        out.push_str("3. Reboot when prompted — WSL requires a restart after the first install.\n");
1322        out.push_str("4. After reboot, Ubuntu will launch automatically and ask you to create a username and password.\n");
1323        out.push_str("5. Set WSL2 as the default version (should already be set, but confirm):\n");
1324        out.push_str("   wsl --set-default-version 2\n");
1325        out.push_str("\nTo install a different distro instead of Ubuntu:\n");
1326        out.push_str("   wsl --install -d Debian\n");
1327        out.push_str("   wsl --list --online   # to see all available distros\n");
1328    }
1329    out.push_str("\nVerification:\n");
1330    out.push_str("- Run: wsl --list --verbose\n");
1331    out.push_str("- You should see your distro with State: Running and Version: 2\n");
1332    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.");
1333    Ok(out.trim_end().to_string())
1334}
1335
1336fn inspect_service_config_fix_plan(issue: &str) -> Result<String, String> {
1337    let lower = issue.to_ascii_lowercase();
1338    // Extract service name hints from the issue text
1339    let service_hint = if lower.contains("ssh") {
1340        Some("sshd")
1341    } else if lower.contains("mysql") {
1342        Some("MySQL80")
1343    } else if lower.contains("postgres") || lower.contains("postgresql") {
1344        Some("postgresql")
1345    } else if lower.contains("redis") {
1346        Some("Redis")
1347    } else if lower.contains("nginx") {
1348        Some("nginx")
1349    } else if lower.contains("apache") {
1350        Some("Apache2.4")
1351    } else {
1352        None
1353    };
1354
1355    #[cfg(target_os = "windows")]
1356    let service_state = if let Some(svc) = service_hint {
1357        Command::new("powershell")
1358            .args([
1359                "-NoProfile",
1360                "-NonInteractive",
1361                "-Command",
1362                &format!("Get-Service -Name '{}' -ErrorAction SilentlyContinue | Select-Object Name,Status,StartType | ForEach-Object {{ \"Service: $($_.Name) | Status: $($_.Status) | StartType: $($_.StartType)\" }}", svc),
1363            ])
1364            .output()
1365            .ok()
1366            .and_then(|o| String::from_utf8(o.stdout).ok())
1367            .unwrap_or_default()
1368            .trim()
1369            .to_string()
1370    } else {
1371        String::new()
1372    };
1373    #[cfg(not(target_os = "windows"))]
1374    let service_state = String::new();
1375
1376    let mut out = String::from("Host inspection: fix_plan\n\n");
1377    let _ = writeln!(out, "- Requested issue: {}", issue);
1378    out.push_str("- Fix-plan type: service_config\n");
1379    if let Some(svc) = service_hint {
1380        let _ = writeln!(out, "- Service detected in request: {}", svc);
1381    }
1382    if !service_state.is_empty() {
1383        let _ = writeln!(out, "- Current state: {}", service_state);
1384    }
1385
1386    out.push_str("\nFix plan — Managing Windows services (PowerShell, run as Administrator):\n");
1387    out.push_str("\nStart a service:\n");
1388    out.push_str("  Start-Service -Name \"ServiceName\"\n");
1389    out.push_str("\nStop a service:\n");
1390    out.push_str("  Stop-Service -Name \"ServiceName\"\n");
1391    out.push_str("\nRestart a service:\n");
1392    out.push_str("  Restart-Service -Name \"ServiceName\"\n");
1393    out.push_str("\nEnable a service to start automatically:\n");
1394    out.push_str("  Set-Service -Name \"ServiceName\" -StartupType Automatic\n");
1395    out.push_str("\nDisable a service (stops it from auto-starting):\n");
1396    out.push_str("  Set-Service -Name \"ServiceName\" -StartupType Disabled\n");
1397    out.push_str("\nFind the exact service name:\n");
1398    out.push_str("  Get-Service | Where-Object { $_.DisplayName -like '*mysql*' }\n");
1399    out.push_str("\nVerification:\n");
1400    out.push_str("  Get-Service -Name \"ServiceName\" | Select-Object Name,Status,StartType\n");
1401    if let Some(svc) = service_hint {
1402        let _ = write!(
1403            out,
1404            "\nFor your detected service ({}):\n  Get-Service -Name '{}'\n",
1405            svc, svc
1406        );
1407    }
1408    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.");
1409    Ok(out.trim_end().to_string())
1410}
1411
1412fn inspect_windows_activation_fix_plan(issue: &str) -> Result<String, String> {
1413    #[cfg(target_os = "windows")]
1414    let activation_status = {
1415        Command::new("powershell")
1416            .args([
1417                "-NoProfile",
1418                "-NonInteractive",
1419                "-Command",
1420                "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 + ')' })\" }",
1421            ])
1422            .output()
1423            .ok()
1424            .and_then(|o| String::from_utf8(o.stdout).ok())
1425            .unwrap_or_default()
1426            .trim()
1427            .to_string()
1428    };
1429    #[cfg(not(target_os = "windows"))]
1430    let activation_status = String::new();
1431
1432    let activation_lower = activation_status.to_lowercase();
1433    let is_licensed =
1434        activation_lower.contains("licensed") && !activation_lower.contains("not licensed");
1435
1436    let mut out = String::from("Host inspection: fix_plan\n\n");
1437    let _ = writeln!(out, "- Requested issue: {}", issue);
1438    out.push_str("- Fix-plan type: windows_activation\n");
1439    if !activation_status.is_empty() {
1440        let _ = write!(out, "- Current activation state:\n{}\n", activation_status);
1441    }
1442
1443    if is_licensed {
1444        out.push_str(
1445            "\nWindows appears to be activated. If you are still seeing activation prompts, try:\n",
1446        );
1447        out.push_str("1. Run in elevated PowerShell: slmgr /ato\n");
1448        out.push_str("   (Forces an online activation attempt)\n");
1449        out.push_str("2. Check activation details: slmgr /dli\n");
1450    } else {
1451        out.push_str("\nFix plan — Activating Windows:\n");
1452        out.push_str("1. Check your current status first:\n");
1453        out.push_str("   slmgr /dli   (basic info)\n");
1454        out.push_str("   slmgr /dlv   (detailed — shows remaining rearms, grace period)\n");
1455        out.push_str("\n2. If you have a retail product key:\n");
1456        out.push_str("   slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX   (install key)\n");
1457        out.push_str("   slmgr /ato                                   (activate online)\n");
1458        out.push_str("\n3. If you had a digital license (linked to your Microsoft account):\n");
1459        out.push_str("   - Go to Settings → System → Activation\n");
1460        out.push_str("   - Click 'Troubleshoot' → 'I changed hardware on this device recently'\n");
1461        out.push_str("   - Sign in with the Microsoft account that holds the license\n");
1462        out.push_str("\n4. If using a volume license (organization/enterprise):\n");
1463        out.push_str("   - Contact your IT department for the KMS server address\n");
1464        out.push_str("   - Set KMS host: slmgr /skms kms.yourorg.com\n");
1465        out.push_str("   - Activate:    slmgr /ato\n");
1466    }
1467    out.push_str("\nVerification:\n");
1468    out.push_str("  slmgr /dli   — should show 'License Status: Licensed'\n");
1469    out.push_str("  Or: Settings → System → Activation → 'Windows is activated'\n");
1470    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.");
1471    Ok(out.trim_end().to_string())
1472}
1473
1474fn inspect_registry_edit_fix_plan(issue: &str) -> Result<String, String> {
1475    let mut out = String::from("Host inspection: fix_plan\n\n");
1476    let _ = writeln!(out, "- Requested issue: {}", issue);
1477    out.push_str("- Fix-plan type: registry_edit\n");
1478    out.push_str(
1479        "\nCAUTION: Registry edits affect core Windows behavior. Always back up before editing.\n",
1480    );
1481    out.push_str("\nFix plan — Safely editing the Windows Registry:\n");
1482    out.push_str("\n1. Back up before you touch anything:\n");
1483    out.push_str("   # Export the key you're about to change (PowerShell):\n");
1484    out.push_str("   reg export \"HKLM\\SOFTWARE\\MyKey\" C:\\backup\\MyKey_backup.reg\n");
1485    out.push_str("   # Or export the whole registry (takes a while):\n");
1486    out.push_str("   reg export HKLM C:\\backup\\HKLM_full.reg\n");
1487    out.push_str("\n2. Read a value (PowerShell, no elevation needed for HKCU):\n");
1488    out.push_str("   Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1489    out.push_str("\n3. Create or update a DWORD value (PowerShell, Admin for HKLM):\n");
1490    out.push_str(
1491        "   Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue' -Value 1 -Type DWord\n",
1492    );
1493    out.push_str("\n4. Create a new key:\n");
1494    out.push_str("   New-Item -Path 'HKLM:\\SOFTWARE\\MyNewKey' -Force\n");
1495    out.push_str("\n5. Delete a value:\n");
1496    out.push_str("   Remove-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1497    out.push_str("\n6. Restore from backup if something breaks:\n");
1498    out.push_str("   reg import C:\\backup\\MyKey_backup.reg\n");
1499    out.push_str("\nCommon registry hives:\n");
1500    out.push_str("  HKLM = HKEY_LOCAL_MACHINE  (machine-wide, requires Admin)\n");
1501    out.push_str("  HKCU = HKEY_CURRENT_USER   (current user, no elevation needed)\n");
1502    out.push_str("  HKCR = HKEY_CLASSES_ROOT    (file associations)\n");
1503    out.push_str("\nVerification:\n");
1504    out.push_str("  Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' | Select-Object MyValue\n");
1505    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.");
1506    Ok(out.trim_end().to_string())
1507}
1508
1509fn inspect_scheduled_task_fix_plan(issue: &str) -> Result<String, String> {
1510    let mut out = String::from("Host inspection: fix_plan\n\n");
1511    let _ = writeln!(out, "- Requested issue: {}", issue);
1512    out.push_str("- Fix-plan type: scheduled_task_create\n");
1513    out.push_str("\nFix plan — Creating a Scheduled Task (PowerShell, run as Administrator):\n");
1514    out.push_str("\nExample: Run a script at 9 AM every day\n");
1515    out.push_str("  $action  = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-File C:\\Scripts\\MyScript.ps1'\n");
1516    out.push_str("  $trigger = New-ScheduledTaskTrigger -Daily -At '09:00AM'\n");
1517    out.push_str("  Register-ScheduledTask -TaskName 'MyDailyTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1518    out.push_str("\nExample: Run at Windows startup\n");
1519    out.push_str("  $trigger = New-ScheduledTaskTrigger -AtStartup\n");
1520    out.push_str("  Register-ScheduledTask -TaskName 'MyStartupTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1521    out.push_str("\nExample: Run at user logon\n");
1522    out.push_str("  $trigger = New-ScheduledTaskTrigger -AtLogon\n");
1523    out.push_str(
1524        "  Register-ScheduledTask -TaskName 'MyLogonTask' -Action $action -Trigger $trigger\n",
1525    );
1526    out.push_str("\nExample: Run every 30 minutes\n");
1527    out.push_str("  $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 30) -Once -At (Get-Date)\n");
1528    out.push_str("\nView all tasks:\n");
1529    out.push_str("  Get-ScheduledTask | Select-Object TaskName,State | Sort-Object TaskName\n");
1530    out.push_str("\nDelete a task:\n");
1531    out.push_str("  Unregister-ScheduledTask -TaskName 'MyDailyTask' -Confirm:$false\n");
1532    out.push_str("\nRun a task immediately:\n");
1533    out.push_str("  Start-ScheduledTask -TaskName 'MyDailyTask'\n");
1534    out.push_str("\nVerification:\n");
1535    out.push_str("  Get-ScheduledTask -TaskName 'MyDailyTask' | Select-Object TaskName,State,LastRunTime,NextRunTime\n");
1536    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.");
1537    Ok(out.trim_end().to_string())
1538}
1539
1540fn inspect_disk_cleanup_fix_plan(issue: &str) -> Result<String, String> {
1541    #[cfg(target_os = "windows")]
1542    let disk_info = {
1543        Command::new("powershell")
1544            .args([
1545                "-NoProfile",
1546                "-NonInteractive",
1547                "-Command",
1548                "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\" }",
1549            ])
1550            .output()
1551            .ok()
1552            .and_then(|o| String::from_utf8(o.stdout).ok())
1553            .unwrap_or_default()
1554            .trim()
1555            .to_string()
1556    };
1557    #[cfg(not(target_os = "windows"))]
1558    let disk_info = String::new();
1559
1560    let mut out = String::from("Host inspection: fix_plan\n\n");
1561    let _ = writeln!(out, "- Requested issue: {}", issue);
1562    out.push_str("- Fix-plan type: disk_cleanup\n");
1563    if !disk_info.is_empty() {
1564        let _ = write!(out, "\nCurrent drive usage:\n{}\n", disk_info);
1565    }
1566    out.push_str("\nFix plan — Reclaiming disk space (ordered by impact):\n");
1567    out.push_str("\n1. Run Windows Disk Cleanup (built-in, GUI):\n");
1568    out.push_str("   cleanmgr /sageset:1    (configure what to clean)\n");
1569    out.push_str("   cleanmgr /sagerun:1    (run the cleanup)\n");
1570    out.push_str("   Tick 'Windows Update Cleanup' for the biggest reclaim (often 5-20 GB).\n");
1571    out.push_str("\n2. Clear the Windows Update cache (PowerShell, Admin):\n");
1572    out.push_str("   Stop-Service wuauserv\n");
1573    out.push_str("   Remove-Item C:\\Windows\\SoftwareDistribution\\Download\\* -Recurse -Force\n");
1574    out.push_str("   Start-Service wuauserv\n");
1575    out.push_str("\n3. Clear Windows Temp folder:\n");
1576    out.push_str("   Remove-Item $env:TEMP\\* -Recurse -Force -ErrorAction SilentlyContinue\n");
1577    out.push_str(
1578        "   Remove-Item C:\\Windows\\Temp\\* -Recurse -Force -ErrorAction SilentlyContinue\n",
1579    );
1580    out.push_str("\n4. Developer cache directories (often the biggest culprits):\n");
1581    out.push_str("   - Rust build artifacts: cargo clean  (inside each project)\n");
1582    out.push_str("   - npm cache:  npm cache clean --force\n");
1583    out.push_str("   - pip cache:  pip cache purge\n");
1584    out.push_str(
1585        "   - Docker:     docker system prune -a  (removes all unused images/containers)\n",
1586    );
1587    out.push_str("   - Cargo registry cache: Remove-Item ~\\.cargo\\registry -Recurse -Force  (will redownload on next build)\n");
1588    out.push_str("\n5. Check for large files:\n");
1589    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");
1590    out.push_str("\nVerification:\n");
1591    out.push_str(
1592        "  Get-PSDrive C | Select-Object @{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}}\n",
1593    );
1594    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.");
1595    Ok(out.trim_end().to_string())
1596}
1597
1598fn inspect_generic_fix_plan(issue: &str) -> Result<String, String> {
1599    let mut out = String::from("Host inspection: fix_plan\n\n");
1600    let _ = writeln!(out, "- Requested issue: {}", issue);
1601    out.push_str("- Fix-plan type: generic\n");
1602    out.push_str(
1603        "\nGuidance:\n- Use `fix_plan` with a descriptive issue string to get a grounded, machine-specific walkthrough.\n\
1604         Structured lanes available:\n\
1605         - PATH/toolchain drift (cargo, rustc, node, python, winget, choco, scoop)\n\
1606         - Port conflict (address already in use, what owns port)\n\
1607         - LM Studio connectivity (localhost:1234, no coding model loaded, embedding model)\n\
1608         - Driver install (GPU driver, nvidia driver, install driver, update driver)\n\
1609         - Group Policy (gpedit, local policy, administrative template)\n\
1610         - Firewall rule (inbound rule, outbound rule, open port, allow port, block port)\n\
1611         - SSH key (ssh-keygen, generate ssh, authorized_keys)\n\
1612         - WSL setup (wsl2, windows subsystem for linux, install ubuntu)\n\
1613         - Service config (start/stop/restart/enable/disable a service)\n\
1614         - Windows activation (product key, not activated, kms)\n\
1615         - Registry edit (regedit, reg add, hklm, hkcu, registry key)\n\
1616         - Scheduled task (task scheduler, schtasks, run on startup, cron)\n\
1617         - Disk cleanup (free up disk, clear cache, disk full, reclaim space)\n\
1618         - If your issue is outside these lanes, run the closest `inspect_host` topic first to ground the diagnosis.",
1619    );
1620    Ok(out.trim_end().to_string())
1621}
1622
1623fn inspect_resource_load() -> Result<String, String> {
1624    #[cfg(target_os = "windows")]
1625    {
1626        let output = Command::new("powershell")
1627            .args([
1628                "-NoProfile",
1629                "-Command",
1630                "(Get-CimInstance Win32_Processor).LoadPercentage; Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json -Compress",
1631            ])
1632            .output()
1633            .map_err(|e| format!("Failed to run powershell: {e}"))?;
1634
1635        let text = String::from_utf8_lossy(&output.stdout);
1636        let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
1637
1638        let cpu_load = lines
1639            .next()
1640            .and_then(|l| l.parse::<u32>().ok())
1641            .unwrap_or(0);
1642        let mem_json: String = lines.collect();
1643        let mem_val: Value = serde_json::from_str(&mem_json).unwrap_or(Value::Null);
1644
1645        let total_kb = mem_val["TotalVisibleMemorySize"].as_u64().unwrap_or(1);
1646        let free_kb = mem_val["FreePhysicalMemory"].as_u64().unwrap_or(0);
1647        let used_kb = total_kb.saturating_sub(free_kb);
1648        let mem_percent = (used_kb * 100).checked_div(total_kb).unwrap_or(0);
1649
1650        let mut out = String::from("Host inspection: resource_load\n\n");
1651        out.push_str("**System Performance Summary:**\n");
1652        let _ = writeln!(out, "- CPU Load: {}%", cpu_load);
1653        let _ = writeln!(
1654            out,
1655            "- Memory Usage: {} / {} ({}%)",
1656            human_bytes(used_kb * 1024),
1657            human_bytes(total_kb * 1024),
1658            mem_percent
1659        );
1660
1661        if cpu_load > 85 {
1662            out.push_str("\n[Warning] CPU load is extremely high. System may be unresponsive.\n");
1663        }
1664        if mem_percent > 90 {
1665            out.push_str("\n[Warning] Memory usage is near capacity. Swap activity may slow down the machine.\n");
1666        }
1667
1668        Ok(out)
1669    }
1670    #[cfg(not(target_os = "windows"))]
1671    {
1672        Ok("Resource load inspection is not yet implemented for this platform.".to_string())
1673    }
1674}
1675
1676#[derive(Debug)]
1677enum EndpointProbe {
1678    Reachable(u16),
1679    Unreachable(String),
1680}
1681
1682async fn probe_http_endpoint(url: &str) -> EndpointProbe {
1683    let client = match reqwest::Client::builder()
1684        .timeout(std::time::Duration::from_secs(3))
1685        .build()
1686    {
1687        Ok(client) => client,
1688        Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1689    };
1690
1691    match client.get(url).send().await {
1692        Ok(resp) => EndpointProbe::Reachable(resp.status().as_u16()),
1693        Err(err) => EndpointProbe::Unreachable(err.to_string()),
1694    }
1695}
1696
1697async fn detect_loaded_embed_model(configured_api: &str) -> Option<String> {
1698    if configured_api.contains("11434") {
1699        let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1700        let url = format!("{}/api/ps", base);
1701        let client = reqwest::Client::builder()
1702            .timeout(std::time::Duration::from_secs(3))
1703            .build()
1704            .ok()?;
1705        let response = client.get(url).send().await.ok()?;
1706        let body = response.json::<serde_json::Value>().await.ok()?;
1707        let entries = body["models"].as_array()?;
1708        for entry in entries {
1709            let name = entry["name"]
1710                .as_str()
1711                .or_else(|| entry["model"].as_str())
1712                .unwrap_or_default();
1713            let lower = name.to_ascii_lowercase();
1714            if lower.contains("embed")
1715                || lower.contains("embedding")
1716                || lower.contains("minilm")
1717                || lower.contains("bge")
1718                || lower.contains("e5")
1719            {
1720                return Some(name.to_string());
1721            }
1722        }
1723        return None;
1724    }
1725
1726    let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1727    let url = format!("{}/api/v0/models", base);
1728    let client = reqwest::Client::builder()
1729        .timeout(std::time::Duration::from_secs(3))
1730        .build()
1731        .ok()?;
1732
1733    #[derive(serde::Deserialize)]
1734    struct ModelList {
1735        data: Vec<ModelEntry>,
1736    }
1737    #[derive(serde::Deserialize)]
1738    struct ModelEntry {
1739        id: String,
1740        #[serde(rename = "type", default)]
1741        model_type: String,
1742        #[serde(default)]
1743        state: String,
1744    }
1745
1746    let response = client.get(url).send().await.ok()?;
1747    let models = response.json::<ModelList>().await.ok()?;
1748    models
1749        .data
1750        .into_iter()
1751        .find(|model| model.model_type == "embeddings" && model.state == "loaded")
1752        .map(|model| model.id)
1753}
1754
1755fn first_port_in_text(text: &str) -> Option<u16> {
1756    text.split(|c: char| !c.is_ascii_digit())
1757        .find(|fragment| !fragment.is_empty())
1758        .and_then(|fragment| fragment.parse::<u16>().ok())
1759}
1760
1761fn inspect_processes(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1762    let mut processes = collect_processes()?;
1763    if let Some(filter) = name_filter.as_deref() {
1764        let lowered = filter.to_ascii_lowercase();
1765        processes.retain(|entry| entry.name.to_ascii_lowercase().contains(&lowered));
1766    }
1767    processes.sort_by(|a, b| {
1768        let a_cpu = a.cpu_percent.unwrap_or(0.0);
1769        let b_cpu = b.cpu_percent.unwrap_or(0.0);
1770        b_cpu
1771            .partial_cmp(&a_cpu)
1772            .unwrap_or(std::cmp::Ordering::Equal)
1773            .then_with(|| b.memory_bytes.cmp(&a.memory_bytes))
1774            .then_with(|| a.name.cmp(&b.name))
1775            .then_with(|| a.pid.cmp(&b.pid))
1776    });
1777
1778    let total_memory: u64 = processes.iter().map(|entry| entry.memory_bytes).sum();
1779
1780    let mut out = String::from("Host inspection: processes\n\n");
1781    if let Some(filter) = name_filter.as_deref() {
1782        let _ = writeln!(out, "- Filter name: {}", filter);
1783    }
1784    let _ = writeln!(out, "- Processes found: {}", processes.len());
1785    let _ = writeln!(
1786        out,
1787        "- Total reported working set: {}",
1788        human_bytes(total_memory)
1789    );
1790
1791    if processes.is_empty() {
1792        out.push_str("\nNo running processes matched.");
1793        return Ok(out);
1794    }
1795
1796    out.push_str("\nTop processes by resource usage:\n");
1797    for entry in processes.iter().take(max_entries) {
1798        let cpu_str = entry
1799            .cpu_percent
1800            .map(|p| format!(" [CPU: {:.1}%]", p))
1801            .or_else(|| entry.cpu_seconds.map(|s| format!(" [CPU: {:.1}s]", s)))
1802            .unwrap_or_default();
1803        let io_str = if let (Some(r), Some(w)) = (entry.read_ops, entry.write_ops) {
1804            format!(" [I/O R:{}/W:{}]", r, w)
1805        } else {
1806            " [I/O unknown]".to_string()
1807        };
1808        let _ = writeln!(
1809            out,
1810            "- {} (pid {}) - {}{}{}{}",
1811            entry.name,
1812            entry.pid,
1813            human_bytes(entry.memory_bytes),
1814            cpu_str,
1815            io_str,
1816            entry
1817                .detail
1818                .as_deref()
1819                .map(|detail| format!(" [{}]", detail))
1820                .unwrap_or_default()
1821        );
1822    }
1823    if processes.len() > max_entries {
1824        let _ = writeln!(
1825            out,
1826            "- ... {} more processes omitted",
1827            processes.len() - max_entries
1828        );
1829    }
1830
1831    Ok(out.trim_end().to_string())
1832}
1833
1834fn inspect_network(max_entries: usize) -> Result<String, String> {
1835    let adapters = collect_network_adapters()?;
1836    let active_count = adapters
1837        .iter()
1838        .filter(|adapter| adapter.is_active())
1839        .count();
1840    let exposure = listener_exposure_summary(collect_listening_ports().ok().unwrap_or_default());
1841
1842    let mut out = String::from("Host inspection: network\n\n");
1843    let _ = writeln!(out, "- Adapters found: {}", adapters.len());
1844    let _ = writeln!(out, "- Active adapters: {}", active_count);
1845    let _ = writeln!(
1846        out,
1847        "- Listener exposure: {} loopback-only, {} wildcard/public, {} specific-bind",
1848        exposure.loopback_only, exposure.wildcard_public, exposure.specific_bind
1849    );
1850
1851    if adapters.is_empty() {
1852        out.push_str("\nNo adapter details were detected.");
1853        return Ok(out);
1854    }
1855
1856    out.push_str("\nAdapter summary:\n");
1857    for adapter in adapters.iter().take(max_entries) {
1858        let status = if adapter.is_active() {
1859            "active"
1860        } else if adapter.disconnected {
1861            "disconnected"
1862        } else {
1863            "idle"
1864        };
1865        let mut details = vec![status.to_string()];
1866        if !adapter.ipv4.is_empty() {
1867            details.push(format!("ipv4 {}", adapter.ipv4.join(", ")));
1868        }
1869        if !adapter.ipv6.is_empty() {
1870            details.push(format!("ipv6 {}", adapter.ipv6.join(", ")));
1871        }
1872        if !adapter.gateways.is_empty() {
1873            details.push(format!("gateway {}", adapter.gateways.join(", ")));
1874        }
1875        if !adapter.dns_servers.is_empty() {
1876            details.push(format!("dns {}", adapter.dns_servers.join(", ")));
1877        }
1878        let _ = writeln!(out, "- {} - {}", adapter.name, details.join(" | "));
1879    }
1880    if adapters.len() > max_entries {
1881        let _ = writeln!(
1882            out,
1883            "- ... {} more adapters omitted",
1884            adapters.len() - max_entries
1885        );
1886    }
1887
1888    Ok(out.trim_end().to_string())
1889}
1890
1891fn inspect_lan_discovery(max_entries: usize) -> Result<String, String> {
1892    let mut out = String::from("Host inspection: lan_discovery\n\n");
1893
1894    #[cfg(target_os = "windows")]
1895    {
1896        let n = max_entries.clamp(5, 20);
1897        let adapters = collect_network_adapters()?;
1898        let services = collect_services().unwrap_or_default();
1899        let active_adapters: Vec<&NetworkAdapter> = adapters
1900            .iter()
1901            .filter(|adapter| adapter.is_active())
1902            .collect();
1903        let gateways: Vec<String> = active_adapters
1904            .iter()
1905            .flat_map(|adapter| adapter.gateways.clone())
1906            .collect::<HashSet<_>>()
1907            .into_iter()
1908            .collect();
1909
1910        let neighbor_script = r#"
1911$neighbors = Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue |
1912    Where-Object {
1913        $_.IPAddress -notlike '127.*' -and
1914        $_.IPAddress -notlike '169.254*' -and
1915        $_.State -notin @('Unreachable','Invalid')
1916    } |
1917    Select-Object IPAddress, LinkLayerAddress, State, InterfaceAlias
1918$neighbors | ConvertTo-Json -Compress
1919"#;
1920        let neighbor_text = Command::new("powershell")
1921            .args(["-NoProfile", "-NonInteractive", "-Command", neighbor_script])
1922            .output()
1923            .ok()
1924            .and_then(|o| String::from_utf8(o.stdout).ok())
1925            .unwrap_or_default();
1926        let neighbors: Vec<(String, String, String, String)> = parse_lan_neighbors(&neighbor_text)
1927            .into_iter()
1928            .take(n)
1929            .collect();
1930
1931        let listener_script = r#"
1932Get-NetUDPEndpoint -ErrorAction SilentlyContinue |
1933    Where-Object { $_.LocalPort -in 137,138,1900,5353,5355 } |
1934    Select-Object LocalAddress, LocalPort, OwningProcess |
1935    ForEach-Object {
1936        $proc = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Name
1937        "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$proc"
1938    }
1939"#;
1940        let listener_text = Command::new("powershell")
1941            .args(["-NoProfile", "-NonInteractive", "-Command", listener_script])
1942            .output()
1943            .ok()
1944            .and_then(|o| String::from_utf8(o.stdout).ok())
1945            .unwrap_or_default();
1946        let listeners: Vec<(String, u16, String, String)> = listener_text
1947            .lines()
1948            .filter_map(|line| {
1949                let mut it = line.trim().splitn(4, '|');
1950                let a = it.next()?.to_string();
1951                let b = it.next()?.parse::<u16>().ok()?;
1952                let c = it.next()?.to_string();
1953                let d = it.next()?.to_string();
1954                Some((a, b, c, d))
1955            })
1956            .take(n)
1957            .collect();
1958
1959        let smb_mapping_script = r#"
1960Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } |
1961    ForEach-Object { "$($_.Name):|$($_.DisplayRoot)" }
1962"#;
1963        let smb_mappings: Vec<String> = Command::new("powershell")
1964            .args([
1965                "-NoProfile",
1966                "-NonInteractive",
1967                "-Command",
1968                smb_mapping_script,
1969            ])
1970            .output()
1971            .ok()
1972            .and_then(|o| String::from_utf8(o.stdout).ok())
1973            .unwrap_or_default()
1974            .lines()
1975            .take(n)
1976            .map(|line| line.trim().to_string())
1977            .filter(|line| !line.is_empty())
1978            .collect();
1979
1980        let smb_connections_script = r#"
1981Get-SmbConnection -ErrorAction SilentlyContinue |
1982    Select-Object ServerName, ShareName, NumOpens |
1983    ForEach-Object { "$($_.ServerName)|$($_.ShareName)|$($_.NumOpens)" }
1984"#;
1985        let smb_connections: Vec<String> = Command::new("powershell")
1986            .args([
1987                "-NoProfile",
1988                "-NonInteractive",
1989                "-Command",
1990                smb_connections_script,
1991            ])
1992            .output()
1993            .ok()
1994            .and_then(|o| String::from_utf8(o.stdout).ok())
1995            .unwrap_or_default()
1996            .lines()
1997            .take(n)
1998            .map(|line| line.trim().to_string())
1999            .filter(|line| !line.is_empty())
2000            .collect();
2001
2002        let discovery_service_names = [
2003            "FDResPub",
2004            "fdPHost",
2005            "SSDPSRV",
2006            "upnphost",
2007            "LanmanServer",
2008            "LanmanWorkstation",
2009            "lmhosts",
2010        ];
2011        let discovery_services: Vec<&ServiceEntry> = services
2012            .iter()
2013            .filter(|entry| {
2014                discovery_service_names
2015                    .iter()
2016                    .any(|name| entry.name.eq_ignore_ascii_case(name))
2017            })
2018            .collect();
2019
2020        let mut findings = Vec::with_capacity(4);
2021        if active_adapters.is_empty() {
2022            findings.push(AuditFinding {
2023                finding: "No active LAN adapters were detected.".to_string(),
2024                impact: "Neighborhood, SMB, mDNS, SSDP, and printer/NAS discovery cannot work without an active local interface.".to_string(),
2025                fix: "Bring up Wi-Fi or Ethernet first, then rerun LAN discovery. If the adapter should be up already, inspect `network` or `connectivity` next.".to_string(),
2026            });
2027        }
2028
2029        let stopped_discovery_services: Vec<&ServiceEntry> = discovery_services
2030            .iter()
2031            .copied()
2032            .filter(|entry| {
2033                !entry.status.eq_ignore_ascii_case("running")
2034                    && !entry.status.eq_ignore_ascii_case("active")
2035            })
2036            .collect();
2037        if !stopped_discovery_services.is_empty() {
2038            let names = {
2039                let mut s = String::new();
2040                for (i, entry) in stopped_discovery_services.iter().enumerate() {
2041                    if i > 0 {
2042                        s.push_str(", ");
2043                    }
2044                    s.push_str(&entry.name);
2045                }
2046                s
2047            };
2048            findings.push(AuditFinding {
2049                finding: format!("Discovery-related services are not running: {names}"),
2050                impact: "Windows network neighborhood visibility, SSDP/UPnP discovery, or SMB browse behavior can look broken even when the network itself is fine.".to_string(),
2051                fix: "Start the relevant services and set their startup type appropriately. `FDResPub` and `fdPHost` matter for neighborhood visibility; `SSDPSRV` and `upnphost` matter for UPnP.".to_string(),
2052            });
2053        }
2054
2055        if listeners.is_empty() {
2056            findings.push(AuditFinding {
2057                finding: "No discovery-oriented UDP listeners were found on 137, 138, 1900, 5353, or 5355.".to_string(),
2058                impact: "NetBIOS, SSDP/UPnP, mDNS, and LLMNR discovery may be inactive on this host, so other devices may not see it automatically.".to_string(),
2059                fix: "If auto-discovery is expected, confirm the related services are running and check whether local firewall policy is suppressing these discovery ports.".to_string(),
2060            });
2061        }
2062
2063        if !active_adapters.is_empty() && neighbors.len() <= gateways.len() {
2064            findings.push(AuditFinding {
2065                finding: "Very little neighborhood evidence was observed beyond the default gateway.".to_string(),
2066                impact: "That often means discovery traffic is quiet, the LAN is isolated, or peer devices are not advertising themselves.".to_string(),
2067                fix: "Check whether the target device is on the same subnet/VLAN, whether discovery is enabled on both sides, and whether the local firewall is allowing discovery protocols.".to_string(),
2068            });
2069        }
2070
2071        out.push_str("=== Findings ===\n");
2072        if findings.is_empty() {
2073            out.push_str(
2074                "- Finding: LAN discovery signals look healthy from this inspection pass.\n",
2075            );
2076            out.push_str("  Impact: Neighborhood visibility, SMB browsing, and SSDP/mDNS discovery do not show an obvious host-side blocker.\n");
2077            out.push_str("  Fix: If one device still cannot be seen, test the specific host/share/printer path next to separate name resolution from service reachability.\n");
2078        } else {
2079            for finding in &findings {
2080                let _ = writeln!(out, "- Finding: {}", finding.finding);
2081                let _ = writeln!(out, "  Impact: {}", finding.impact);
2082                let _ = writeln!(out, "  Fix: {}", finding.fix);
2083            }
2084        }
2085
2086        out.push_str("\n=== Active adapter and gateway summary ===\n");
2087        if active_adapters.is_empty() {
2088            out.push_str("- No active adapters detected.\n");
2089        } else {
2090            for adapter in active_adapters.iter().take(n) {
2091                let ipv4 = if adapter.ipv4.is_empty() {
2092                    "no IPv4".to_string()
2093                } else {
2094                    adapter.ipv4.join(", ")
2095                };
2096                let gateway = if adapter.gateways.is_empty() {
2097                    "no gateway".to_string()
2098                } else {
2099                    adapter.gateways.join(", ")
2100                };
2101                let _ = writeln!(
2102                    out,
2103                    "- {} | IPv4: {} | Gateway: {}",
2104                    adapter.name, ipv4, gateway
2105                );
2106            }
2107        }
2108
2109        out.push_str("\n=== Neighborhood evidence ===\n");
2110        let _ = writeln!(out, "- Gateway count: {}", gateways.len());
2111        let _ = writeln!(out, "- Neighbor entries observed: {}", neighbors.len());
2112        if neighbors.is_empty() {
2113            out.push_str("- No ARP/neighbor evidence retrieved.\n");
2114        } else {
2115            for (ip, mac, state, iface) in neighbors.iter().take(n) {
2116                let _ = writeln!(
2117                    out,
2118                    "- {} on {} | MAC: {} | State: {}",
2119                    ip, iface, mac, state
2120                );
2121            }
2122        }
2123
2124        out.push_str("\n=== Discovery services ===\n");
2125        if discovery_services.is_empty() {
2126            out.push_str("- Discovery service status unavailable.\n");
2127        } else {
2128            for entry in discovery_services.iter().take(n) {
2129                let startup = entry.startup.as_deref().unwrap_or("unknown");
2130                let _ = writeln!(
2131                    out,
2132                    "- {} | Status: {} | Startup: {}",
2133                    entry.name, entry.status, startup
2134                );
2135            }
2136        }
2137
2138        out.push_str("\n=== Discovery listener surface ===\n");
2139        if listeners.is_empty() {
2140            out.push_str("- No discovery-oriented UDP listeners detected.\n");
2141        } else {
2142            for (addr, port, pid, proc_name) in listeners.iter().take(n) {
2143                let label = match *port {
2144                    137 => "NetBIOS Name Service",
2145                    138 => "NetBIOS Datagram",
2146                    1900 => "SSDP/UPnP",
2147                    5353 => "mDNS",
2148                    5355 => "LLMNR",
2149                    _ => "Discovery",
2150                };
2151                let proc_label = if proc_name.is_empty() {
2152                    "unknown".to_string()
2153                } else {
2154                    proc_name.clone()
2155                };
2156                let _ = writeln!(
2157                    out,
2158                    "- {}:{} | {} | PID {} ({})",
2159                    addr, port, label, pid, proc_label
2160                );
2161            }
2162        }
2163
2164        out.push_str("\n=== SMB and neighborhood visibility ===\n");
2165        if smb_mappings.is_empty() && smb_connections.is_empty() {
2166            out.push_str("- No mapped SMB drives or active SMB connections detected.\n");
2167        } else {
2168            if !smb_mappings.is_empty() {
2169                out.push_str("- Mapped drives:\n");
2170                for mapping in smb_mappings.iter().take(n) {
2171                    let mut it = mapping.splitn(3, '|');
2172                    if let (Some(a), Some(b)) = (it.next(), it.next()) {
2173                        let _ = writeln!(out, "  - {} -> {}", a, b);
2174                    }
2175                }
2176            }
2177            if !smb_connections.is_empty() {
2178                out.push_str("- Active SMB connections:\n");
2179                for connection in smb_connections.iter().take(n) {
2180                    let mut it = connection.splitn(4, '|');
2181                    if let (Some(a), Some(b), Some(c)) = (it.next(), it.next(), it.next()) {
2182                        let _ = writeln!(out, "  - {}\\{} | Opens: {}", a, b, c);
2183                    }
2184                }
2185            }
2186        }
2187    }
2188
2189    #[cfg(not(target_os = "windows"))]
2190    {
2191        let n = max_entries.clamp(5, 20);
2192        let adapters = collect_network_adapters()?;
2193        let arp_output = Command::new("ip")
2194            .args(["neigh"])
2195            .output()
2196            .ok()
2197            .and_then(|o| String::from_utf8(o.stdout).ok())
2198            .unwrap_or_default();
2199        let neighbors: Vec<&str> = arp_output
2200            .lines()
2201            .filter(|line| !line.trim().is_empty())
2202            .take(n)
2203            .collect();
2204
2205        out.push_str("=== Findings ===\n");
2206        if adapters.iter().any(|adapter| adapter.is_active()) {
2207            out.push_str(
2208                "- Finding: LAN discovery support is partially available on this platform.\n",
2209            );
2210            out.push_str("  Impact: Adapter and neighbor evidence can be inspected, but mDNS/UPnP coverage depends on local tools and services like Avahi.\n");
2211            out.push_str("  Fix: If discovery is failing, inspect Avahi/systemd-resolved, local firewall rules, and `udp_ports` next.\n");
2212        } else {
2213            out.push_str("- Finding: No active LAN adapters were detected.\n");
2214            out.push_str(
2215                "  Impact: Neighborhood discovery cannot work without an active interface.\n",
2216            );
2217            out.push_str("  Fix: Bring up Wi-Fi or Ethernet first, then rerun LAN discovery.\n");
2218        }
2219
2220        out.push_str("\n=== Active adapter and gateway summary ===\n");
2221        if adapters.is_empty() {
2222            out.push_str("- No adapters detected.\n");
2223        } else {
2224            for adapter in adapters.iter().take(n) {
2225                let ipv4 = if adapter.ipv4.is_empty() {
2226                    "no IPv4".to_string()
2227                } else {
2228                    adapter.ipv4.join(", ")
2229                };
2230                let gateway = if adapter.gateways.is_empty() {
2231                    "no gateway".to_string()
2232                } else {
2233                    adapter.gateways.join(", ")
2234                };
2235                let _ = write!(
2236                    out,
2237                    "- {} | IPv4: {} | Gateway: {}\n",
2238                    adapter.name, ipv4, gateway
2239                );
2240            }
2241        }
2242
2243        out.push_str("\n=== Neighborhood evidence ===\n");
2244        if neighbors.is_empty() {
2245            out.push_str("- No neighbor entries detected.\n");
2246        } else {
2247            for line in neighbors {
2248                let _ = write!(out, "- {}\n", line.trim());
2249            }
2250        }
2251    }
2252
2253    Ok(out.trim_end().to_string())
2254}
2255
2256fn inspect_services(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
2257    let mut services = collect_services()?;
2258    if let Some(filter) = name_filter.as_deref() {
2259        let lowered = filter.to_ascii_lowercase();
2260        services.retain(|entry| {
2261            entry.name.to_ascii_lowercase().contains(&lowered)
2262                || entry
2263                    .display_name
2264                    .as_deref()
2265                    .map(|d| d.to_ascii_lowercase().contains(&lowered))
2266                    .unwrap_or(false)
2267        });
2268    }
2269
2270    services.sort_by(|a, b| {
2271        let a_running =
2272            a.status.eq_ignore_ascii_case("running") || a.status.eq_ignore_ascii_case("active");
2273        let b_running =
2274            b.status.eq_ignore_ascii_case("running") || b.status.eq_ignore_ascii_case("active");
2275        b_running.cmp(&a_running).then_with(|| a.name.cmp(&b.name))
2276    });
2277
2278    let running = services
2279        .iter()
2280        .filter(|entry| {
2281            entry.status.eq_ignore_ascii_case("running")
2282                || entry.status.eq_ignore_ascii_case("active")
2283        })
2284        .count();
2285    let failed = services
2286        .iter()
2287        .filter(|entry| {
2288            entry.status.eq_ignore_ascii_case("failed")
2289                || entry.status.eq_ignore_ascii_case("error")
2290                || entry.status.eq_ignore_ascii_case("stopped")
2291        })
2292        .count();
2293
2294    let mut out = String::from("Host inspection: services\n\n");
2295    if let Some(filter) = name_filter.as_deref() {
2296        let _ = writeln!(out, "- Filter name: {}", filter);
2297    }
2298    let _ = writeln!(out, "- Services found: {}", services.len());
2299    let _ = writeln!(out, "- Running/active: {}", running);
2300    let _ = writeln!(out, "- Failed/stopped: {}", failed);
2301
2302    if services.is_empty() {
2303        out.push_str("\nNo services matched.");
2304        return Ok(out);
2305    }
2306
2307    // Split into running and stopped sections so both are always visible.
2308    let per_section = (max_entries / 2).max(5);
2309
2310    let running_services: Vec<_> = services
2311        .iter()
2312        .filter(|e| {
2313            e.status.eq_ignore_ascii_case("running") || e.status.eq_ignore_ascii_case("active")
2314        })
2315        .collect();
2316    let stopped_services: Vec<_> = services
2317        .iter()
2318        .filter(|e| {
2319            e.status.eq_ignore_ascii_case("stopped")
2320                || e.status.eq_ignore_ascii_case("failed")
2321                || e.status.eq_ignore_ascii_case("error")
2322        })
2323        .collect();
2324
2325    let fmt_entry = |entry: &&ServiceEntry| {
2326        let startup = entry
2327            .startup
2328            .as_deref()
2329            .map(|v| format!(" | startup {}", v))
2330            .unwrap_or_default();
2331        let logon = entry
2332            .start_name
2333            .as_deref()
2334            .map(|v| format!(" | LogOn: {}", v))
2335            .unwrap_or_default();
2336        let display = entry
2337            .display_name
2338            .as_deref()
2339            .filter(|v| *v != entry.name)
2340            .map(|v| format!(" [{}]", v))
2341            .unwrap_or_default();
2342        format!(
2343            "- {}{} - {}{}{}\n",
2344            entry.name, display, entry.status, startup, logon
2345        )
2346    };
2347
2348    let _ = write!(
2349        out,
2350        "\nRunning services ({} total, showing up to {}):\n",
2351        running_services.len(),
2352        per_section
2353    );
2354    for entry in running_services.iter().take(per_section) {
2355        out.push_str(&fmt_entry(entry));
2356    }
2357    if running_services.len() > per_section {
2358        let _ = writeln!(
2359            out,
2360            "- ... {} more running services omitted",
2361            running_services.len() - per_section
2362        );
2363    }
2364
2365    let _ = write!(
2366        out,
2367        "\nStopped/failed services ({} total, showing up to {}):\n",
2368        stopped_services.len(),
2369        per_section
2370    );
2371    for entry in stopped_services.iter().take(per_section) {
2372        out.push_str(&fmt_entry(entry));
2373    }
2374    if stopped_services.len() > per_section {
2375        let _ = writeln!(
2376            out,
2377            "- ... {} more stopped services omitted",
2378            stopped_services.len() - per_section
2379        );
2380    }
2381
2382    Ok(out.trim_end().to_string())
2383}
2384
2385async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
2386    inspect_directory("Disk", path, max_entries).await
2387}
2388
2389fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
2390    let mut listeners = collect_listening_ports()?;
2391    if let Some(port) = port_filter {
2392        listeners.retain(|entry| entry.port == port);
2393    }
2394    listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
2395
2396    let mut out = String::from("Host inspection: ports\n\n");
2397    if let Some(port) = port_filter {
2398        let _ = writeln!(out, "- Filter port: {}", port);
2399    }
2400    let _ = writeln!(out, "- Listening endpoints found: {}", listeners.len());
2401
2402    if listeners.is_empty() {
2403        out.push_str("\nNo listening endpoints matched.");
2404        return Ok(out);
2405    }
2406
2407    out.push_str("\nListening endpoints:\n");
2408    for entry in listeners.iter().take(max_entries) {
2409        let pid_str = entry
2410            .pid
2411            .as_deref()
2412            .map(|p| format!(" pid {}", p))
2413            .unwrap_or_default();
2414        let name_str = entry
2415            .process_name
2416            .as_deref()
2417            .map(|n| format!(" [{}]", n))
2418            .unwrap_or_default();
2419        let _ = writeln!(
2420            out,
2421            "- {} {} ({}){}{}",
2422            entry.protocol, entry.local, entry.state, pid_str, name_str
2423        );
2424    }
2425    if listeners.len() > max_entries {
2426        let _ = writeln!(
2427            out,
2428            "- ... {} more listening endpoints omitted",
2429            listeners.len() - max_entries
2430        );
2431    }
2432
2433    Ok(out.trim_end().to_string())
2434}
2435
2436fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
2437    if !path.exists() {
2438        return Err(format!("Path does not exist: {}", path.display()));
2439    }
2440    if !path.is_dir() {
2441        return Err(format!("Path is not a directory: {}", path.display()));
2442    }
2443
2444    let markers = collect_project_markers(&path);
2445    let hematite_state = collect_hematite_state(&path);
2446    let git_state = inspect_git_state(&path);
2447    let release_state = inspect_release_artifacts(&path);
2448
2449    let mut out = String::from("Host inspection: repo_doctor\n\n");
2450    let _ = writeln!(out, "- Path: {}", path.display());
2451    let _ = writeln!(out, "- Workspace mode: {}", workspace_mode_for_path(&path));
2452
2453    if markers.is_empty() {
2454        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");
2455    } else {
2456        out.push_str("- Project markers:\n");
2457        for marker in markers.iter().take(max_entries) {
2458            let _ = writeln!(out, "  - {}", marker);
2459        }
2460    }
2461
2462    match git_state {
2463        Some(git) => {
2464            let _ = writeln!(out, "- Git root: {}", git.root.display());
2465            let _ = writeln!(out, "- Git branch: {}", git.branch);
2466            let _ = writeln!(out, "- Git status: {}", git.status_label());
2467        }
2468        None => out.push_str("- Git: not inside a detected work tree\n"),
2469    }
2470
2471    let _ = writeln!(
2472        out,
2473        "- Hematite docs/imports/reports: {}/{}/{}",
2474        hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
2475    );
2476    if hematite_state.workspace_profile {
2477        out.push_str("- Workspace profile: present\n");
2478    } else {
2479        out.push_str("- Workspace profile: absent\n");
2480    }
2481
2482    if let Some(release) = release_state {
2483        let _ = writeln!(out, "- Cargo version: {}", release.version);
2484        let _ = writeln!(
2485            out,
2486            "- Windows artifacts for current version: {}/{}/{}",
2487            bool_label(release.portable_dir),
2488            bool_label(release.portable_zip),
2489            bool_label(release.setup_exe)
2490        );
2491    }
2492
2493    Ok(out.trim_end().to_string())
2494}
2495
2496async fn inspect_known_directory(
2497    label: &str,
2498    path: Option<PathBuf>,
2499    max_entries: usize,
2500) -> Result<String, String> {
2501    let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
2502    inspect_directory(label, path, max_entries).await
2503}
2504
2505async fn inspect_directory(
2506    label: &str,
2507    path: PathBuf,
2508    max_entries: usize,
2509) -> Result<String, String> {
2510    let label = label.to_string();
2511    tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
2512        .await
2513        .map_err(|e| format!("inspect_host task failed: {e}"))?
2514}
2515
2516fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
2517    if !path.exists() {
2518        return Err(format!("Path does not exist: {}", path.display()));
2519    }
2520    if !path.is_dir() {
2521        return Err(format!("Path is not a directory: {}", path.display()));
2522    }
2523
2524    let mut top_level_entries = Vec::new();
2525    for entry in fs::read_dir(path)
2526        .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
2527    {
2528        match entry {
2529            Ok(entry) => top_level_entries.push(entry),
2530            Err(_) => continue,
2531        }
2532    }
2533    top_level_entries.sort_by_key(|entry| entry.file_name());
2534
2535    let top_level_count = top_level_entries.len();
2536    let mut sample_names = Vec::with_capacity(max_entries.min(top_level_count));
2537    let mut largest_entries = Vec::with_capacity(top_level_count);
2538    let mut aggregate = PathAggregate::default();
2539    let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
2540
2541    for entry in top_level_entries {
2542        let name = entry.file_name().to_string_lossy().to_string();
2543        if sample_names.len() < max_entries {
2544            sample_names.push(name.clone());
2545        }
2546        let kind = match entry.file_type() {
2547            Ok(ft) if ft.is_dir() => "dir",
2548            Ok(ft) if ft.is_symlink() => "symlink",
2549            _ => "file",
2550        };
2551        let stats = measure_path(&entry.path(), &mut budget);
2552        aggregate.merge(&stats);
2553        largest_entries.push(LargestEntry {
2554            name,
2555            kind,
2556            bytes: stats.total_bytes,
2557        });
2558    }
2559
2560    largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
2561
2562    let mut out = format!("Directory inspection: {}\n\n", label);
2563    let _ = writeln!(out, "- Path: {}", path.display());
2564    let _ = writeln!(out, "- Top-level items: {}", top_level_count);
2565    let _ = writeln!(out, "- Recursive files: {}", aggregate.file_count);
2566    let _ = writeln!(out, "- Recursive directories: {}", aggregate.dir_count);
2567    let _ = writeln!(
2568        out,
2569        "- Total size: {}{}",
2570        human_bytes(aggregate.total_bytes),
2571        if aggregate.partial {
2572            " (partial scan)"
2573        } else {
2574            ""
2575        }
2576    );
2577    if aggregate.skipped_entries > 0 {
2578        let _ = writeln!(
2579            out,
2580            "- Skipped entries: {} (permissions, symlinks, or scan budget)",
2581            aggregate.skipped_entries
2582        );
2583    }
2584
2585    if !largest_entries.is_empty() {
2586        out.push_str("\nLargest top-level entries:\n");
2587        for entry in largest_entries.iter().take(max_entries) {
2588            let _ = writeln!(
2589                out,
2590                "- {} [{}] - {}",
2591                entry.name,
2592                entry.kind,
2593                human_bytes(entry.bytes)
2594            );
2595        }
2596    }
2597
2598    if !sample_names.is_empty() {
2599        out.push_str("\nSample names:\n");
2600        for name in sample_names {
2601            let _ = writeln!(out, "- {}", name);
2602        }
2603    }
2604
2605    Ok(out.trim_end().to_string())
2606}
2607
2608fn resolve_path(raw: &str) -> Result<PathBuf, String> {
2609    let trimmed = raw.trim();
2610    if trimmed.is_empty() {
2611        return Err("Path must not be empty.".to_string());
2612    }
2613
2614    if let Some(rest) = trimmed
2615        .strip_prefix("~/")
2616        .or_else(|| trimmed.strip_prefix("~\\"))
2617    {
2618        let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
2619        return Ok(home.join(rest));
2620    }
2621
2622    let path = PathBuf::from(trimmed);
2623    if path.is_absolute() {
2624        Ok(path)
2625    } else {
2626        let cwd =
2627            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
2628        let full_path = cwd.join(&path);
2629
2630        // Heuristic: If it's a relative path to .hematite or hematite.exe and doesn't exist here,
2631        // check the user's home directory.
2632        if !full_path.exists()
2633            && (trimmed.starts_with(".hematite") || trimmed.starts_with("hematite.exe"))
2634        {
2635            if let Some(home) = home::home_dir() {
2636                let home_path = home.join(trimmed);
2637                if home_path.exists() {
2638                    return Ok(home_path);
2639                }
2640            }
2641        }
2642
2643        Ok(full_path)
2644    }
2645}
2646
2647fn workspace_mode_label(workspace_root: &Path) -> &'static str {
2648    workspace_mode_for_path(workspace_root)
2649}
2650
2651fn workspace_mode_for_path(path: &Path) -> &'static str {
2652    if is_project_marker_path(path) {
2653        "project"
2654    } else if path.join(".hematite").join("docs").exists()
2655        || path.join(".hematite").join("imports").exists()
2656        || path.join(".hematite").join("reports").exists()
2657    {
2658        "docs-only"
2659    } else {
2660        "general directory"
2661    }
2662}
2663
2664fn is_project_marker_path(path: &Path) -> bool {
2665    [
2666        "Cargo.toml",
2667        "package.json",
2668        "pyproject.toml",
2669        "go.mod",
2670        "composer.json",
2671        "requirements.txt",
2672        "Makefile",
2673        "justfile",
2674    ]
2675    .iter()
2676    .any(|name| path.join(name).exists())
2677        || path.join(".git").exists()
2678}
2679
2680fn preferred_shell_label() -> &'static str {
2681    #[cfg(target_os = "windows")]
2682    {
2683        "PowerShell"
2684    }
2685    #[cfg(not(target_os = "windows"))]
2686    {
2687        "sh"
2688    }
2689}
2690
2691fn desktop_dir() -> Option<PathBuf> {
2692    home::home_dir().map(|home| home.join("Desktop"))
2693}
2694
2695fn downloads_dir() -> Option<PathBuf> {
2696    home::home_dir().map(|home| home.join("Downloads"))
2697}
2698
2699fn count_top_level_items(path: &Path) -> Result<usize, String> {
2700    let mut count = 0usize;
2701    for entry in
2702        fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
2703    {
2704        if entry.is_ok() {
2705            count += 1;
2706        }
2707    }
2708    Ok(count)
2709}
2710
2711#[derive(Default)]
2712struct PathAggregate {
2713    total_bytes: u64,
2714    file_count: u64,
2715    dir_count: u64,
2716    skipped_entries: u64,
2717    partial: bool,
2718}
2719
2720impl PathAggregate {
2721    fn merge(&mut self, other: &PathAggregate) {
2722        self.total_bytes += other.total_bytes;
2723        self.file_count += other.file_count;
2724        self.dir_count += other.dir_count;
2725        self.skipped_entries += other.skipped_entries;
2726        self.partial |= other.partial;
2727    }
2728}
2729
2730struct LargestEntry {
2731    name: String,
2732    kind: &'static str,
2733    bytes: u64,
2734}
2735
2736fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
2737    if *budget == 0 {
2738        return PathAggregate {
2739            partial: true,
2740            skipped_entries: 1,
2741            ..PathAggregate::default()
2742        };
2743    }
2744    *budget -= 1;
2745
2746    let metadata = match fs::symlink_metadata(path) {
2747        Ok(metadata) => metadata,
2748        Err(_) => {
2749            return PathAggregate {
2750                skipped_entries: 1,
2751                ..PathAggregate::default()
2752            }
2753        }
2754    };
2755
2756    let file_type = metadata.file_type();
2757    if file_type.is_symlink() {
2758        return PathAggregate {
2759            skipped_entries: 1,
2760            ..PathAggregate::default()
2761        };
2762    }
2763
2764    if metadata.is_file() {
2765        return PathAggregate {
2766            total_bytes: metadata.len(),
2767            file_count: 1,
2768            ..PathAggregate::default()
2769        };
2770    }
2771
2772    if !metadata.is_dir() {
2773        return PathAggregate::default();
2774    }
2775
2776    let mut aggregate = PathAggregate {
2777        dir_count: 1,
2778        ..PathAggregate::default()
2779    };
2780
2781    let read_dir = match fs::read_dir(path) {
2782        Ok(read_dir) => read_dir,
2783        Err(_) => {
2784            aggregate.skipped_entries += 1;
2785            return aggregate;
2786        }
2787    };
2788
2789    for child in read_dir {
2790        match child {
2791            Ok(child) => {
2792                let child_stats = measure_path(&child.path(), budget);
2793                aggregate.merge(&child_stats);
2794            }
2795            Err(_) => aggregate.skipped_entries += 1,
2796        }
2797    }
2798
2799    aggregate
2800}
2801
2802struct PathAnalysis {
2803    total_entries: usize,
2804    unique_entries: usize,
2805    entries: Vec<String>,
2806    duplicate_entries: Vec<String>,
2807    missing_entries: Vec<String>,
2808}
2809
2810fn analyze_path_env() -> PathAnalysis {
2811    let mut entries = Vec::new();
2812    let mut duplicate_entries = Vec::new();
2813    let mut missing_entries = Vec::new();
2814    let mut seen = HashSet::new();
2815
2816    let raw_path = std::env::var_os("PATH").unwrap_or_default();
2817    for path in std::env::split_paths(&raw_path) {
2818        let display = path.display().to_string();
2819        if display.trim().is_empty() {
2820            continue;
2821        }
2822
2823        let normalized = normalize_path_entry(&display);
2824        if !seen.insert(normalized) {
2825            duplicate_entries.push(display.clone());
2826        }
2827        if !path.exists() {
2828            missing_entries.push(display.clone());
2829        }
2830        entries.push(display);
2831    }
2832
2833    let total_entries = entries.len();
2834    let unique_entries = seen.len();
2835
2836    PathAnalysis {
2837        total_entries,
2838        unique_entries,
2839        entries,
2840        duplicate_entries,
2841        missing_entries,
2842    }
2843}
2844
2845fn normalize_path_entry(value: &str) -> String {
2846    #[cfg(target_os = "windows")]
2847    {
2848        value
2849            .replace('/', "\\")
2850            .trim_end_matches(['\\', '/'])
2851            .to_ascii_lowercase()
2852    }
2853    #[cfg(not(target_os = "windows"))]
2854    {
2855        value.trim_end_matches('/').to_string()
2856    }
2857}
2858
2859struct ToolchainReport {
2860    found: Vec<(String, String)>,
2861    missing: Vec<String>,
2862}
2863
2864struct PackageManagerReport {
2865    found: Vec<(String, String)>,
2866}
2867
2868#[derive(Debug, Clone)]
2869struct ProcessEntry {
2870    name: String,
2871    pid: u32,
2872    memory_bytes: u64,
2873    cpu_seconds: Option<f64>,
2874    cpu_percent: Option<f64>,
2875    read_ops: Option<u64>,
2876    write_ops: Option<u64>,
2877    detail: Option<String>,
2878}
2879
2880#[derive(Debug, Clone)]
2881struct ServiceEntry {
2882    name: String,
2883    status: String,
2884    startup: Option<String>,
2885    display_name: Option<String>,
2886    start_name: Option<String>,
2887}
2888
2889#[derive(Debug, Clone, Default)]
2890struct NetworkAdapter {
2891    name: String,
2892    ipv4: Vec<String>,
2893    ipv6: Vec<String>,
2894    gateways: Vec<String>,
2895    dns_servers: Vec<String>,
2896    disconnected: bool,
2897}
2898
2899impl NetworkAdapter {
2900    fn is_active(&self) -> bool {
2901        !self.disconnected
2902            && (!self.ipv4.is_empty() || !self.ipv6.is_empty() || !self.gateways.is_empty())
2903    }
2904}
2905
2906#[derive(Debug, Clone, Copy, Default)]
2907struct ListenerExposureSummary {
2908    loopback_only: usize,
2909    wildcard_public: usize,
2910    specific_bind: usize,
2911}
2912
2913#[derive(Debug, Clone)]
2914struct ListeningPort {
2915    protocol: String,
2916    local: String,
2917    port: u16,
2918    state: String,
2919    pid: Option<String>,
2920    process_name: Option<String>,
2921}
2922
2923fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
2924    #[cfg(target_os = "windows")]
2925    {
2926        collect_windows_listening_ports()
2927    }
2928    #[cfg(not(target_os = "windows"))]
2929    {
2930        collect_unix_listening_ports()
2931    }
2932}
2933
2934fn collect_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2935    #[cfg(target_os = "windows")]
2936    {
2937        collect_windows_network_adapters()
2938    }
2939    #[cfg(not(target_os = "windows"))]
2940    {
2941        collect_unix_network_adapters()
2942    }
2943}
2944
2945fn collect_services() -> Result<Vec<ServiceEntry>, String> {
2946    #[cfg(target_os = "windows")]
2947    {
2948        collect_windows_services()
2949    }
2950    #[cfg(not(target_os = "windows"))]
2951    {
2952        collect_unix_services()
2953    }
2954}
2955
2956#[cfg(target_os = "windows")]
2957fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
2958    let output = Command::new("netstat")
2959        .args(["-ano", "-p", "tcp"])
2960        .output()
2961        .map_err(|e| format!("Failed to run netstat: {e}"))?;
2962    if !output.status.success() {
2963        return Err("netstat returned a non-success status.".to_string());
2964    }
2965
2966    let text = String::from_utf8_lossy(&output.stdout);
2967    let mut listeners = Vec::new();
2968    for line in text.lines() {
2969        let trimmed = line.trim();
2970        if !trimmed.starts_with("TCP") {
2971            continue;
2972        }
2973        let mut it = trimmed.split_whitespace();
2974        if let (Some(proto), Some(local), Some(_), Some(state), Some(pid)) =
2975            (it.next(), it.next(), it.next(), it.next(), it.next())
2976        {
2977            if state != "LISTENING" {
2978                continue;
2979            }
2980            let Some(port) = extract_port_from_socket(local) else {
2981                continue;
2982            };
2983            listeners.push(ListeningPort {
2984                protocol: proto.to_string(),
2985                local: local.to_string(),
2986                port,
2987                state: state.to_string(),
2988                pid: Some(pid.to_string()),
2989                process_name: None,
2990            });
2991        }
2992    }
2993
2994    // Enrich with process names via PowerShell — works without elevation for
2995    // most user-space processes. System processes (PID 4, etc.) stay unnamed.
2996    let unique_pids: Vec<String> = listeners
2997        .iter()
2998        .filter_map(|l| l.pid.clone())
2999        .collect::<HashSet<_>>()
3000        .into_iter()
3001        .collect();
3002
3003    if !unique_pids.is_empty() {
3004        let pid_list = unique_pids.join(",");
3005        let ps_cmd = format!(
3006            "Get-Process -Id {} -ErrorAction SilentlyContinue | Select-Object Id,Name | Format-Table -HideTableHeaders",
3007            pid_list
3008        );
3009        if let Ok(ps_out) = Command::new("powershell")
3010            .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
3011            .output()
3012        {
3013            let mut pid_map = std::collections::HashMap::<String, String>::new();
3014            let ps_text = String::from_utf8_lossy(&ps_out.stdout);
3015            for line in ps_text.lines() {
3016                let mut it = line.split_whitespace();
3017                if let (Some(a), Some(b)) = (it.next(), it.next()) {
3018                    pid_map.insert(a.to_string(), b.to_string());
3019                }
3020            }
3021            for listener in &mut listeners {
3022                if let Some(pid) = &listener.pid {
3023                    listener.process_name = pid_map.get(pid).cloned();
3024                }
3025            }
3026        }
3027    }
3028
3029    Ok(listeners)
3030}
3031
3032#[cfg(not(target_os = "windows"))]
3033fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
3034    let output = Command::new("ss")
3035        .args(["-ltn"])
3036        .output()
3037        .map_err(|e| format!("Failed to run ss: {e}"))?;
3038    if !output.status.success() {
3039        return Err("ss returned a non-success status.".to_string());
3040    }
3041
3042    let text = String::from_utf8_lossy(&output.stdout);
3043    let mut listeners = Vec::new();
3044    for line in text.lines().skip(1) {
3045        let mut it = line.split_whitespace();
3046        if let (Some(state), Some(_), Some(_), Some(local)) =
3047            (it.next(), it.next(), it.next(), it.next())
3048        {
3049            let Some(port) = extract_port_from_socket(local) else {
3050                continue;
3051            };
3052            listeners.push(ListeningPort {
3053                protocol: "tcp".to_string(),
3054                local: local.to_string(),
3055                port,
3056                state: state.to_string(),
3057                pid: None,
3058                process_name: None,
3059            });
3060        }
3061    }
3062
3063    Ok(listeners)
3064}
3065
3066fn collect_processes() -> Result<Vec<ProcessEntry>, String> {
3067    #[cfg(target_os = "windows")]
3068    {
3069        collect_windows_processes()
3070    }
3071    #[cfg(not(target_os = "windows"))]
3072    {
3073        collect_unix_processes()
3074    }
3075}
3076
3077#[cfg(target_os = "windows")]
3078fn collect_windows_services() -> Result<Vec<ServiceEntry>, String> {
3079    let command = "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,DisplayName,StartName | ConvertTo-Json -Compress";
3080    let output = Command::new("powershell")
3081        .args(["-NoProfile", "-Command", command])
3082        .output()
3083        .map_err(|e| format!("Failed to run PowerShell service inspection: {e}"))?;
3084    if !output.status.success() {
3085        return Err("PowerShell service inspection returned a non-success status.".to_string());
3086    }
3087
3088    parse_windows_services_json(&String::from_utf8_lossy(&output.stdout))
3089}
3090
3091#[cfg(not(target_os = "windows"))]
3092fn collect_unix_services() -> Result<Vec<ServiceEntry>, String> {
3093    let status_output = Command::new("systemctl")
3094        .args([
3095            "list-units",
3096            "--type=service",
3097            "--all",
3098            "--no-pager",
3099            "--no-legend",
3100            "--plain",
3101        ])
3102        .output()
3103        .map_err(|e| format!("Failed to run systemctl list-units: {e}"))?;
3104    if !status_output.status.success() {
3105        return Err("systemctl list-units returned a non-success status.".to_string());
3106    }
3107
3108    let startup_output = Command::new("systemctl")
3109        .args([
3110            "list-unit-files",
3111            "--type=service",
3112            "--no-legend",
3113            "--no-pager",
3114            "--plain",
3115        ])
3116        .output()
3117        .map_err(|e| format!("Failed to run systemctl list-unit-files: {e}"))?;
3118    if !startup_output.status.success() {
3119        return Err("systemctl list-unit-files returned a non-success status.".to_string());
3120    }
3121
3122    Ok(parse_unix_services(
3123        &String::from_utf8_lossy(&status_output.stdout),
3124        &String::from_utf8_lossy(&startup_output.stdout),
3125    ))
3126}
3127
3128#[cfg(target_os = "windows")]
3129fn collect_windows_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3130    let output = Command::new("ipconfig")
3131        .args(["/all"])
3132        .output()
3133        .map_err(|e| format!("Failed to run ipconfig: {e}"))?;
3134    if !output.status.success() {
3135        return Err("ipconfig returned a non-success status.".to_string());
3136    }
3137
3138    Ok(parse_windows_ipconfig_all(&String::from_utf8_lossy(
3139        &output.stdout,
3140    )))
3141}
3142
3143#[cfg(not(target_os = "windows"))]
3144fn collect_unix_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3145    let addr_output = Command::new("ip")
3146        .args(["-o", "addr", "show", "up"])
3147        .output()
3148        .map_err(|e| format!("Failed to run ip addr: {e}"))?;
3149    if !addr_output.status.success() {
3150        return Err("ip addr returned a non-success status.".to_string());
3151    }
3152
3153    let route_output = Command::new("ip")
3154        .args(["route", "show", "default"])
3155        .output()
3156        .map_err(|e| format!("Failed to run ip route: {e}"))?;
3157    if !route_output.status.success() {
3158        return Err("ip route returned a non-success status.".to_string());
3159    }
3160
3161    let mut adapters = parse_unix_ip_addr(&String::from_utf8_lossy(&addr_output.stdout));
3162    apply_unix_default_routes(
3163        &mut adapters,
3164        &String::from_utf8_lossy(&route_output.stdout),
3165    );
3166    apply_unix_dns_servers(&mut adapters);
3167    Ok(adapters)
3168}
3169
3170#[cfg(target_os = "windows")]
3171fn collect_windows_processes() -> Result<Vec<ProcessEntry>, String> {
3172    // We take two samples of CPU time separated by a short interval to calculate recent CPU %
3173    let script = r#"
3174        $s1 = Get-Process | Select-Object Id, CPU
3175        Start-Sleep -Milliseconds 250
3176        $s2 = Get-Process | Select-Object Name, Id, WorkingSet64, CPU, ReadOperationCount, WriteOperationCount
3177        $s2 | ForEach-Object {
3178            $p2 = $_
3179            $p1 = $s1 | Where-Object { $_.Id -eq $p2.Id }
3180            $pct = 0.0
3181            if ($p1 -and $p2.CPU -gt $p1.CPU) {
3182                # (Delta CPU seconds / interval) * 100 / LogicalProcessors
3183                # Note: We skip division by logical processors to show 'per-core' usage or just raw % if preferred.
3184                # Standard Task Manager style is (delta / interval) * 100.
3185                $pct = [math]::Round((($p2.CPU - $p1.CPU) / 0.25) * 100, 1)
3186            }
3187            "PID:$($p2.Id)|NAME:$($p2.Name)|MEM:$($p2.WorkingSet64)|CPU_S:$($p2.CPU)|CPU_P:$pct|READ:$($p2.ReadOperationCount)|WRITE:$($p2.WriteOperationCount)"
3188        }
3189    "#;
3190
3191    let output = Command::new("powershell")
3192        .args(["-NoProfile", "-Command", script])
3193        .output()
3194        .map_err(|e| format!("Failed to run powershell Get-Process: {e}"))?;
3195
3196    let text = String::from_utf8_lossy(&output.stdout);
3197    let mut out = Vec::new();
3198    let mut parts: Vec<&str> = Vec::with_capacity(8);
3199    for line in text.lines() {
3200        parts.clear();
3201        parts.extend(line.trim().split('|'));
3202        if parts.len() < 5 {
3203            continue;
3204        }
3205        let mut entry = ProcessEntry {
3206            name: "unknown".to_string(),
3207            pid: 0,
3208            memory_bytes: 0,
3209            cpu_seconds: None,
3210            cpu_percent: None,
3211            read_ops: None,
3212            write_ops: None,
3213            detail: None,
3214        };
3215        for p in &parts {
3216            if let Some((k, v)) = p.split_once(':') {
3217                match k {
3218                    "PID" => entry.pid = v.parse().unwrap_or(0),
3219                    "NAME" => entry.name = v.to_string(),
3220                    "MEM" => entry.memory_bytes = v.parse().unwrap_or(0),
3221                    "CPU_S" => entry.cpu_seconds = v.parse().ok(),
3222                    "CPU_P" => entry.cpu_percent = v.parse().ok(),
3223                    "READ" => entry.read_ops = v.parse().ok(),
3224                    "WRITE" => entry.write_ops = v.parse().ok(),
3225                    _ => {}
3226                }
3227            }
3228        }
3229        out.push(entry);
3230    }
3231    Ok(out)
3232}
3233
3234#[cfg(not(target_os = "windows"))]
3235fn collect_unix_processes() -> Result<Vec<ProcessEntry>, String> {
3236    let output = Command::new("ps")
3237        .args(["-eo", "pid=,rss=,comm="])
3238        .output()
3239        .map_err(|e| format!("Failed to run ps: {e}"))?;
3240    if !output.status.success() {
3241        return Err("ps returned a non-success status.".to_string());
3242    }
3243
3244    let text = String::from_utf8_lossy(&output.stdout);
3245    let mut processes = Vec::new();
3246    for line in text.lines() {
3247        let mut it = line.split_whitespace();
3248        let Some(pid_str) = it.next() else {
3249            continue;
3250        };
3251        let Some(rss_str) = it.next() else {
3252            continue;
3253        };
3254        let Some(first_word) = it.next() else {
3255            continue;
3256        };
3257        let Ok(pid) = pid_str.parse::<u32>() else {
3258            continue;
3259        };
3260        let Ok(rss_kib) = rss_str.parse::<u64>() else {
3261            continue;
3262        };
3263        let mut name = first_word.to_string();
3264        for w in it {
3265            name.push(' ');
3266            name.push_str(w);
3267        }
3268        processes.push(ProcessEntry {
3269            name,
3270            pid,
3271            memory_bytes: rss_kib * 1024,
3272            cpu_seconds: None,
3273            cpu_percent: None,
3274            read_ops: None,
3275            write_ops: None,
3276            detail: None,
3277        });
3278    }
3279
3280    Ok(processes)
3281}
3282
3283fn extract_port_from_socket(value: &str) -> Option<u16> {
3284    let cleaned = value.trim().trim_matches(['[', ']']);
3285    let port_str = cleaned.rsplit(':').next()?;
3286    port_str.parse::<u16>().ok()
3287}
3288
3289fn listener_exposure_summary(listeners: Vec<ListeningPort>) -> ListenerExposureSummary {
3290    let mut summary = ListenerExposureSummary::default();
3291    for entry in listeners {
3292        let local = entry.local.to_ascii_lowercase();
3293        if is_loopback_listener(&local) {
3294            summary.loopback_only += 1;
3295        } else if is_wildcard_listener(&local) {
3296            summary.wildcard_public += 1;
3297        } else {
3298            summary.specific_bind += 1;
3299        }
3300    }
3301    summary
3302}
3303
3304fn is_loopback_listener(local: &str) -> bool {
3305    local.starts_with("127.")
3306        || local.starts_with("[::1]")
3307        || local.starts_with("::1")
3308        || local.starts_with("localhost:")
3309}
3310
3311fn is_wildcard_listener(local: &str) -> bool {
3312    local.starts_with("0.0.0.0:")
3313        || local.starts_with("[::]:")
3314        || local.starts_with(":::")
3315        || local == "*:*"
3316}
3317
3318struct GitState {
3319    root: PathBuf,
3320    branch: String,
3321    dirty_entries: usize,
3322}
3323
3324impl GitState {
3325    fn status_label(&self) -> String {
3326        if self.dirty_entries == 0 {
3327            "clean".to_string()
3328        } else {
3329            format!("dirty ({} changed path(s))", self.dirty_entries)
3330        }
3331    }
3332}
3333
3334fn inspect_git_state(path: &Path) -> Option<GitState> {
3335    let root = capture_first_line(
3336        "git",
3337        &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
3338    )?;
3339    let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
3340        .unwrap_or_else(|| "detached".to_string());
3341    let output = Command::new("git")
3342        .args(["-C", path.to_str()?, "status", "--short"])
3343        .output()
3344        .ok()?;
3345    if !output.status.success() {
3346        return None;
3347    }
3348    let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
3349    Some(GitState {
3350        root: PathBuf::from(root),
3351        branch,
3352        dirty_entries,
3353    })
3354}
3355
3356struct HematiteState {
3357    docs_count: usize,
3358    import_count: usize,
3359    report_count: usize,
3360    workspace_profile: bool,
3361}
3362
3363fn collect_hematite_state(path: &Path) -> HematiteState {
3364    let root = path.join(".hematite");
3365    HematiteState {
3366        docs_count: count_entries_if_exists(&root.join("docs")),
3367        import_count: count_entries_if_exists(&root.join("imports")),
3368        report_count: count_entries_if_exists(&root.join("reports")),
3369        workspace_profile: root.join("workspace_profile.json").exists(),
3370    }
3371}
3372
3373fn count_entries_if_exists(path: &Path) -> usize {
3374    if !path.exists() || !path.is_dir() {
3375        return 0;
3376    }
3377    fs::read_dir(path)
3378        .ok()
3379        .map(|iter| iter.filter(|entry| entry.is_ok()).count())
3380        .unwrap_or(0)
3381}
3382
3383fn collect_project_markers(path: &Path) -> Vec<String> {
3384    [
3385        "Cargo.toml",
3386        "package.json",
3387        "pyproject.toml",
3388        "go.mod",
3389        "justfile",
3390        "Makefile",
3391        ".git",
3392    ]
3393    .iter()
3394    .filter(|&name| path.join(name).exists())
3395    .map(|name| (*name).to_string())
3396    .collect()
3397}
3398
3399struct ReleaseArtifactState {
3400    version: String,
3401    portable_dir: bool,
3402    portable_zip: bool,
3403    setup_exe: bool,
3404}
3405
3406fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
3407    let cargo_toml = path.join("Cargo.toml");
3408    if !cargo_toml.exists() {
3409        return None;
3410    }
3411    let cargo_text = fs::read_to_string(cargo_toml).ok()?;
3412    let version = [regex_line_capture(
3413        &cargo_text,
3414        r#"(?m)^version\s*=\s*"([^"]+)""#,
3415    )?]
3416    .concat();
3417    let dist_windows = path.join("dist").join("windows");
3418    let prefix = format!("Hematite-{}", version);
3419    Some(ReleaseArtifactState {
3420        version,
3421        portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
3422        portable_zip: dist_windows
3423            .join(format!("{}-portable.zip", prefix))
3424            .exists(),
3425        setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
3426    })
3427}
3428
3429fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
3430    let regex = regex::Regex::new(pattern).ok()?;
3431    let captures = regex.captures(text)?;
3432    captures.get(1).map(|m| m.as_str().to_string())
3433}
3434
3435fn bool_label(value: bool) -> &'static str {
3436    if value {
3437        "yes"
3438    } else {
3439        "no"
3440    }
3441}
3442
3443fn collect_toolchains() -> ToolchainReport {
3444    let config = crate::agent::config::load_config();
3445    let mut python_probes = Vec::with_capacity(5);
3446    if let Some(ref path) = config.python_path {
3447        python_probes.push(CommandProbe::new(path, &["--version"]));
3448    };
3449
3450    python_probes.extend([
3451        CommandProbe::new("python3", &["--version"]),
3452        CommandProbe::new("python", &["--version"]),
3453        CommandProbe::new("py", &["-3", "--version"]),
3454        CommandProbe::new("py", &["--version"]),
3455    ]);
3456
3457    let checks = [
3458        ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
3459        ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
3460        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3461        ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
3462        ToolCheck::new(
3463            "npm",
3464            &[
3465                CommandProbe::new("npm", &["--version"]),
3466                CommandProbe::new("npm.cmd", &["--version"]),
3467            ],
3468        ),
3469        ToolCheck::new(
3470            "pnpm",
3471            &[
3472                CommandProbe::new("pnpm", &["--version"]),
3473                CommandProbe::new("pnpm.cmd", &["--version"]),
3474            ],
3475        ),
3476        ToolCheck::new("python", &python_probes),
3477        ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
3478        ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
3479        ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
3480        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3481    ];
3482
3483    let mut found = Vec::with_capacity(checks.len());
3484    let mut missing = Vec::with_capacity(checks.len());
3485
3486    for check in checks {
3487        match check.detect() {
3488            Some(version) => found.push((check.label.to_string(), version)),
3489            None => missing.push(check.label.to_string()),
3490        }
3491    }
3492
3493    ToolchainReport { found, missing }
3494}
3495
3496fn collect_package_managers() -> PackageManagerReport {
3497    let config = crate::agent::config::load_config();
3498    let mut pip_probes = Vec::with_capacity(6);
3499    if let Some(ref path) = config.python_path {
3500        pip_probes.push(CommandProbe::new(path, &["-m", "pip", "--version"]));
3501    }
3502    pip_probes.extend([
3503        CommandProbe::new("python3", &["-m", "pip", "--version"]),
3504        CommandProbe::new("python", &["-m", "pip", "--version"]),
3505        CommandProbe::new("py", &["-3", "-m", "pip", "--version"]),
3506        CommandProbe::new("py", &["-m", "pip", "--version"]),
3507        CommandProbe::new("pip", &["--version"]),
3508    ]);
3509
3510    let checks = [
3511        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3512        ToolCheck::new(
3513            "npm",
3514            &[
3515                CommandProbe::new("npm", &["--version"]),
3516                CommandProbe::new("npm.cmd", &["--version"]),
3517            ],
3518        ),
3519        ToolCheck::new(
3520            "pnpm",
3521            &[
3522                CommandProbe::new("pnpm", &["--version"]),
3523                CommandProbe::new("pnpm.cmd", &["--version"]),
3524            ],
3525        ),
3526        ToolCheck::new("pip", &pip_probes),
3527        ToolCheck::new("pipx", &[CommandProbe::new("pipx", &["--version"])]),
3528        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3529        ToolCheck::new("winget", &[CommandProbe::new("winget", &["--version"])]),
3530        ToolCheck::new(
3531            "choco",
3532            &[
3533                CommandProbe::new("choco", &["--version"]),
3534                CommandProbe::new("choco.exe", &["--version"]),
3535            ],
3536        ),
3537        ToolCheck::new("scoop", &[CommandProbe::new("scoop", &["--version"])]),
3538    ];
3539
3540    let mut found = Vec::with_capacity(checks.len());
3541    for check in checks {
3542        if let Some(version) = check.detect() {
3543            found.push((check.label.to_string(), version))
3544        }
3545    }
3546
3547    PackageManagerReport { found }
3548}
3549
3550#[derive(Clone)]
3551struct ToolCheck {
3552    label: &'static str,
3553    probes: Vec<CommandProbe>,
3554}
3555
3556impl ToolCheck {
3557    fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
3558        Self {
3559            label,
3560            probes: probes.to_vec(),
3561        }
3562    }
3563
3564    fn detect(&self) -> Option<String> {
3565        for probe in &self.probes {
3566            if let Some(output) = capture_first_line(&probe.program, &probe.args) {
3567                return Some(output);
3568            }
3569        }
3570        None
3571    }
3572}
3573
3574#[derive(Clone)]
3575struct CommandProbe {
3576    program: String,
3577    args: Vec<String>,
3578}
3579
3580impl CommandProbe {
3581    fn new(program: &str, args: &[&str]) -> Self {
3582        Self {
3583            program: program.to_string(),
3584            args: args.iter().map(|s| s.to_string()).collect(),
3585        }
3586    }
3587}
3588
3589fn build_env_doctor_findings(
3590    toolchains: &ToolchainReport,
3591    package_managers: &PackageManagerReport,
3592    path_stats: &PathAnalysis,
3593) -> Vec<String> {
3594    let found_tools = toolchains
3595        .found
3596        .iter()
3597        .map(|(label, _)| label.as_str())
3598        .collect::<HashSet<_>>();
3599    let found_managers = package_managers
3600        .found
3601        .iter()
3602        .map(|(label, _)| label.as_str())
3603        .collect::<HashSet<_>>();
3604
3605    let mut findings = Vec::with_capacity(4);
3606
3607    if !path_stats.duplicate_entries.is_empty() {
3608        findings.push(format!(
3609            "PATH contains {} duplicate entries. That is usually harmless but worth cleaning up.",
3610            path_stats.duplicate_entries.len()
3611        ));
3612    }
3613    if !path_stats.missing_entries.is_empty() {
3614        findings.push(format!(
3615            "PATH contains {} entries that do not exist on disk.",
3616            path_stats.missing_entries.len()
3617        ));
3618    }
3619    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
3620        findings.push(
3621            "Rust is present but Cargo was not detected. That is an incomplete Rust toolchain."
3622                .to_string(),
3623        );
3624    }
3625    if found_tools.contains("node")
3626        && !found_managers.contains("npm")
3627        && !found_managers.contains("pnpm")
3628    {
3629        findings.push(
3630            "Node is present but no JavaScript package manager was detected (npm or pnpm)."
3631                .to_string(),
3632        );
3633    }
3634    if found_tools.contains("python")
3635        && !found_managers.contains("pip")
3636        && !found_managers.contains("uv")
3637        && !found_managers.contains("pipx")
3638    {
3639        findings.push(
3640            "Python is present but no Python package manager was detected (pip, uv, or pipx)."
3641                .to_string(),
3642        );
3643    }
3644    let windows_manager_count = ["winget", "choco", "scoop"]
3645        .iter()
3646        .filter(|label| found_managers.contains(**label))
3647        .count();
3648    if windows_manager_count > 1 {
3649        findings.push(
3650            "Multiple Windows package managers are installed. That is workable, but it can create overlap in update paths."
3651                .to_string(),
3652        );
3653    }
3654    if findings.is_empty() && !found_managers.is_empty() {
3655        findings.push(
3656            "Core package-manager coverage looks healthy for a normal developer workstation."
3657                .to_string(),
3658        );
3659    }
3660
3661    findings
3662}
3663
3664fn capture_first_line<S: AsRef<str>>(program: &str, args: &[S]) -> Option<String> {
3665    let output = std::process::Command::new(program)
3666        .args(args.iter().map(|s| s.as_ref()))
3667        .output()
3668        .ok()?;
3669    if !output.status.success() {
3670        return None;
3671    }
3672
3673    let stdout = if output.stdout.is_empty() {
3674        String::from_utf8_lossy(&output.stderr).into_owned()
3675    } else {
3676        String::from_utf8_lossy(&output.stdout).into_owned()
3677    };
3678
3679    stdout
3680        .lines()
3681        .map(str::trim)
3682        .find(|line| !line.is_empty())
3683        .map(|line| line.to_string())
3684}
3685
3686fn human_bytes(bytes: u64) -> String {
3687    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
3688    let mut value = bytes as f64;
3689    let mut unit_index = 0usize;
3690
3691    while value >= 1024.0 && unit_index < UNITS.len() - 1 {
3692        value /= 1024.0;
3693        unit_index += 1;
3694    }
3695
3696    if unit_index == 0 {
3697        format!("{} {}", bytes, UNITS[unit_index])
3698    } else {
3699        format!("{value:.1} {}", UNITS[unit_index])
3700    }
3701}
3702
3703#[cfg(target_os = "windows")]
3704fn parse_windows_ipconfig_all(text: &str) -> Vec<NetworkAdapter> {
3705    let mut adapters = Vec::new();
3706    let mut current: Option<NetworkAdapter> = None;
3707    let mut pending_dns = false;
3708
3709    for raw_line in text.lines() {
3710        let line = raw_line.trim_end();
3711        let trimmed = line.trim();
3712        if trimmed.is_empty() {
3713            pending_dns = false;
3714            continue;
3715        }
3716
3717        if !line.starts_with(' ') && trimmed.ends_with(':') && trimmed.contains("adapter") {
3718            if let Some(adapter) = current.take() {
3719                adapters.push(adapter);
3720            }
3721            current = Some(NetworkAdapter {
3722                name: trimmed.trim_end_matches(':').to_string(),
3723                ..NetworkAdapter::default()
3724            });
3725            pending_dns = false;
3726            continue;
3727        }
3728
3729        let Some(adapter) = current.as_mut() else {
3730            continue;
3731        };
3732
3733        if trimmed.contains("Media State") && trimmed.contains("disconnected") {
3734            adapter.disconnected = true;
3735        }
3736
3737        if let Some(value) = value_after_colon(trimmed) {
3738            let normalized = normalize_ipconfig_value(value);
3739            if trimmed.starts_with("IPv4 Address") && !normalized.is_empty() {
3740                adapter.ipv4.push(normalized);
3741                pending_dns = false;
3742            } else if trimmed.starts_with("IPv6 Address")
3743                || trimmed.starts_with("Temporary IPv6 Address")
3744                || trimmed.starts_with("Link-local IPv6 Address")
3745            {
3746                if !normalized.is_empty() {
3747                    adapter.ipv6.push(normalized);
3748                }
3749                pending_dns = false;
3750            } else if trimmed.starts_with("Default Gateway") {
3751                if !normalized.is_empty() {
3752                    adapter.gateways.push(normalized);
3753                }
3754                pending_dns = false;
3755            } else if trimmed.starts_with("DNS Servers") {
3756                if !normalized.is_empty() {
3757                    adapter.dns_servers.push(normalized);
3758                }
3759                pending_dns = true;
3760            } else {
3761                pending_dns = false;
3762            }
3763        } else if pending_dns {
3764            let normalized = normalize_ipconfig_value(trimmed);
3765            if !normalized.is_empty() {
3766                adapter.dns_servers.push(normalized);
3767            }
3768        }
3769    }
3770
3771    if let Some(adapter) = current.take() {
3772        adapters.push(adapter);
3773    }
3774
3775    for adapter in &mut adapters {
3776        dedup_vec(&mut adapter.ipv4);
3777        dedup_vec(&mut adapter.ipv6);
3778        dedup_vec(&mut adapter.gateways);
3779        dedup_vec(&mut adapter.dns_servers);
3780    }
3781
3782    adapters
3783}
3784
3785#[cfg(not(target_os = "windows"))]
3786fn parse_unix_ip_addr(text: &str) -> Vec<NetworkAdapter> {
3787    let mut adapters = std::collections::BTreeMap::<String, NetworkAdapter>::new();
3788
3789    for line in text.lines() {
3790        let mut it = line.split_whitespace();
3791        let (Some(_), Some(iface), Some(family), Some(addr_full)) =
3792            (it.next(), it.next(), it.next(), it.next())
3793        else {
3794            continue;
3795        };
3796        let name = iface.trim_end_matches(':').to_string();
3797        let addr = addr_full.split('/').next().unwrap_or("").to_string();
3798        let entry = adapters
3799            .entry(name.clone())
3800            .or_insert_with(|| NetworkAdapter {
3801                name,
3802                ..NetworkAdapter::default()
3803            });
3804        match family {
3805            "inet" if !addr.is_empty() => entry.ipv4.push(addr),
3806            "inet6" if !addr.is_empty() => entry.ipv6.push(addr),
3807            _ => {}
3808        }
3809    }
3810
3811    adapters.into_values().collect()
3812}
3813
3814#[cfg(not(target_os = "windows"))]
3815fn apply_unix_default_routes(adapters: &mut [NetworkAdapter], text: &str) {
3816    for line in text.lines() {
3817        let cols: Vec<&str> = line.split_whitespace().collect();
3818        if cols.len() < 5 {
3819            continue;
3820        }
3821        let gateway = cols
3822            .windows(2)
3823            .find(|pair| pair[0] == "via")
3824            .map(|pair| pair[1].to_string());
3825        let dev = cols
3826            .windows(2)
3827            .find(|pair| pair[0] == "dev")
3828            .map(|pair| pair[1]);
3829        if let (Some(gateway), Some(dev)) = (gateway, dev) {
3830            if let Some(adapter) = adapters.iter_mut().find(|adapter| adapter.name == dev) {
3831                adapter.gateways.push(gateway);
3832            }
3833        }
3834    }
3835
3836    for adapter in adapters {
3837        dedup_vec(&mut adapter.gateways);
3838    }
3839}
3840
3841#[cfg(not(target_os = "windows"))]
3842fn apply_unix_dns_servers(adapters: &mut [NetworkAdapter]) {
3843    let Ok(text) = fs::read_to_string("/etc/resolv.conf") else {
3844        return;
3845    };
3846    let mut dns_servers = text
3847        .lines()
3848        .filter_map(|line| line.strip_prefix("nameserver "))
3849        .map(str::trim)
3850        .filter(|value| !value.is_empty())
3851        .map(|value| value.to_string())
3852        .collect::<Vec<_>>();
3853    dedup_vec(&mut dns_servers);
3854    if dns_servers.is_empty() {
3855        return;
3856    }
3857    for adapter in adapters.iter_mut().filter(|adapter| adapter.is_active()) {
3858        adapter.dns_servers = dns_servers.clone();
3859    }
3860}
3861
3862#[cfg(target_os = "windows")]
3863fn value_after_colon(line: &str) -> Option<&str> {
3864    line.split_once(':').map(|(_, value)| value.trim())
3865}
3866
3867#[cfg(target_os = "windows")]
3868fn normalize_ipconfig_value(value: &str) -> String {
3869    value
3870        .trim()
3871        .trim_end_matches("(Preferred)")
3872        .trim_end_matches("(Deprecated)")
3873        .trim()
3874        .trim_matches(['(', ')'])
3875        .trim()
3876        .to_string()
3877}
3878
3879#[cfg(target_os = "windows")]
3880fn is_noise_lan_neighbor(ip: &str, mac: &str) -> bool {
3881    let mac_upper = mac.to_ascii_uppercase();
3882    if mac_upper == "FF-FF-FF-FF-FF-FF" || mac_upper.starts_with("01-00-5E-") {
3883        return true;
3884    }
3885
3886    ip == "255.255.255.255"
3887        || ip.starts_with("224.")
3888        || ip.starts_with("225.")
3889        || ip.starts_with("226.")
3890        || ip.starts_with("227.")
3891        || ip.starts_with("228.")
3892        || ip.starts_with("229.")
3893        || ip.starts_with("230.")
3894        || ip.starts_with("231.")
3895        || ip.starts_with("232.")
3896        || ip.starts_with("233.")
3897        || ip.starts_with("234.")
3898        || ip.starts_with("235.")
3899        || ip.starts_with("236.")
3900        || ip.starts_with("237.")
3901        || ip.starts_with("238.")
3902        || ip.starts_with("239.")
3903}
3904
3905fn dedup_vec(values: &mut Vec<String>) {
3906    let mut seen = HashSet::new();
3907    values.retain(|value| seen.insert(value.clone()));
3908}
3909
3910#[cfg(target_os = "windows")]
3911fn parse_lan_neighbors(text: &str) -> Vec<(String, String, String, String)> {
3912    let trimmed = text.trim();
3913    if trimmed.is_empty() {
3914        return Vec::new();
3915    }
3916
3917    let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
3918        return Vec::new();
3919    };
3920    let entries = match value {
3921        Value::Array(items) => items,
3922        other => vec![other],
3923    };
3924
3925    let mut neighbors = Vec::with_capacity(entries.len());
3926    for entry in entries {
3927        let ip = entry
3928            .get("IPAddress")
3929            .and_then(|v| v.as_str())
3930            .unwrap_or("")
3931            .to_string();
3932        if ip.is_empty() {
3933            continue;
3934        }
3935        let mac = entry
3936            .get("LinkLayerAddress")
3937            .and_then(|v| v.as_str())
3938            .unwrap_or("unknown")
3939            .to_string();
3940        let state = entry
3941            .get("State")
3942            .and_then(|v| v.as_str())
3943            .unwrap_or("unknown")
3944            .to_string();
3945        let iface = entry
3946            .get("InterfaceAlias")
3947            .and_then(|v| v.as_str())
3948            .unwrap_or("unknown")
3949            .to_string();
3950        if is_noise_lan_neighbor(&ip, &mac) {
3951            continue;
3952        }
3953        neighbors.push((ip, mac, state, iface));
3954    }
3955
3956    neighbors
3957}
3958
3959#[cfg(target_os = "windows")]
3960fn parse_windows_services_json(text: &str) -> Result<Vec<ServiceEntry>, String> {
3961    let trimmed = text.trim();
3962    if trimmed.is_empty() {
3963        return Ok(Vec::new());
3964    }
3965
3966    let value: Value = serde_json::from_str(trimmed)
3967        .map_err(|e| format!("Failed to parse PowerShell service JSON: {e}"))?;
3968    let entries = match value {
3969        Value::Array(items) => items,
3970        other => vec![other],
3971    };
3972
3973    let mut services = Vec::with_capacity(entries.len());
3974    for entry in entries {
3975        let Some(name) = entry.get("Name").and_then(|v| v.as_str()) else {
3976            continue;
3977        };
3978        services.push(ServiceEntry {
3979            name: name.to_string(),
3980            status: entry
3981                .get("State")
3982                .and_then(|v| v.as_str())
3983                .unwrap_or("unknown")
3984                .to_string(),
3985            startup: entry
3986                .get("StartMode")
3987                .and_then(|v| v.as_str())
3988                .map(|v| v.to_string()),
3989            display_name: entry
3990                .get("DisplayName")
3991                .and_then(|v| v.as_str())
3992                .map(|v| v.to_string()),
3993            start_name: entry
3994                .get("StartName")
3995                .and_then(|v| v.as_str())
3996                .map(|v| v.to_string()),
3997        });
3998    }
3999
4000    Ok(services)
4001}
4002
4003#[cfg(target_os = "windows")]
4004fn windows_json_entries(node: Option<&Value>) -> Vec<Value> {
4005    match node.cloned() {
4006        Some(Value::Array(items)) => items,
4007        Some(other) => vec![other],
4008        None => Vec::new(),
4009    }
4010}
4011
4012#[cfg(target_os = "windows")]
4013fn parse_windows_pnp_devices(node: Option<&Value>) -> Vec<WindowsPnpDevice> {
4014    windows_json_entries(node)
4015        .into_iter()
4016        .filter_map(|entry| {
4017            let name = entry
4018                .get("FriendlyName")
4019                .and_then(|v| v.as_str())
4020                .or_else(|| entry.get("Name").and_then(|v| v.as_str()))
4021                .unwrap_or("")
4022                .trim()
4023                .to_string();
4024            if name.is_empty() {
4025                return None;
4026            }
4027            Some(WindowsPnpDevice {
4028                name,
4029                status: entry
4030                    .get("Status")
4031                    .and_then(|v| v.as_str())
4032                    .unwrap_or("Unknown")
4033                    .trim()
4034                    .to_string(),
4035                problem: entry.get("Problem").and_then(|v| v.as_u64()).or_else(|| {
4036                    entry
4037                        .get("Problem")
4038                        .and_then(|v| v.as_i64())
4039                        .map(|v| v as u64)
4040                }),
4041                class_name: entry
4042                    .get("Class")
4043                    .and_then(|v| v.as_str())
4044                    .map(|v| v.trim().to_string()),
4045                instance_id: entry
4046                    .get("InstanceId")
4047                    .and_then(|v| v.as_str())
4048                    .map(|v| v.trim().to_string()),
4049            })
4050        })
4051        .collect()
4052}
4053
4054#[cfg(target_os = "windows")]
4055fn parse_windows_sound_devices(node: Option<&Value>) -> Vec<WindowsSoundDevice> {
4056    windows_json_entries(node)
4057        .into_iter()
4058        .filter_map(|entry| {
4059            let name = entry
4060                .get("Name")
4061                .and_then(|v| v.as_str())
4062                .unwrap_or("")
4063                .trim()
4064                .to_string();
4065            if name.is_empty() {
4066                return None;
4067            }
4068            Some(WindowsSoundDevice {
4069                name,
4070                status: entry
4071                    .get("Status")
4072                    .and_then(|v| v.as_str())
4073                    .unwrap_or("Unknown")
4074                    .trim()
4075                    .to_string(),
4076                manufacturer: entry
4077                    .get("Manufacturer")
4078                    .and_then(|v| v.as_str())
4079                    .map(|v| v.trim().to_string()),
4080            })
4081        })
4082        .collect()
4083}
4084
4085#[cfg(target_os = "windows")]
4086fn windows_device_has_issue(device: &WindowsPnpDevice) -> bool {
4087    !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
4088        || device.problem.unwrap_or(0) != 0
4089}
4090
4091#[cfg(target_os = "windows")]
4092fn windows_sound_device_has_issue(device: &WindowsSoundDevice) -> bool {
4093    !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
4094}
4095
4096#[cfg(target_os = "windows")]
4097fn is_microphone_like_name(name: &str) -> bool {
4098    let lower = name.to_ascii_lowercase();
4099    lower.contains("microphone")
4100        || lower.contains("mic")
4101        || lower.contains("input")
4102        || lower.contains("array")
4103        || lower.contains("capture")
4104        || lower.contains("record")
4105}
4106
4107#[cfg(target_os = "windows")]
4108fn is_bluetooth_like_name(name: &str) -> bool {
4109    let lower = name.to_ascii_lowercase();
4110    lower.contains("bluetooth") || lower.contains("hands-free") || lower.contains("a2dp")
4111}
4112
4113#[cfg(target_os = "windows")]
4114fn service_is_running(service: &ServiceEntry) -> bool {
4115    service.status.eq_ignore_ascii_case("running") || service.status.eq_ignore_ascii_case("active")
4116}
4117
4118#[cfg(not(target_os = "windows"))]
4119fn parse_unix_services(status_text: &str, startup_text: &str) -> Vec<ServiceEntry> {
4120    let mut startup_modes = std::collections::HashMap::<String, String>::new();
4121    for line in startup_text.lines() {
4122        let mut it = line.split_whitespace();
4123        if let (Some(name), Some(mode)) = (it.next(), it.next()) {
4124            startup_modes.insert(name.to_string(), mode.to_string());
4125        }
4126    }
4127
4128    let mut services = Vec::new();
4129    for line in status_text.lines() {
4130        let mut it = line.split_whitespace();
4131        let Some(unit) = it.next() else {
4132            continue;
4133        };
4134        let Some(load) = it.next() else {
4135            continue;
4136        };
4137        let Some(active) = it.next() else {
4138            continue;
4139        };
4140        let Some(sub) = it.next() else {
4141            continue;
4142        };
4143        let description = {
4144            let mut desc = String::new();
4145            for (i, w) in it.enumerate() {
4146                if i > 0 {
4147                    desc.push(' ');
4148                }
4149                desc.push_str(w);
4150            }
4151            if desc.is_empty() {
4152                None
4153            } else {
4154                Some(desc)
4155            }
4156        };
4157        services.push(ServiceEntry {
4158            name: unit.to_string(),
4159            status: format!("{}/{}", active, sub),
4160            startup: startup_modes
4161                .get(unit)
4162                .cloned()
4163                .or_else(|| Some(load.to_string())),
4164            display_name: description,
4165            start_name: None,
4166        });
4167    }
4168
4169    services
4170}
4171
4172// ── health_report ─────────────────────────────────────────────────────────────
4173
4174/// Synthesized system health report — runs multiple checks and returns a
4175/// plain-English tiered verdict suitable for both developers and non-technical
4176/// users who just want to know if their machine is okay.
4177fn inspect_health_report() -> Result<String, String> {
4178    let mut needs_fix: Vec<String> = Vec::with_capacity(8);
4179    let mut watch: Vec<String> = Vec::with_capacity(8);
4180    let mut good: Vec<String> = Vec::with_capacity(8);
4181    let mut tips: Vec<String> = Vec::with_capacity(8);
4182
4183    health_check_disk(&mut needs_fix, &mut watch, &mut good);
4184    health_check_memory(&mut watch, &mut good);
4185    health_check_network(&mut needs_fix, &mut watch, &mut good);
4186    health_check_pending_reboot(&mut watch, &mut good);
4187    health_check_services(&mut needs_fix, &mut watch, &mut good);
4188    health_check_thermal(&mut watch, &mut good);
4189    health_check_tools(&mut watch, &mut good, &mut tips);
4190    health_check_recent_errors(&mut watch, &mut tips);
4191
4192    let overall = if !needs_fix.is_empty() {
4193        "ACTION REQUIRED"
4194    } else if !watch.is_empty() {
4195        "WORTH A LOOK"
4196    } else {
4197        "ALL GOOD"
4198    };
4199
4200    let mut out = format!("System Health Report — {overall}\n\n");
4201
4202    if !needs_fix.is_empty() {
4203        out.push_str("Needs fixing:\n");
4204        for item in &needs_fix {
4205            let _ = writeln!(out, "  [!] {item}");
4206        }
4207        out.push('\n');
4208    }
4209    if !watch.is_empty() {
4210        out.push_str("Worth watching:\n");
4211        for item in &watch {
4212            let _ = writeln!(out, "  [-] {item}");
4213        }
4214        out.push('\n');
4215    }
4216    if !good.is_empty() {
4217        out.push_str("Looking good:\n");
4218        for item in &good {
4219            let _ = writeln!(out, "  [+] {item}");
4220        }
4221        out.push('\n');
4222    }
4223    if !tips.is_empty() {
4224        out.push_str("To dig deeper:\n");
4225        for tip in &tips {
4226            let _ = writeln!(out, "  {tip}");
4227        }
4228    }
4229
4230    Ok(out.trim_end().to_string())
4231}
4232
4233fn health_check_disk(needs_fix: &mut Vec<String>, watch: &mut Vec<String>, good: &mut Vec<String>) {
4234    #[cfg(target_os = "windows")]
4235    {
4236        let script = r#"try {
4237    $d = Get-PSDrive C -ErrorAction Stop
4238    "$($d.Free)|$($d.Used)"
4239} catch { "ERR" }"#;
4240        if let Ok(out) = Command::new("powershell")
4241            .args(["-NoProfile", "-Command", script])
4242            .output()
4243        {
4244            let text = String::from_utf8_lossy(&out.stdout);
4245            let text = text.trim();
4246            if !text.starts_with("ERR") {
4247                let mut it = text.splitn(3, '|');
4248                if let (Some(p0), Some(p1)) = (it.next(), it.next()) {
4249                    let free_bytes: u64 = p0.trim().parse().unwrap_or(0);
4250                    let used_bytes: u64 = p1.trim().parse().unwrap_or(0);
4251                    let total = free_bytes + used_bytes;
4252                    let free_gb = free_bytes / 1_073_741_824;
4253                    let pct_free = if total > 0 {
4254                        (free_bytes as f64 / total as f64 * 100.0) as u64
4255                    } else {
4256                        0
4257                    };
4258                    let msg = format!("Disk: {free_gb} GB free on C: ({pct_free}% available)");
4259                    if free_gb < 5 {
4260                        needs_fix.push(format!(
4261                            "{msg} — very low. Free up space or your system may slow down or stop working."
4262                        ));
4263                    } else if free_gb < 15 {
4264                        watch.push(format!("{msg} — getting low, consider cleaning up."));
4265                    } else {
4266                        good.push(msg);
4267                    }
4268                    return;
4269                }
4270            }
4271        }
4272        watch.push("Disk: could not read free space from C: drive.".to_string());
4273    }
4274
4275    #[cfg(not(target_os = "windows"))]
4276    {
4277        if let Ok(out) = Command::new("df").args(["-BG", "/"]).output() {
4278            let text = String::from_utf8_lossy(&out.stdout);
4279            for line in text.lines().skip(1) {
4280                let mut it = line.split_whitespace();
4281                if let (Some(_), Some(_), Some(_), Some(avail_raw), Some(use_pct_raw)) =
4282                    (it.next(), it.next(), it.next(), it.next(), it.next())
4283                {
4284                    let avail_str = avail_raw.trim_end_matches('G');
4285                    let use_pct = use_pct_raw.trim_end_matches('%');
4286                    let avail_gb: u64 = avail_str.parse().unwrap_or(0);
4287                    let used_pct: u64 = use_pct.parse().unwrap_or(0);
4288                    let msg = format!("Disk: {avail_gb} GB free on / ({used_pct}% used)");
4289                    if avail_gb < 5 {
4290                        needs_fix.push(format!(
4291                            "{msg} — very low. Free up space to prevent system issues."
4292                        ));
4293                    } else if avail_gb < 15 {
4294                        watch.push(format!("{msg} — getting low."));
4295                    } else {
4296                        good.push(msg);
4297                    }
4298                    return;
4299                }
4300            }
4301        }
4302        watch.push("Disk: could not determine free space.".to_string());
4303    }
4304}
4305
4306fn health_check_memory(watch: &mut Vec<String>, good: &mut Vec<String>) {
4307    #[cfg(target_os = "windows")]
4308    {
4309        let script = r#"try {
4310    $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
4311    "$($os.FreePhysicalMemory)|$($os.TotalVisibleMemorySize)"
4312} catch { "ERR" }"#;
4313        if let Ok(out) = Command::new("powershell")
4314            .args(["-NoProfile", "-Command", script])
4315            .output()
4316        {
4317            let text = String::from_utf8_lossy(&out.stdout);
4318            let text = text.trim();
4319            if !text.starts_with("ERR") {
4320                let mut it = text.splitn(3, '|');
4321                if let (Some(p0), Some(p1)) = (it.next(), it.next()) {
4322                    let free_kb: u64 = p0.trim().parse().unwrap_or(0);
4323                    let total_kb: u64 = p1.trim().parse().unwrap_or(0);
4324                    if total_kb > 0 {
4325                        let free_gb = free_kb / 1_048_576;
4326                        let total_gb = total_kb / 1_048_576;
4327                        let free_pct = free_kb * 100 / total_kb;
4328                        let msg = format!(
4329                            "RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)"
4330                        );
4331                        if free_pct < 10 {
4332                            watch.push(format!(
4333                                "{msg} — very low. Close unused apps to free up memory."
4334                            ));
4335                        } else if free_pct < 25 {
4336                            watch.push(format!("{msg} — running a bit low."));
4337                        } else {
4338                            good.push(msg);
4339                        }
4340                    }
4341                }
4342            }
4343        }
4344    }
4345
4346    #[cfg(not(target_os = "windows"))]
4347    {
4348        if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4349            let mut total_kb = 0u64;
4350            let mut avail_kb = 0u64;
4351            for line in content.lines() {
4352                if line.starts_with("MemTotal:") {
4353                    total_kb = line
4354                        .split_whitespace()
4355                        .nth(1)
4356                        .and_then(|v| v.parse().ok())
4357                        .unwrap_or(0);
4358                } else if line.starts_with("MemAvailable:") {
4359                    avail_kb = line
4360                        .split_whitespace()
4361                        .nth(1)
4362                        .and_then(|v| v.parse().ok())
4363                        .unwrap_or(0);
4364                }
4365            }
4366            if total_kb > 0 {
4367                let free_gb = avail_kb / 1_048_576;
4368                let total_gb = total_kb / 1_048_576;
4369                let free_pct = avail_kb * 100 / total_kb;
4370                let msg =
4371                    format!("RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)");
4372                if free_pct < 10 {
4373                    watch.push(format!("{msg} — very low. Close unused apps."));
4374                } else if free_pct < 25 {
4375                    watch.push(format!("{msg} — running a bit low."));
4376                } else {
4377                    good.push(msg);
4378                }
4379            }
4380        }
4381    }
4382}
4383
4384/// Try running `cmd --arg` via PATH first, then via a known install-path fallback.
4385/// Prevents false "not installed" reports when the process PATH omits tool directories
4386/// (e.g. ~/.cargo/bin missing from a shortcut-launched or headless session).
4387fn probe_tool(cmd: &str, arg: &str) -> bool {
4388    if Command::new(cmd)
4389        .arg(arg)
4390        .stdout(std::process::Stdio::null())
4391        .stderr(std::process::Stdio::null())
4392        .status()
4393        .map(|s| s.success())
4394        .unwrap_or(false)
4395    {
4396        return true;
4397    }
4398    // Fallback: well-known Windows install locations for tools that live outside system32.
4399    #[cfg(windows)]
4400    {
4401        let home = std::env::var("USERPROFILE").unwrap_or_default();
4402        let fallback: Option<String> = match cmd {
4403            "cargo" | "rustc" | "rustup" => Some(format!(r"{}\\.cargo\\bin\\{}.exe", home, cmd)),
4404            "node" => Some(r"C:\Program Files\nodejs\node.exe".to_string()),
4405            "npm" => Some("C:\\Program Files\\nodejs\\npm.cmd".to_string()),
4406            _ => None,
4407        };
4408        if let Some(path) = fallback {
4409            return Command::new(&path)
4410                .arg(arg)
4411                .stdout(std::process::Stdio::null())
4412                .stderr(std::process::Stdio::null())
4413                .status()
4414                .map(|s| s.success())
4415                .unwrap_or(false);
4416        }
4417    }
4418    false
4419}
4420
4421fn health_check_tools(watch: &mut Vec<String>, good: &mut Vec<String>, tips: &mut Vec<String>) {
4422    let tool_checks: &[(&str, &str, &str)] = &[
4423        ("git", "--version", "Git"),
4424        ("cargo", "--version", "Rust / Cargo"),
4425        ("node", "--version", "Node.js"),
4426        ("python", "--version", "Python"),
4427        ("python3", "--version", "Python 3"),
4428        ("npm", "--version", "npm"),
4429    ];
4430
4431    let mut found: Vec<String> = Vec::with_capacity(tool_checks.len());
4432    let mut missing: Vec<String> = Vec::with_capacity(tool_checks.len());
4433    let mut python_found = false;
4434
4435    for (cmd, arg, label) in tool_checks {
4436        if cmd.starts_with("python") && python_found {
4437            continue;
4438        }
4439        let ok = probe_tool(cmd, arg);
4440        if ok {
4441            found.push((*label).to_string());
4442            if cmd.starts_with("python") {
4443                python_found = true;
4444            }
4445        } else if !cmd.starts_with("python") || !python_found {
4446            missing.push((*label).to_string());
4447        }
4448    }
4449
4450    if !found.is_empty() {
4451        good.push(format!("Dev tools found: {}", found.join(", ")));
4452    }
4453    if !missing.is_empty() {
4454        watch.push(format!(
4455            "Not installed (or not on PATH): {} — only matters if you need them",
4456            missing.join(", ")
4457        ));
4458        tips.push(
4459            "Run inspect_host(topic=\"toolchains\") for exact version details on all dev tools."
4460                .to_string(),
4461        );
4462    }
4463}
4464
4465fn health_check_recent_errors(watch: &mut Vec<String>, tips: &mut Vec<String>) {
4466    #[cfg(target_os = "windows")]
4467    {
4468        let script = r#"try {
4469    $cutoff = (Get-Date).AddHours(-24)
4470    $count = (Get-WinEvent -FilterHashtable @{LogName='Application','System'; Level=1,2,3; StartTime=$cutoff} -MaxEvents 200 -ErrorAction SilentlyContinue | Measure-Object).Count
4471    $count
4472} catch { "0" }"#;
4473        if let Ok(out) = Command::new("powershell")
4474            .args(["-NoProfile", "-Command", script])
4475            .output()
4476        {
4477            let text = String::from_utf8_lossy(&out.stdout);
4478            let count: u64 = text.trim().parse().unwrap_or(0);
4479            if count > 0 {
4480                watch.push(format!(
4481                    "{count} critical/error event{} in Windows event logs in the last 24 hours.",
4482                    if count == 1 { "" } else { "s" }
4483                ));
4484                tips.push(
4485                    "Run inspect_host(topic=\"log_check\") to see the actual error messages."
4486                        .to_string(),
4487                );
4488            }
4489        }
4490    }
4491
4492    #[cfg(not(target_os = "windows"))]
4493    {
4494        if let Ok(out) = Command::new("journalctl")
4495            .args(["-p", "3", "-n", "1", "--no-pager", "--quiet"])
4496            .output()
4497        {
4498            let text = String::from_utf8_lossy(&out.stdout);
4499            if !text.trim().is_empty() {
4500                watch.push("Critical/error entries found in the system journal.".to_string());
4501                tips.push(
4502                    "Run inspect_host(topic=\"log_check\") to see recent errors.".to_string(),
4503                );
4504            }
4505        }
4506    }
4507}
4508
4509fn health_check_network(
4510    needs_fix: &mut Vec<String>,
4511    watch: &mut Vec<String>,
4512    good: &mut Vec<String>,
4513) {
4514    #[cfg(target_os = "windows")]
4515    {
4516        // Use .NET Ping directly — PS5.1 compatible, 2-second timeout.
4517        let script = r#"try {
4518    $ping = New-Object System.Net.NetworkInformation.Ping
4519    $r = $ping.Send("1.1.1.1", 2000)
4520    if ($r.Status -eq 'Success') { "OK|$($r.RoundtripTime)" } else { "FAIL" }
4521} catch { "FAIL" }"#;
4522        if let Ok(out) = Command::new("powershell")
4523            .args(["-NoProfile", "-Command", script])
4524            .output()
4525        {
4526            let text = String::from_utf8_lossy(&out.stdout);
4527            let text = text.trim();
4528            if text.starts_with("OK") {
4529                let latency = text.split('|').nth(1).unwrap_or("?");
4530                let latency_ms: u64 = latency.parse().unwrap_or(0);
4531                let msg = format!("Internet connectivity: reachable ({}ms RTT)", latency_ms);
4532                if latency_ms > 300 {
4533                    watch.push(format!("{msg} — high latency, may indicate network issue."));
4534                } else {
4535                    good.push(msg);
4536                }
4537            } else {
4538                needs_fix.push(
4539                    "Internet connectivity: unreachable — could not ping 1.1.1.1. \
4540                     Check adapter, gateway, or DNS."
4541                        .to_string(),
4542                );
4543            }
4544            return;
4545        }
4546        watch.push("Network: could not run connectivity check.".to_string());
4547    }
4548
4549    #[cfg(not(target_os = "windows"))]
4550    {
4551        let _ = watch;
4552        let ok = Command::new("ping")
4553            .args(["-c", "1", "-W", "2", "1.1.1.1"])
4554            .stdout(std::process::Stdio::null())
4555            .stderr(std::process::Stdio::null())
4556            .status()
4557            .map(|s| s.success())
4558            .unwrap_or(false);
4559        if ok {
4560            good.push("Internet connectivity: reachable.".to_string());
4561        } else {
4562            needs_fix.push("Internet connectivity: unreachable — cannot ping 1.1.1.1.".to_string());
4563        }
4564    }
4565}
4566
4567fn health_check_pending_reboot(watch: &mut Vec<String>, good: &mut Vec<String>) {
4568    #[cfg(target_os = "windows")]
4569    {
4570        let script = r#"try {
4571    $pending = $false
4572    $reasons = @()
4573    if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending' -ErrorAction SilentlyContinue) {
4574        $pending = $true; $reasons += 'CBS/component update'
4575    }
4576    if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired' -ErrorAction SilentlyContinue) {
4577        $pending = $true; $reasons += 'Windows Update'
4578    }
4579    $pfr = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue)
4580    if ($pfr -and $pfr.PendingFileRenameOperations) {
4581        $pending = $true; $reasons += 'file rename ops'
4582    }
4583    if ($pending) { "PENDING|$($reasons -join ',')" } else { "OK" }
4584} catch { "OK" }"#;
4585        if let Ok(out) = Command::new("powershell")
4586            .args(["-NoProfile", "-Command", script])
4587            .output()
4588        {
4589            let text = String::from_utf8_lossy(&out.stdout);
4590            let text = text.trim();
4591            if text.starts_with("PENDING") {
4592                let reasons = text.split('|').nth(1).unwrap_or("unknown reason");
4593                watch.push(format!(
4594                    "Pending reboot required ({reasons}) — restart when convenient to apply changes."
4595                ));
4596            } else {
4597                good.push("No pending reboot.".to_string());
4598            }
4599        }
4600    }
4601
4602    #[cfg(not(target_os = "windows"))]
4603    {
4604        // Linux: check if a kernel update is pending (requires reboot to take effect)
4605        if std::path::Path::new("/var/run/reboot-required").exists() {
4606            watch.push(
4607                "Pending reboot required — a kernel or package update needs a restart.".to_string(),
4608            );
4609        } else {
4610            good.push("No pending reboot.".to_string());
4611        }
4612    }
4613}
4614
4615fn health_check_services(
4616    needs_fix: &mut Vec<String>,
4617    watch: &mut Vec<String>,
4618    good: &mut Vec<String>,
4619) {
4620    #[cfg(not(target_os = "windows"))]
4621    let _ = (&needs_fix, &good);
4622    #[cfg(target_os = "windows")]
4623    let _ = &watch;
4624
4625    #[cfg(target_os = "windows")]
4626    {
4627        // Only checks services whose being stopped indicates a real system problem.
4628        let script = r#"try {
4629    $names = @('EventLog','WinDefend','Dnscache')
4630    $stopped = @()
4631    foreach ($n in $names) {
4632        $s = Get-Service $n -ErrorAction SilentlyContinue
4633        if ($s -and $s.Status -ne 'Running') { $stopped += $n }
4634    }
4635    if ($stopped.Count -gt 0) { "STOPPED|$($stopped -join ',')" } else { "OK" }
4636} catch { "OK" }"#;
4637        if let Ok(out) = Command::new("powershell")
4638            .args(["-NoProfile", "-Command", script])
4639            .output()
4640        {
4641            let text = String::from_utf8_lossy(&out.stdout);
4642            let text = text.trim();
4643            if text.starts_with("STOPPED") {
4644                let names = text.split('|').nth(1).unwrap_or("unknown");
4645                needs_fix.push(format!(
4646                    "Critical service(s) not running: {names} — these should always be active."
4647                ));
4648            } else {
4649                good.push("Core services (Event Log, Defender, DNS) all running.".to_string());
4650            }
4651        }
4652    }
4653
4654    #[cfg(not(target_os = "windows"))]
4655    {
4656        // Linux: check systemd failed units
4657        if let Ok(out) = Command::new("systemctl")
4658            .args(["--failed", "--no-legend", "--plain"])
4659            .output()
4660        {
4661            let text = String::from_utf8_lossy(&out.stdout);
4662            let failed: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
4663            if !failed.is_empty() {
4664                watch.push(format!(
4665                    "{} failed systemd unit(s): {}",
4666                    failed.len(),
4667                    failed.join(", ")
4668                ));
4669            } else {
4670                good.push("No failed systemd units.".to_string());
4671            }
4672        }
4673    }
4674}
4675
4676fn health_check_thermal(watch: &mut Vec<String>, good: &mut Vec<String>) {
4677    #[cfg(target_os = "windows")]
4678    {
4679        // WMI thermal zones — best-effort, silently skip if unavailable or requires elevation.
4680        let script = r#"try {
4681    $zones = Get-WmiObject -Namespace "root/wmi" -Class MSAcpi_ThermalZoneTemperature -ErrorAction Stop
4682    $temps = $zones | ForEach-Object { [math]::Round(($_.CurrentTemperature - 2732) / 10, 1) }
4683    $max = ($temps | Measure-Object -Maximum).Maximum
4684    "$max"
4685} catch { "NA" }"#;
4686        if let Ok(out) = Command::new("powershell")
4687            .args(["-NoProfile", "-Command", script])
4688            .output()
4689        {
4690            let text = String::from_utf8_lossy(&out.stdout);
4691            let text = text.trim();
4692            if text != "NA" && !text.is_empty() {
4693                if let Ok(temp) = text.parse::<f64>() {
4694                    let msg = format!("CPU thermal: {temp:.0}°C peak zone temperature");
4695                    if temp >= 90.0 {
4696                        watch.push(format!("{msg} — very high, check cooling and airflow."));
4697                    } else if temp >= 75.0 {
4698                        watch.push(format!(
4699                            "{msg} — elevated under load, monitor for throttling."
4700                        ));
4701                    } else {
4702                        good.push(format!("{msg} — normal."));
4703                    }
4704                }
4705            }
4706            // If NA or unparseable, skip silently — thermal WMI often needs admin.
4707        }
4708    }
4709
4710    #[cfg(not(target_os = "windows"))]
4711    {
4712        // Linux: read first available hwmon temp input
4713        let paths = [
4714            "/sys/class/thermal/thermal_zone0/temp",
4715            "/sys/class/hwmon/hwmon0/temp1_input",
4716        ];
4717        for path in &paths {
4718            if let Ok(content) = std::fs::read_to_string(path) {
4719                if let Ok(raw) = content.trim().parse::<u64>() {
4720                    let temp_c = raw / 1000;
4721                    let msg = format!("CPU thermal: {temp_c}°C");
4722                    if temp_c >= 90 {
4723                        watch.push(format!("{msg} — very high, check cooling."));
4724                    } else if temp_c >= 75 {
4725                        watch.push(format!("{msg} — elevated under load."));
4726                    } else {
4727                        good.push(format!("{msg} — normal."));
4728                    }
4729                    return;
4730                }
4731            }
4732        }
4733    }
4734}
4735
4736// ── log_check ─────────────────────────────────────────────────────────────────
4737
4738fn inspect_log_check(lookback_hours: Option<u32>, max_entries: usize) -> Result<String, String> {
4739    let mut out = String::from("Host inspection: log_check\n\n");
4740
4741    #[cfg(target_os = "windows")]
4742    {
4743        // Pull recent critical/error events from Windows Application and System logs.
4744        let hours = lookback_hours.unwrap_or(24);
4745        let _ = write!(
4746            out,
4747            "Checking System/Application logs from the last {} hours...\n\n",
4748            hours
4749        );
4750
4751        let n = max_entries.clamp(1, 50);
4752        let script = format!(
4753            r#"try {{
4754    $events = Get-WinEvent -FilterHashtable @{{LogName='Application','System'; Level=1,2,3; StartTime=(Get-Date).AddHours(-{hours})}} -MaxEvents 100 -ErrorAction SilentlyContinue
4755    if (-not $events) {{ "NO_EVENTS"; exit }}
4756    $events | Select-Object -First {n} | ForEach-Object {{
4757        $line = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + '|' + $_.LevelDisplayName + '|' + $_.ProviderName + '|' + (($_.Message -split '[\r\n]')[0].Trim())
4758        $line
4759    }}
4760}} catch {{ "ERROR:" + $_.Exception.Message }}"#,
4761            hours = hours,
4762            n = n
4763        );
4764        let output = Command::new("powershell")
4765            .args(["-NoProfile", "-Command", &script])
4766            .output()
4767            .map_err(|e| format!("log_check: failed to run PowerShell: {e}"))?;
4768
4769        let raw = String::from_utf8_lossy(&output.stdout);
4770        let text = raw.trim();
4771
4772        if text.is_empty() || text == "NO_EVENTS" {
4773            out.push_str("No critical or error events found in Application/System logs.\n");
4774            return Ok(out.trim_end().to_string());
4775        }
4776        if text.starts_with("ERROR:") {
4777            let _ = writeln!(out, "Warning: event log query returned: {text}");
4778            return Ok(out.trim_end().to_string());
4779        }
4780
4781        let mut count = 0usize;
4782        for line in text.lines() {
4783            let mut it = line.splitn(4, '|');
4784            if let (Some(time), Some(level), Some(source), Some(msg)) =
4785                (it.next(), it.next(), it.next(), it.next())
4786            {
4787                let _ = writeln!(out, "[{time}] [{level}] {source}: {msg}");
4788                count += 1;
4789            }
4790        }
4791        let _ = write!(
4792            out,
4793            "\nEvents shown: {count} (critical/error from Application + System logs)\n"
4794        );
4795    }
4796
4797    #[cfg(not(target_os = "windows"))]
4798    {
4799        let _ = lookback_hours;
4800        // Use journalctl on Linux/macOS if available.
4801        let n = max_entries.clamp(1, 50).to_string();
4802        let output = Command::new("journalctl")
4803            .args(["-p", "3", "-n", &n, "--no-pager", "--output=short-precise"])
4804            .output();
4805
4806        match output {
4807            Ok(o) if o.status.success() => {
4808                let text = String::from_utf8_lossy(&o.stdout);
4809                let trimmed = text.trim();
4810                if trimmed.is_empty() || trimmed.contains("No entries") {
4811                    out.push_str("No critical or error entries found in the system journal.\n");
4812                } else {
4813                    out.push_str(trimmed);
4814                    out.push('\n');
4815                    out.push_str("\n(source: journalctl -p 3 = critical/alert/emergency/error)\n");
4816                }
4817            }
4818            _ => {
4819                // Fallback: check /var/log/syslog or /var/log/messages
4820                let log_paths = ["/var/log/syslog", "/var/log/messages"];
4821                let mut found = false;
4822                for log_path in &log_paths {
4823                    if let Ok(content) = std::fs::read_to_string(log_path) {
4824                        let lines: Vec<&str> = content.lines().collect();
4825                        let mut tail: Vec<&str> = lines
4826                            .iter()
4827                            .rev()
4828                            .filter(|l| {
4829                                let l_lower = l.to_ascii_lowercase();
4830                                l_lower.contains("error") || l_lower.contains("crit")
4831                            })
4832                            .take(max_entries)
4833                            .copied()
4834                            .collect::<Vec<_>>();
4835                        tail.reverse();
4836                        if !tail.is_empty() {
4837                            let _ = write!(out, "Source: {log_path}\n");
4838                            for l in &tail {
4839                                out.push_str(l);
4840                                out.push('\n');
4841                            }
4842                            found = true;
4843                            break;
4844                        }
4845                    }
4846                }
4847                if !found {
4848                    out.push_str(
4849                        "journalctl not found and no readable syslog detected on this system.\n",
4850                    );
4851                }
4852            }
4853        }
4854    }
4855
4856    Ok(out.trim_end().to_string())
4857}
4858
4859// ── startup_items ─────────────────────────────────────────────────────────────
4860
4861fn inspect_startup_items(max_entries: usize) -> Result<String, String> {
4862    let mut out = String::from("Host inspection: startup_items\n\n");
4863
4864    #[cfg(target_os = "windows")]
4865    {
4866        // Query both HKLM and HKCU Run keys.
4867        let script = r#"
4868$hives = @(
4869    @{Hive='HKLM'; Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4870    @{Hive='HKCU'; Path='HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4871    @{Hive='HKLM (32-bit)'; Path='HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'}
4872)
4873foreach ($h in $hives) {
4874    try {
4875        $props = Get-ItemProperty -Path $h.Path -ErrorAction Stop
4876        $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
4877            "$($h.Hive)|$($_.Name)|$($_.Value)"
4878        }
4879    } catch {}
4880}
4881"#;
4882        let output = Command::new("powershell")
4883            .args(["-NoProfile", "-Command", script])
4884            .output()
4885            .map_err(|e| format!("startup_items: failed to run PowerShell: {e}"))?;
4886
4887        let raw = String::from_utf8_lossy(&output.stdout);
4888        let text = raw.trim();
4889
4890        let entries: Vec<(String, String, String)> = text
4891            .lines()
4892            .filter_map(|l| {
4893                let mut it = l.splitn(3, '|');
4894                match (it.next(), it.next(), it.next()) {
4895                    (Some(a), Some(b), Some(c)) => {
4896                        Some((a.to_string(), b.to_string(), c.to_string()))
4897                    }
4898                    _ => None,
4899                }
4900            })
4901            .take(max_entries)
4902            .collect();
4903
4904        if entries.is_empty() {
4905            out.push_str("No startup entries found in the Windows Run registry keys.\n");
4906        } else {
4907            out.push_str("Registry run keys (programs that start with Windows):\n\n");
4908            let mut last_hive = String::new();
4909            for (hive, name, value) in &entries {
4910                if *hive != last_hive {
4911                    let _ = writeln!(out, "[{}]", hive);
4912                    last_hive = hive.clone();
4913                }
4914                // Truncate very long values (paths with many args)
4915                let display = if value.len() > 100 {
4916                    format!("{}…", safe_head(value, 100))
4917                } else {
4918                    value.clone()
4919                };
4920                let _ = writeln!(out, "  {name}: {display}");
4921            }
4922            let _ = write!(out, "\nTotal startup entries: {}\n", entries.len());
4923        }
4924
4925        // 3. Unified Startup Command check (Task Manager style)
4926        let unified_script = r#"Get-CimInstance Win32_StartupCommand | ForEach-Object { "  $($_.Name): $($_.Command) ($($_.Location))" }"#;
4927        if let Ok(unified_out) = Command::new("powershell")
4928            .args(["-NoProfile", "-Command", unified_script])
4929            .output()
4930        {
4931            let unified_text = String::from_utf8_lossy(&unified_out.stdout);
4932            let trimmed = unified_text.trim();
4933            if !trimmed.is_empty() {
4934                out.push_str("\n=== Unified Startup Commands (WMI) ===\n");
4935                out.push_str(trimmed);
4936                out.push('\n');
4937            }
4938        }
4939    }
4940
4941    #[cfg(not(target_os = "windows"))]
4942    {
4943        // On Linux: systemd enabled services + cron @reboot entries.
4944        let output = Command::new("systemctl")
4945            .args([
4946                "list-unit-files",
4947                "--type=service",
4948                "--state=enabled",
4949                "--no-legend",
4950                "--no-pager",
4951                "--plain",
4952            ])
4953            .output();
4954
4955        match output {
4956            Ok(o) if o.status.success() => {
4957                let text = String::from_utf8_lossy(&o.stdout);
4958                let services: Vec<&str> = text
4959                    .lines()
4960                    .filter(|l| !l.trim().is_empty())
4961                    .take(max_entries)
4962                    .collect();
4963                if services.is_empty() {
4964                    out.push_str("No enabled systemd services found.\n");
4965                } else {
4966                    out.push_str("Enabled systemd services (run at boot):\n\n");
4967                    for s in &services {
4968                        let _ = write!(out, "  {s}\n");
4969                    }
4970                    let _ = write!(out, "\nShowing {} of enabled services.\n", services.len());
4971                }
4972            }
4973            _ => {
4974                out.push_str(
4975                    "systemctl not found on this system. Cannot enumerate startup services.\n",
4976                );
4977            }
4978        }
4979
4980        // Check @reboot cron entries.
4981        if let Ok(cron_out) = Command::new("crontab").args(["-l"]).output() {
4982            let cron_text = String::from_utf8_lossy(&cron_out.stdout);
4983            let reboot_entries: Vec<&str> = cron_text
4984                .lines()
4985                .filter(|l| l.trim_start().starts_with("@reboot"))
4986                .collect();
4987            if !reboot_entries.is_empty() {
4988                out.push_str("\nCron @reboot entries:\n");
4989                for e in reboot_entries {
4990                    let _ = write!(out, "  {e}\n");
4991                }
4992            }
4993        }
4994    }
4995
4996    Ok(out.trim_end().to_string())
4997}
4998
4999fn inspect_os_config() -> Result<String, String> {
5000    let mut out = String::from("Host inspection: OS Configuration\n\n");
5001
5002    #[cfg(target_os = "windows")]
5003    {
5004        // Power Plan
5005        if let Ok(power_out) = Command::new("powercfg").args(["/getactivescheme"]).output() {
5006            let power_str = String::from_utf8_lossy(&power_out.stdout);
5007            out.push_str("=== Power Plan ===\n");
5008            out.push_str(power_str.trim());
5009            out.push_str("\n\n");
5010        }
5011
5012        // Firewall Status
5013        let fw_script =
5014            "Get-NetFirewallProfile | Format-Table -Property Name, Enabled -AutoSize | Out-String";
5015        if let Ok(fw_out) = Command::new("powershell")
5016            .args(["-NoProfile", "-Command", fw_script])
5017            .output()
5018        {
5019            let fw_str = String::from_utf8_lossy(&fw_out.stdout);
5020            out.push_str("=== Firewall Profiles ===\n");
5021            out.push_str(fw_str.trim());
5022            out.push_str("\n\n");
5023        }
5024
5025        // System Uptime
5026        let uptime_script =
5027            "(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString()";
5028        if let Ok(uptime_out) = Command::new("powershell")
5029            .args(["-NoProfile", "-Command", uptime_script])
5030            .output()
5031        {
5032            let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
5033            out.push_str("=== System Uptime (Last Boot) ===\n");
5034            out.push_str(uptime_str.trim());
5035            out.push_str("\n\n");
5036        }
5037    }
5038
5039    #[cfg(not(target_os = "windows"))]
5040    {
5041        // Uptime
5042        if let Ok(uptime_out) = Command::new("uptime").args(["-p"]).output() {
5043            let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
5044            out.push_str("=== System Uptime ===\n");
5045            out.push_str(uptime_str.trim());
5046            out.push_str("\n\n");
5047        }
5048
5049        // Firewall (ufw status if available)
5050        if let Ok(ufw_out) = Command::new("ufw").arg("status").output() {
5051            let ufw_str = String::from_utf8_lossy(&ufw_out.stdout);
5052            if !ufw_str.trim().is_empty() {
5053                out.push_str("=== Firewall (UFW) ===\n");
5054                out.push_str(ufw_str.trim());
5055                out.push_str("\n\n");
5056            }
5057        }
5058    }
5059    Ok(out.trim_end().to_string())
5060}
5061
5062pub async fn resolve_host_issue(args: &Value) -> Result<String, String> {
5063    let action = args
5064        .get("action")
5065        .and_then(|v| v.as_str())
5066        .ok_or_else(|| "Missing required argument: 'action'".to_string())?;
5067
5068    let target = args
5069        .get("target")
5070        .and_then(|v| v.as_str())
5071        .unwrap_or("")
5072        .trim();
5073
5074    if target.is_empty() && action != "clear_temp" {
5075        return Err("Missing required argument: 'target' for this action".to_string());
5076    }
5077
5078    match action {
5079        "install_package" => {
5080            #[cfg(target_os = "windows")]
5081            {
5082                let cmd = format!("winget install --id {} -e --accept-package-agreements --accept-source-agreements", target);
5083                match Command::new("powershell")
5084                    .args(["-NoProfile", "-Command", &cmd])
5085                    .output()
5086                {
5087                    Ok(out) => Ok(format!(
5088                        "Executed remediation (winget install):\n{}",
5089                        String::from_utf8_lossy(&out.stdout)
5090                    )),
5091                    Err(e) => Err(format!("Failed to run winget: {}", e)),
5092                }
5093            }
5094            #[cfg(not(target_os = "windows"))]
5095            {
5096                Err(
5097                    "install_package via wrapper is only supported on Windows currently (winget)"
5098                        .to_string(),
5099                )
5100            }
5101        }
5102        "restart_service" => {
5103            #[cfg(target_os = "windows")]
5104            {
5105                let cmd = format!("Restart-Service -Name {} -Force", target);
5106                match Command::new("powershell")
5107                    .args(["-NoProfile", "-Command", &cmd])
5108                    .output()
5109                {
5110                    Ok(out) => {
5111                        let err_str = String::from_utf8_lossy(&out.stderr);
5112                        if !err_str.is_empty() {
5113                            return Err(format!("Error restarting service:\n{}", err_str));
5114                        }
5115                        Ok(format!("Successfully restarted service: {}", target))
5116                    }
5117                    Err(e) => Err(format!("Failed to restart service: {}", e)),
5118                }
5119            }
5120            #[cfg(not(target_os = "windows"))]
5121            {
5122                Err(
5123                    "restart_service via wrapper is only supported on Windows currently"
5124                        .to_string(),
5125                )
5126            }
5127        }
5128        "clear_temp" => {
5129            #[cfg(target_os = "windows")]
5130            {
5131                let cmd = "Remove-Item -Path \"$env:TEMP\\*\" -Recurse -Force -ErrorAction SilentlyContinue";
5132                match Command::new("powershell")
5133                    .args(["-NoProfile", "-Command", cmd])
5134                    .output()
5135                {
5136                    Ok(_) => Ok("Successfully cleared temporary files".to_string()),
5137                    Err(e) => Err(format!("Failed to clear temp: {}", e)),
5138                }
5139            }
5140            #[cfg(not(target_os = "windows"))]
5141            {
5142                Err("clear_temp via wrapper is only supported on Windows currently".to_string())
5143            }
5144        }
5145        other => Err(format!("Unknown remediation action: {}", other)),
5146    }
5147}
5148
5149// ── storage ───────────────────────────────────────────────────────────────────
5150
5151fn inspect_storage(max_entries: usize) -> Result<String, String> {
5152    let mut out = String::from("Host inspection: storage\n\n");
5153    let _ = max_entries; // used by non-Windows branch
5154
5155    // ── Drive overview ────────────────────────────────────────────────────────
5156    out.push_str("Drives:\n");
5157
5158    #[cfg(target_os = "windows")]
5159    {
5160        let script = r#"Get-PSDrive -PSProvider 'FileSystem' | ForEach-Object {
5161    $free = $_.Free
5162    $used = $_.Used
5163    if ($free -eq $null) { $free = 0 }
5164    if ($used -eq $null) { $used = 0 }
5165    $total = $free + $used
5166    "$($_.Name)|$free|$used|$total"
5167}"#;
5168        match Command::new("powershell")
5169            .args(["-NoProfile", "-Command", script])
5170            .output()
5171        {
5172            Ok(o) => {
5173                let text = String::from_utf8_lossy(&o.stdout);
5174                let mut drive_count = 0usize;
5175                for line in text.lines() {
5176                    let mut it = line.trim().splitn(5, '|');
5177                    if let (Some(name), Some(p1), _, Some(p3)) =
5178                        (it.next(), it.next(), it.next(), it.next())
5179                    {
5180                        let free: u64 = p1.parse().unwrap_or(0);
5181                        let total: u64 = p3.parse().unwrap_or(0);
5182                        if total == 0 {
5183                            continue;
5184                        }
5185                        let free_gb = free / 1_073_741_824;
5186                        let total_gb = total / 1_073_741_824;
5187                        let used_pct = ((total - free) as f64 / total as f64 * 100.0) as u64;
5188                        let bar_len = 20usize;
5189                        let filled = (used_pct as usize * bar_len / 100).min(bar_len);
5190                        let bar: String = "#".repeat(filled) + &".".repeat(bar_len - filled);
5191                        let warn = if free_gb < 5 {
5192                            " [!] CRITICALLY LOW"
5193                        } else if free_gb < 15 {
5194                            " [-] LOW"
5195                        } else {
5196                            ""
5197                        };
5198                        let _ = writeln!(out,
5199                            "  {name}:  [{bar}] {used_pct}% used — {free_gb} GB free of {total_gb} GB{warn}"
5200                        );
5201                        drive_count += 1;
5202                    }
5203                }
5204                if drive_count == 0 {
5205                    out.push_str("  (could not enumerate drives)\n");
5206                }
5207            }
5208            Err(e) => {
5209                let _ = writeln!(out, "  (drive scan failed: {e})");
5210            }
5211        }
5212
5213        // ── Real-time Performance (Latency) ──────────────────────────────────
5214        let latency_script = "Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter \"Name='_Total'\" | Select-Object -ExpandProperty AvgDiskQueueLength";
5215        match Command::new("powershell")
5216            .args(["-NoProfile", "-Command", latency_script])
5217            .output()
5218        {
5219            Ok(o) => {
5220                out.push_str("\nReal-time Disk Intensity:\n");
5221                let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5222                if !text.is_empty() {
5223                    let _ = writeln!(out, "  Average Disk Queue Length: {text}");
5224                    if let Ok(q) = text.parse::<f64>() {
5225                        if q > 2.0 {
5226                            out.push_str(
5227                                "  [!] WARNING: High disk latency detected (Queue Length > 2.0)\n",
5228                            );
5229                        } else {
5230                            out.push_str("  [~] Disk latency is within healthy bounds.\n");
5231                        }
5232                    }
5233                } else {
5234                    out.push_str("  Average Disk Queue Length: unavailable\n");
5235                }
5236            }
5237            Err(_) => {
5238                out.push_str("\nReal-time Disk Intensity:\n");
5239                out.push_str("  Average Disk Queue Length: unavailable\n");
5240            }
5241        }
5242    }
5243
5244    #[cfg(not(target_os = "windows"))]
5245    {
5246        match Command::new("df")
5247            .args(["-h", "--output=target,size,avail,pcent"])
5248            .output()
5249        {
5250            Ok(o) => {
5251                let text = String::from_utf8_lossy(&o.stdout);
5252                let mut count = 0usize;
5253                for line in text.lines().skip(1) {
5254                    let mut it = line.split_whitespace();
5255                    if let (Some(fs), Some(size), Some(avail), Some(used)) =
5256                        (it.next(), it.next(), it.next(), it.next())
5257                    {
5258                        if !fs.starts_with("tmpfs") {
5259                            let _ = write!(
5260                                out,
5261                                "  {}  size: {}  avail: {}  used: {}\n",
5262                                fs, size, avail, used
5263                            );
5264                            count += 1;
5265                            if count >= max_entries {
5266                                break;
5267                            }
5268                        }
5269                    }
5270                }
5271            }
5272            Err(e) => {
5273                let _ = write!(out, "  (df failed: {e})\n");
5274            }
5275        }
5276    }
5277
5278    // ── Large developer cache directories ─────────────────────────────────────
5279    out.push_str("\nLarge developer cache directories (if present):\n");
5280
5281    #[cfg(target_os = "windows")]
5282    {
5283        let home = std::env::var("USERPROFILE").unwrap_or_default();
5284        let check_dirs: &[(&str, &str)] = &[
5285            ("Temp", r"AppData\Local\Temp"),
5286            ("npm cache", r"AppData\Roaming\npm-cache"),
5287            ("Cargo registry", r".cargo\registry"),
5288            ("Cargo git", r".cargo\git"),
5289            ("pip cache", r"AppData\Local\pip\cache"),
5290            ("Yarn cache", r"AppData\Local\Yarn\Cache"),
5291            (".rustup toolchains", r".rustup\toolchains"),
5292            ("node_modules (home)", r"node_modules"),
5293        ];
5294
5295        let mut found_any = false;
5296        for (label, rel) in check_dirs {
5297            let full = format!(r"{}\{}", home, rel);
5298            let path = std::path::Path::new(&full);
5299            if path.exists() {
5300                // Quick size estimate via PowerShell (non-blocking cap at 5s)
5301                let size_script = format!(
5302                    r#"try {{ $s = (Get-ChildItem -Path '{}' -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum; [math]::Round($s/1MB,0) }} catch {{ '?' }}"#,
5303                    full.replace('\'', "''")
5304                );
5305                let size_mb = Command::new("powershell")
5306                    .args(["-NoProfile", "-Command", &size_script])
5307                    .output()
5308                    .ok()
5309                    .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
5310                    .unwrap_or_else(|| "?".to_string());
5311                let _ = writeln!(out, "  {label}: {size_mb} MB  ({full})");
5312                found_any = true;
5313            }
5314        }
5315        if !found_any {
5316            out.push_str("  (none of the common cache directories found)\n");
5317        }
5318
5319        out.push_str("\nTip: to reclaim space, run inspect_host(topic=\"fix_plan\", issue=\"free up disk space\")\n");
5320    }
5321
5322    #[cfg(not(target_os = "windows"))]
5323    {
5324        let home = std::env::var("HOME").unwrap_or_default();
5325        let check_dirs: &[(&str, &str)] = &[
5326            ("npm cache", ".npm"),
5327            ("Cargo registry", ".cargo/registry"),
5328            ("pip cache", ".cache/pip"),
5329            (".rustup toolchains", ".rustup/toolchains"),
5330            ("Yarn cache", ".cache/yarn"),
5331        ];
5332        let mut found_any = false;
5333        for (label, rel) in check_dirs {
5334            let full = format!("{}/{}", home, rel);
5335            if std::path::Path::new(&full).exists() {
5336                let size = Command::new("du")
5337                    .args(["-sh", &full])
5338                    .output()
5339                    .ok()
5340                    .map(|o| {
5341                        let s = String::from_utf8_lossy(&o.stdout);
5342                        s.split_whitespace().next().unwrap_or("?").to_string()
5343                    })
5344                    .unwrap_or_else(|| "?".to_string());
5345                let _ = write!(out, "  {label}: {size}  ({full})\n");
5346                found_any = true;
5347            }
5348        }
5349        if !found_any {
5350            out.push_str("  (none of the common cache directories found)\n");
5351        }
5352    }
5353
5354    Ok(out.trim_end().to_string())
5355}
5356
5357// ── hardware ──────────────────────────────────────────────────────────────────
5358
5359fn inspect_hardware() -> Result<String, String> {
5360    let mut out = String::from("Host inspection: hardware\n\n");
5361
5362    #[cfg(target_os = "windows")]
5363    {
5364        // CPU
5365        let cpu_script = r#"Get-CimInstance Win32_Processor | ForEach-Object {
5366    "$($_.Name.Trim())|$($_.NumberOfCores)|$($_.NumberOfLogicalProcessors)|$([math]::Round($_.MaxClockSpeed/1000,1))"
5367} | Select-Object -First 1"#;
5368        if let Ok(o) = Command::new("powershell")
5369            .args(["-NoProfile", "-Command", cpu_script])
5370            .output()
5371        {
5372            let text = String::from_utf8_lossy(&o.stdout);
5373            let text = text.trim();
5374            let mut it = text.splitn(5, '|');
5375            if let (Some(p0), Some(p1), Some(p2), Some(p3)) =
5376                (it.next(), it.next(), it.next(), it.next())
5377            {
5378                let _ = write!(
5379                    out,
5380                    "CPU: {}\n  {} physical cores, {} logical processors, {:.1} GHz\n\n",
5381                    p0,
5382                    p1,
5383                    p2,
5384                    p3.parse::<f32>().unwrap_or(0.0)
5385                );
5386            } else {
5387                let _ = write!(out, "CPU: {text}\n\n");
5388            }
5389        }
5390
5391        // RAM (total installed + speed)
5392        let ram_script = r#"$sticks = Get-CimInstance Win32_PhysicalMemory
5393$total = ($sticks | Measure-Object Capacity -Sum).Sum / 1GB
5394$speed = ($sticks | Select-Object -First 1).Speed
5395"$([math]::Round($total,0)) GB @ $($speed) MHz ($($sticks.Count) stick(s))""#;
5396        if let Ok(o) = Command::new("powershell")
5397            .args(["-NoProfile", "-Command", ram_script])
5398            .output()
5399        {
5400            let text = String::from_utf8_lossy(&o.stdout);
5401            let _ = write!(out, "RAM: {}\n\n", text.trim().trim_matches('"'));
5402        }
5403
5404        // GPU(s)
5405        let gpu_script = r#"Get-CimInstance Win32_VideoController | ForEach-Object {
5406    "$($_.Name)|$($_.DriverVersion)|$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
5407}"#;
5408        if let Ok(o) = Command::new("powershell")
5409            .args(["-NoProfile", "-Command", gpu_script])
5410            .output()
5411        {
5412            let text = String::from_utf8_lossy(&o.stdout);
5413            let lines: Vec<&str> = text.lines().collect();
5414            if !lines.is_empty() {
5415                out.push_str("GPU(s):\n");
5416                for line in lines.iter().filter(|l| !l.trim().is_empty()) {
5417                    let mut it = line.trim().splitn(4, '|');
5418                    if let (Some(p0), Some(p1), Some(p2)) = (it.next(), it.next(), it.next()) {
5419                        let res = if p2 == "x" || p2.starts_with('0') {
5420                            String::new()
5421                        } else {
5422                            format!(" — {}@display", p2)
5423                        };
5424                        let _ = write!(out, "  {}\n    Driver: {}{}\n", p0, p1, res);
5425                    } else {
5426                        let _ = writeln!(out, "  {}", line.trim());
5427                    }
5428                }
5429                out.push('\n');
5430            }
5431        }
5432
5433        // Motherboard + BIOS + Virtualization
5434        let mb_script = r#"$mb = Get-CimInstance Win32_BaseBoard
5435$bios = Get-CimInstance Win32_BIOS
5436$cs = Get-CimInstance Win32_ComputerSystem
5437$proc = Get-CimInstance Win32_Processor | Select-Object -First 1
5438$virt = "Hypervisor: $($cs.HypervisorPresent)|SLAT: $($proc.SecondLevelAddressTranslationExtensions)"
5439"$($mb.Manufacturer.Trim()) $($mb.Product.Trim())|BIOS: $($bios.Manufacturer.Trim()) $($bios.SMBIOSBIOSVersion.Trim()) ($($bios.ReleaseDate))|$virt""#;
5440        if let Ok(o) = Command::new("powershell")
5441            .args(["-NoProfile", "-Command", mb_script])
5442            .output()
5443        {
5444            let text = String::from_utf8_lossy(&o.stdout);
5445            let text = text.trim().trim_matches('"');
5446            let mut it = text.splitn(5, '|');
5447            if let (Some(p0), Some(p1), Some(p2), Some(p3)) =
5448                (it.next(), it.next(), it.next(), it.next())
5449            {
5450                let _ = write!(
5451                    out,
5452                    "Motherboard: {}\n{}\nVirtualization: {}, {}\n\n",
5453                    p0.trim(),
5454                    p1.trim(),
5455                    p2.trim(),
5456                    p3.trim()
5457                );
5458            }
5459        }
5460
5461        // Display(s)
5462        let disp_script = r#"Get-CimInstance Win32_DesktopMonitor | Where-Object {$_.ScreenWidth -gt 0} | ForEach-Object {
5463    "$($_.Name)|$($_.ScreenWidth)x$($_.ScreenHeight)"
5464}"#;
5465        if let Ok(o) = Command::new("powershell")
5466            .args(["-NoProfile", "-Command", disp_script])
5467            .output()
5468        {
5469            let text = String::from_utf8_lossy(&o.stdout);
5470            let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
5471            if !lines.is_empty() {
5472                out.push_str("Display(s):\n");
5473                for line in &lines {
5474                    let mut it = line.trim().splitn(3, '|');
5475                    if let (Some(p0), Some(p1)) = (it.next(), it.next()) {
5476                        let _ = writeln!(out, "  {} — {}", p0.trim(), p1);
5477                    }
5478                }
5479            }
5480        }
5481    }
5482
5483    #[cfg(not(target_os = "windows"))]
5484    {
5485        // CPU via /proc/cpuinfo
5486        if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
5487            let model = content
5488                .lines()
5489                .find(|l| l.starts_with("model name"))
5490                .and_then(|l| l.split(':').nth(1))
5491                .map(str::trim)
5492                .unwrap_or("unknown");
5493            let cores = content
5494                .lines()
5495                .filter(|l| l.starts_with("processor"))
5496                .count();
5497            let _ = write!(out, "CPU: {model}\n  {cores} logical processors\n\n");
5498        }
5499
5500        // RAM
5501        if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
5502            let total_kb: u64 = content
5503                .lines()
5504                .find(|l| l.starts_with("MemTotal:"))
5505                .and_then(|l| l.split_whitespace().nth(1))
5506                .and_then(|v| v.parse().ok())
5507                .unwrap_or(0);
5508            let total_gb = total_kb / 1_048_576;
5509            let _ = write!(out, "RAM: {total_gb} GB total\n\n");
5510        }
5511
5512        // GPU via lspci
5513        if let Ok(o) = Command::new("lspci").args(["-vmm"]).output() {
5514            let text = String::from_utf8_lossy(&o.stdout);
5515            let gpu_lines: Vec<&str> = text
5516                .lines()
5517                .filter(|l| l.contains("VGA") || l.contains("Display") || l.contains("3D"))
5518                .collect();
5519            if !gpu_lines.is_empty() {
5520                out.push_str("GPU(s):\n");
5521                for l in gpu_lines {
5522                    let _ = write!(out, "  {l}\n");
5523                }
5524                out.push('\n');
5525            }
5526        }
5527
5528        // DMI/BIOS info
5529        if let Ok(o) = Command::new("dmidecode")
5530            .args(["-t", "baseboard", "-t", "bios"])
5531            .output()
5532        {
5533            let text = String::from_utf8_lossy(&o.stdout);
5534            out.push_str("Motherboard/BIOS:\n");
5535            for line in text
5536                .lines()
5537                .filter(|l| {
5538                    l.contains("Manufacturer:")
5539                        || l.contains("Product Name:")
5540                        || l.contains("Version:")
5541                })
5542                .take(6)
5543            {
5544                let _ = write!(out, "  {}\n", line.trim());
5545            }
5546        }
5547    }
5548
5549    Ok(out.trim_end().to_string())
5550}
5551
5552// ── updates ───────────────────────────────────────────────────────────────────
5553
5554fn inspect_updates() -> Result<String, String> {
5555    let mut out = String::from("Host inspection: updates\n\n");
5556
5557    #[cfg(target_os = "windows")]
5558    {
5559        // Last installed update via COM
5560        let script = r#"
5561try {
5562    $sess = New-Object -ComObject Microsoft.Update.Session
5563    $searcher = $sess.CreateUpdateSearcher()
5564    $count = $searcher.GetTotalHistoryCount()
5565    if ($count -gt 0) {
5566        $latest = $searcher.QueryHistory(0, 1) | Select-Object -First 1
5567        $latest.Date.ToString("yyyy-MM-dd HH:mm") + "|LAST_INSTALL"
5568    } else { "NONE|LAST_INSTALL" }
5569} catch { "ERROR:" + $_.Exception.Message + "|LAST_INSTALL" }
5570"#;
5571        if let Ok(o) = Command::new("powershell")
5572            .args(["-NoProfile", "-Command", script])
5573            .output()
5574        {
5575            let raw = String::from_utf8_lossy(&o.stdout);
5576            let text = raw.trim();
5577            if text.starts_with("ERROR:") {
5578                out.push_str("Last update install: (unable to query)\n");
5579            } else if text.contains("NONE") {
5580                out.push_str("Last update install: No update history found\n");
5581            } else {
5582                let date = text.replace("|LAST_INSTALL", "");
5583                let _ = writeln!(out, "Last update install: {date}");
5584            }
5585        }
5586
5587        // Pending updates count
5588        let pending_script = r#"
5589try {
5590    $sess = New-Object -ComObject Microsoft.Update.Session
5591    $searcher = $sess.CreateUpdateSearcher()
5592    $results = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
5593    $results.Updates.Count.ToString() + "|PENDING"
5594} catch { "ERROR:" + $_.Exception.Message + "|PENDING" }
5595"#;
5596        if let Ok(o) = Command::new("powershell")
5597            .args(["-NoProfile", "-Command", pending_script])
5598            .output()
5599        {
5600            let raw = String::from_utf8_lossy(&o.stdout);
5601            let text = raw.trim();
5602            if text.starts_with("ERROR:") {
5603                out.push_str("Pending updates: (unable to query via COM — try opening Windows Update manually)\n");
5604            } else {
5605                let count: i64 = text.replace("|PENDING", "").trim().parse().unwrap_or(-1);
5606                if count == 0 {
5607                    out.push_str("Pending updates: Up to date — no updates waiting\n");
5608                } else if count > 0 {
5609                    let _ = writeln!(out, "Pending updates: {count} update(s) available");
5610                    out.push_str(
5611                        "  → Open Windows Update (Settings > Windows Update) to install\n",
5612                    );
5613                }
5614            }
5615        }
5616
5617        // Windows Update service state
5618        let svc_script = r#"
5619$svc = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
5620if ($svc) { $svc.Status.ToString() } else { "NOT_FOUND" }
5621"#;
5622        if let Ok(o) = Command::new("powershell")
5623            .args(["-NoProfile", "-Command", svc_script])
5624            .output()
5625        {
5626            let raw = String::from_utf8_lossy(&o.stdout);
5627            let status = raw.trim();
5628            let _ = writeln!(out, "Windows Update service: {status}");
5629        }
5630    }
5631
5632    #[cfg(not(target_os = "windows"))]
5633    {
5634        let apt_out = Command::new("apt").args(["list", "--upgradable"]).output();
5635        let mut found = false;
5636        if let Ok(o) = apt_out {
5637            let text = String::from_utf8_lossy(&o.stdout);
5638            let lines: Vec<&str> = text
5639                .lines()
5640                .filter(|l| l.contains('/') && !l.contains("Listing"))
5641                .collect();
5642            if !lines.is_empty() {
5643                let _ = write!(out, "{} package(s) can be upgraded (apt)\n", lines.len());
5644                out.push_str("  → Run: sudo apt upgrade\n");
5645                found = true;
5646            }
5647        }
5648        if !found {
5649            if let Ok(o) = Command::new("dnf")
5650                .args(["check-update", "--quiet"])
5651                .output()
5652            {
5653                let text = String::from_utf8_lossy(&o.stdout);
5654                let count = text
5655                    .lines()
5656                    .filter(|l| !l.is_empty() && !l.starts_with('!'))
5657                    .count();
5658                if count > 0 {
5659                    let _ = write!(out, "{count} package(s) can be upgraded (dnf)\n");
5660                    out.push_str("  → Run: sudo dnf upgrade\n");
5661                } else {
5662                    out.push_str("System is up to date.\n");
5663                }
5664            } else {
5665                out.push_str("Could not query package manager for updates.\n");
5666            }
5667        }
5668    }
5669
5670    Ok(out.trim_end().to_string())
5671}
5672
5673// ── security ──────────────────────────────────────────────────────────────────
5674
5675fn inspect_security() -> Result<String, String> {
5676    let mut out = String::from("Host inspection: security\n\n");
5677
5678    #[cfg(target_os = "windows")]
5679    {
5680        // Windows Defender status
5681        let defender_script = r#"
5682try {
5683    $status = Get-MpComputerStatus -ErrorAction Stop
5684    "RTP:" + $status.RealTimeProtectionEnabled + "|SCAN:" + $status.QuickScanEndTime.ToString("yyyy-MM-dd HH:mm") + "|VER:" + $status.AntivirusSignatureVersion + "|AGE:" + $status.AntivirusSignatureAge
5685} catch { "ERROR:" + $_.Exception.Message }
5686"#;
5687        if let Ok(o) = Command::new("powershell")
5688            .args(["-NoProfile", "-Command", defender_script])
5689            .output()
5690        {
5691            let raw = String::from_utf8_lossy(&o.stdout);
5692            let text = raw.trim();
5693            if text.starts_with("ERROR:") {
5694                let _ = writeln!(out, "Windows Defender: unable to query — {text}");
5695            } else {
5696                let get = |key: &str| -> String {
5697                    text.split('|')
5698                        .find(|s| s.starts_with(key))
5699                        .and_then(|s| s.split_once(':').map(|x| x.1))
5700                        .unwrap_or("unknown")
5701                        .to_string()
5702                };
5703                let rtp = get("RTP");
5704                let last_scan = {
5705                    // SCAN field has a colon in the time, so grab everything after "SCAN:"
5706                    text.split('|')
5707                        .find(|s| s.starts_with("SCAN:"))
5708                        .and_then(|s| s.get(5..))
5709                        .unwrap_or("unknown")
5710                        .to_string()
5711                };
5712                let def_ver = get("VER");
5713                let age_days: i64 = get("AGE").parse().unwrap_or(-1);
5714
5715                let rtp_label = if rtp == "True" {
5716                    "ENABLED"
5717                } else {
5718                    "DISABLED [!]"
5719                };
5720                let _ = writeln!(out, "Windows Defender real-time protection: {rtp_label}");
5721                let _ = writeln!(out, "Last quick scan: {last_scan}");
5722                let _ = writeln!(out, "Signature version: {def_ver}");
5723                if age_days >= 0 {
5724                    let freshness = if age_days == 0 {
5725                        "up to date".to_string()
5726                    } else if age_days <= 3 {
5727                        format!("{age_days} day(s) old — OK")
5728                    } else if age_days <= 7 {
5729                        format!("{age_days} day(s) old — consider updating")
5730                    } else {
5731                        format!("{age_days} day(s) old — [!] STALE, run Windows Update")
5732                    };
5733                    let _ = writeln!(out, "Signature age: {freshness}");
5734                }
5735                if rtp != "True" {
5736                    out.push_str(
5737                        "\n[!] Real-time protection is OFF — your PC is not actively protected.\n",
5738                    );
5739                    out.push_str(
5740                        "    → Open Windows Security > Virus & threat protection to re-enable.\n",
5741                    );
5742                }
5743            }
5744        }
5745
5746        out.push('\n');
5747
5748        // Windows Firewall state
5749        let fw_script = r#"
5750try {
5751    Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object { $_.Name + ":" + $_.Enabled }
5752} catch { "ERROR:" + $_.Exception.Message }
5753"#;
5754        if let Ok(o) = Command::new("powershell")
5755            .args(["-NoProfile", "-Command", fw_script])
5756            .output()
5757        {
5758            let raw = String::from_utf8_lossy(&o.stdout);
5759            let text = raw.trim();
5760            if !text.starts_with("ERROR:") && !text.is_empty() {
5761                out.push_str("Windows Firewall:\n");
5762                for line in text.lines() {
5763                    if let Some((name, enabled)) = line.split_once(':') {
5764                        let state = if enabled.trim() == "True" {
5765                            "ON"
5766                        } else {
5767                            "OFF [!]"
5768                        };
5769                        let _ = writeln!(out, "  {name}: {state}");
5770                    }
5771                }
5772                out.push('\n');
5773            }
5774        }
5775
5776        // Windows activation status
5777        let act_script = r#"
5778try {
5779    $lic = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%' and LicenseStatus=1" -ErrorAction Stop | Select-Object -First 1
5780    if ($lic) { "ACTIVATED" } else { "NOT_ACTIVATED" }
5781} catch { "UNKNOWN" }
5782"#;
5783        if let Ok(o) = Command::new("powershell")
5784            .args(["-NoProfile", "-Command", act_script])
5785            .output()
5786        {
5787            let raw = String::from_utf8_lossy(&o.stdout);
5788            match raw.trim() {
5789                "ACTIVATED" => out.push_str("Windows activation: Activated\n"),
5790                "NOT_ACTIVATED" => out.push_str("Windows activation: [!] NOT ACTIVATED\n"),
5791                _ => out.push_str("Windows activation: Unable to determine\n"),
5792            }
5793        }
5794
5795        // UAC state
5796        let uac_script = r#"
5797$val = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue
5798if ($val -eq 1) { "ON" } else { "OFF" }
5799"#;
5800        if let Ok(o) = Command::new("powershell")
5801            .args(["-NoProfile", "-Command", uac_script])
5802            .output()
5803        {
5804            let raw = String::from_utf8_lossy(&o.stdout);
5805            let state = raw.trim();
5806            let label = if state == "ON" {
5807                "Enabled"
5808            } else {
5809                "DISABLED [!] — recommended to re-enable via secpol.msc"
5810            };
5811            let _ = writeln!(out, "UAC (User Account Control): {label}");
5812        }
5813    }
5814
5815    #[cfg(not(target_os = "windows"))]
5816    {
5817        if let Ok(o) = Command::new("ufw").arg("status").output() {
5818            let text = String::from_utf8_lossy(&o.stdout);
5819            let _ = write!(out, "UFW: {}\n", text.lines().next().unwrap_or("unknown"));
5820        }
5821        if let Ok(cfg) = std::fs::read_to_string("/etc/selinux/config") {
5822            if let Some(line) = cfg.lines().find(|l| l.starts_with("SELINUX=")) {
5823                let _ = write!(out, "{line}\n");
5824            }
5825        }
5826    }
5827
5828    Ok(out.trim_end().to_string())
5829}
5830
5831// ── pending_reboot ────────────────────────────────────────────────────────────
5832
5833fn inspect_pending_reboot() -> Result<String, String> {
5834    let mut out = String::from("Host inspection: pending_reboot\n\n");
5835
5836    #[cfg(target_os = "windows")]
5837    {
5838        let script = r#"
5839$reasons = @()
5840if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
5841    $reasons += "Windows Update requires a restart"
5842}
5843if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
5844    $reasons += "Windows component install/update requires a restart"
5845}
5846$pfro = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
5847if ($pfro -and $pfro.PendingFileRenameOperations) {
5848    $reasons += "Pending file rename operations (driver or system file replacement)"
5849}
5850if ($reasons.Count -eq 0) { "NO_REBOOT_NEEDED" } else { $reasons -join "|REASON|" }
5851"#;
5852        let output = Command::new("powershell")
5853            .args(["-NoProfile", "-Command", script])
5854            .output()
5855            .map_err(|e| format!("pending_reboot: {e}"))?;
5856
5857        let raw = String::from_utf8_lossy(&output.stdout);
5858        let text = raw.trim();
5859
5860        if text == "NO_REBOOT_NEEDED" {
5861            out.push_str("No restart required — system is up to date and stable.\n");
5862        } else if text.is_empty() {
5863            out.push_str("Could not determine reboot status.\n");
5864        } else {
5865            out.push_str("[!] A system restart is pending:\n\n");
5866            for reason in text.split("|REASON|") {
5867                let _ = writeln!(out, "  • {}", reason.trim());
5868            }
5869            out.push_str("\nRecommendation: Save your work and restart when convenient.\n");
5870        }
5871    }
5872
5873    #[cfg(not(target_os = "windows"))]
5874    {
5875        if std::path::Path::new("/var/run/reboot-required").exists() {
5876            out.push_str("[!] A restart is required (see /var/run/reboot-required)\n");
5877            if let Ok(pkgs) = std::fs::read_to_string("/var/run/reboot-required.pkgs") {
5878                out.push_str("Packages requiring restart:\n");
5879                for p in pkgs.lines().take(10) {
5880                    let _ = write!(out, "  • {p}\n");
5881                }
5882            }
5883        } else {
5884            out.push_str("No restart required.\n");
5885        }
5886    }
5887
5888    Ok(out.trim_end().to_string())
5889}
5890
5891// ── disk_health ───────────────────────────────────────────────────────────────
5892
5893fn inspect_disk_health() -> Result<String, String> {
5894    let mut out = String::from("Host inspection: disk_health\n\n");
5895
5896    #[cfg(target_os = "windows")]
5897    {
5898        let script = r#"
5899try {
5900    $disks = Get-PhysicalDisk -ErrorAction Stop
5901    foreach ($d in $disks) {
5902        $size_gb = [math]::Round($d.Size / 1GB, 0)
5903        $d.FriendlyName + "|" + $d.MediaType + "|" + $size_gb + "GB|" + $d.HealthStatus + "|" + $d.OperationalStatus
5904    }
5905} catch { "ERROR:" + $_.Exception.Message }
5906"#;
5907        let output = Command::new("powershell")
5908            .args(["-NoProfile", "-Command", script])
5909            .output()
5910            .map_err(|e| format!("disk_health: {e}"))?;
5911
5912        let raw = String::from_utf8_lossy(&output.stdout);
5913        let text = raw.trim();
5914
5915        if text.starts_with("ERROR:") {
5916            let _ = writeln!(out, "Unable to query disk health: {text}");
5917            out.push_str("This may require running as administrator.\n");
5918        } else if text.is_empty() {
5919            out.push_str("No physical disks found.\n");
5920        } else {
5921            out.push_str("Physical Drive Health:\n\n");
5922            for line in text.lines() {
5923                let mut it = line.splitn(5, '|');
5924                if let (Some(name), Some(media), Some(size), Some(health)) =
5925                    (it.next(), it.next(), it.next(), it.next())
5926                {
5927                    let op_status = it.next().unwrap_or("");
5928                    let health_label = match health.trim() {
5929                        "Healthy" => "OK",
5930                        "Warning" => "[!] WARNING",
5931                        "Unhealthy" => "[!!] UNHEALTHY — BACK UP YOUR DATA NOW",
5932                        other => other,
5933                    };
5934                    let _ = writeln!(out, "  {name}");
5935                    let _ = writeln!(out, "    Type: {media} | Size: {size}");
5936                    let _ = writeln!(out, "    Health: {health_label}");
5937                    if !op_status.is_empty() {
5938                        let _ = writeln!(out, "    Status: {op_status}");
5939                    }
5940                    out.push('\n');
5941                }
5942            }
5943        }
5944
5945        // SMART failure prediction (best-effort, may need admin)
5946        let smart_script = r#"
5947try {
5948    Get-WmiObject -Class MSStorageDriver_FailurePredictStatus -Namespace root\wmi -ErrorAction Stop |
5949        ForEach-Object { $_.InstanceName + "|" + $_.PredictFailure }
5950} catch { "" }
5951"#;
5952        if let Ok(o) = Command::new("powershell")
5953            .args(["-NoProfile", "-Command", smart_script])
5954            .output()
5955        {
5956            let raw2 = String::from_utf8_lossy(&o.stdout);
5957            let text2 = raw2.trim();
5958            if !text2.is_empty() {
5959                let failures: Vec<&str> = text2.lines().filter(|l| l.contains("|True")).collect();
5960                if failures.is_empty() {
5961                    out.push_str("SMART failure prediction: No failures predicted\n");
5962                } else {
5963                    out.push_str("[!!] SMART failure predicted on one or more drives:\n");
5964                    for f in failures {
5965                        let name = f.split('|').next().unwrap_or(f);
5966                        let _ = writeln!(out, "  • {name}");
5967                    }
5968                    out.push_str(
5969                        "\nBack up your data immediately and replace the failing drive.\n",
5970                    );
5971                }
5972            }
5973        }
5974    }
5975
5976    #[cfg(not(target_os = "windows"))]
5977    {
5978        if let Ok(o) = Command::new("lsblk")
5979            .args(["-d", "-o", "NAME,SIZE,TYPE,ROTA,MODEL"])
5980            .output()
5981        {
5982            let text = String::from_utf8_lossy(&o.stdout);
5983            out.push_str("Block devices:\n");
5984            out.push_str(text.trim());
5985            out.push('\n');
5986        }
5987        if let Ok(scan) = Command::new("smartctl").args(["--scan"]).output() {
5988            let devices = String::from_utf8_lossy(&scan.stdout);
5989            for dev_line in devices.lines().take(4) {
5990                let dev = dev_line.split_whitespace().next().unwrap_or("");
5991                if dev.is_empty() {
5992                    continue;
5993                }
5994                if let Ok(o) = Command::new("smartctl").args(["-H", dev]).output() {
5995                    let health = String::from_utf8_lossy(&o.stdout);
5996                    if let Some(line) = health.lines().find(|l| l.contains("SMART overall-health"))
5997                    {
5998                        let _ = write!(out, "{dev}: {}\n", line.trim());
5999                    }
6000                }
6001            }
6002        } else {
6003            out.push_str("(install smartmontools for SMART health data)\n");
6004        }
6005    }
6006
6007    Ok(out.trim_end().to_string())
6008}
6009
6010// ── battery ───────────────────────────────────────────────────────────────────
6011
6012fn inspect_battery() -> Result<String, String> {
6013    let mut out = String::from("Host inspection: battery\n\n");
6014
6015    #[cfg(target_os = "windows")]
6016    {
6017        let script = r#"
6018try {
6019    $bats = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
6020    if (-not $bats) { "NO_BATTERY"; exit }
6021    
6022    # Modern Battery Health (Cycle count + Capacity health)
6023    $static = Get-CimInstance -Namespace root/WMI -ClassName BatteryStaticData -ErrorAction SilentlyContinue
6024    $full = Get-CimInstance -Namespace root/WMI -ClassName BatteryFullCapacity -ErrorAction SilentlyContinue 
6025    $status = Get-CimInstance -Namespace root/WMI -ClassName BatteryStatus -ErrorAction SilentlyContinue
6026
6027    foreach ($b in $bats) {
6028        $state = switch ($b.BatteryStatus) {
6029            1 { "Discharging" }
6030            2 { "AC Power (Fully Charged)" }
6031            3 { "AC Power (Charging)" }
6032            default { "Status $($b.BatteryStatus)" }
6033        }
6034        
6035        $cycles = if ($status) { $status.CycleCount } else { "unknown" }
6036        $health = if ($static -and $full) {
6037             [math]::Round(($full.FullChargeCapacity / $static.DesignCapacity) * 100, 1)
6038        } else { "unknown" }
6039
6040        $b.Name + "|" + $b.EstimatedChargeRemaining + "|" + $state + "|" + $cycles + "|" + $health
6041    }
6042} catch { "ERROR:" + $_.Exception.Message }
6043"#;
6044        let output = Command::new("powershell")
6045            .args(["-NoProfile", "-Command", script])
6046            .output()
6047            .map_err(|e| format!("battery: {e}"))?;
6048
6049        let raw = String::from_utf8_lossy(&output.stdout);
6050        let text = raw.trim();
6051
6052        if text == "NO_BATTERY" {
6053            out.push_str("No battery detected — desktop or AC-only system.\n");
6054            return Ok(out.trim_end().to_string());
6055        }
6056        if text.starts_with("ERROR:") {
6057            let _ = writeln!(out, "Unable to query battery: {text}");
6058            return Ok(out.trim_end().to_string());
6059        }
6060
6061        for line in text.lines() {
6062            let mut it = line.splitn(6, '|');
6063            if let (Some(name), Some(p1), Some(state), Some(cycles), Some(health)) =
6064                (it.next(), it.next(), it.next(), it.next(), it.next())
6065            {
6066                let charge: i64 = p1.parse().unwrap_or(-1);
6067
6068                let _ = writeln!(out, "Battery: {name}");
6069                if charge >= 0 {
6070                    let bar_filled = (charge as usize * 20) / 100;
6071                    let _ = writeln!(
6072                        out,
6073                        "  Charge: [{}{}] {}%",
6074                        "#".repeat(bar_filled),
6075                        ".".repeat(20 - bar_filled),
6076                        charge
6077                    );
6078                }
6079                let _ = writeln!(out, "  Status: {state}");
6080                let _ = writeln!(out, "  Cycles: {cycles}");
6081                let _ = write!(out, "  Health: {health}% (Actual vs Design Capacity)\n\n");
6082            }
6083        }
6084    }
6085
6086    #[cfg(not(target_os = "windows"))]
6087    {
6088        let power_path = std::path::Path::new("/sys/class/power_supply");
6089        let mut found = false;
6090        if power_path.exists() {
6091            if let Ok(entries) = std::fs::read_dir(power_path) {
6092                for entry in entries.flatten() {
6093                    let p = entry.path();
6094                    if let Ok(t) = std::fs::read_to_string(p.join("type")) {
6095                        if t.trim() == "Battery" {
6096                            found = true;
6097                            let name = p
6098                                .file_name()
6099                                .unwrap_or_default()
6100                                .to_string_lossy()
6101                                .to_string();
6102                            let _ = write!(out, "Battery: {name}\n");
6103                            let read = |f: &str| {
6104                                std::fs::read_to_string(p.join(f))
6105                                    .ok()
6106                                    .map(|s| s.trim().to_string())
6107                            };
6108                            if let Some(cap) = read("capacity") {
6109                                let _ = write!(out, "  Charge: {cap}%\n");
6110                            }
6111                            if let Some(status) = read("status") {
6112                                let _ = write!(out, "  Status: {status}\n");
6113                            }
6114                            if let (Some(full), Some(design)) =
6115                                (read("energy_full"), read("energy_full_design"))
6116                            {
6117                                if let (Ok(f), Ok(d)) = (full.parse::<f64>(), design.parse::<f64>())
6118                                {
6119                                    if d > 0.0 {
6120                                        let _ = write!(
6121                                            out,
6122                                            "  Wear level: {:.1}% of design capacity\n",
6123                                            (f / d) * 100.0
6124                                        );
6125                                    }
6126                                }
6127                            }
6128                        }
6129                    }
6130                }
6131            }
6132        }
6133        if !found {
6134            out.push_str("No battery found.\n");
6135        }
6136    }
6137
6138    Ok(out.trim_end().to_string())
6139}
6140
6141// ── recent_crashes ────────────────────────────────────────────────────────────
6142
6143fn inspect_recent_crashes(max_entries: usize) -> Result<String, String> {
6144    let mut out = String::from("Host inspection: recent_crashes\n\n");
6145    let n = max_entries.clamp(1, 30);
6146
6147    #[cfg(target_os = "windows")]
6148    {
6149        // BSODs / unexpected shutdowns (EventID 41 = kernel power, 1001 = BugCheck)
6150        let bsod_script = format!(
6151            r#"
6152try {{
6153    $events = Get-WinEvent -FilterHashtable @{{LogName='System'; Id=41,1001}} -MaxEvents {n} -ErrorAction SilentlyContinue
6154    if ($events) {{
6155        $events | ForEach-Object {{
6156            $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + $_.Id + "|" + (($_.Message -split "[\r\n]")[0].Trim())
6157        }}
6158    }} else {{ "NO_BSOD" }}
6159}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6160        );
6161
6162        if let Ok(o) = Command::new("powershell")
6163            .args(["-NoProfile", "-Command", &bsod_script])
6164            .output()
6165        {
6166            let raw = String::from_utf8_lossy(&o.stdout);
6167            let text = raw.trim();
6168            if text == "NO_BSOD" {
6169                out.push_str("System crashes (BSOD/kernel): None in recent history\n");
6170            } else if text.starts_with("ERROR:") {
6171                out.push_str("System crashes: unable to query\n");
6172            } else {
6173                out.push_str("System crashes / unexpected shutdowns:\n");
6174                for line in text.lines() {
6175                    let mut it = line.splitn(3, '|');
6176                    if let (Some(time), Some(id), Some(msg)) = (it.next(), it.next(), it.next()) {
6177                        let label = if id == "41" {
6178                            "Unexpected shutdown"
6179                        } else {
6180                            "BSOD (BugCheck)"
6181                        };
6182                        let _ = writeln!(out, "  [{time}] {label}: {msg}");
6183                    }
6184                }
6185                out.push('\n');
6186            }
6187        }
6188
6189        // Application crashes (EventID 1000 = app crash, 1002 = app hang)
6190        let app_script = format!(
6191            r#"
6192try {{
6193    $crashes = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue
6194    if ($crashes) {{
6195        $crashes | ForEach-Object {{
6196            $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + (($_.Message -split "[\r\n]")[0].Trim())
6197        }}
6198    }} else {{ "NO_CRASHES" }}
6199}} catch {{ "ERROR_APP:" + $_.Exception.Message }}"#
6200        );
6201
6202        if let Ok(o) = Command::new("powershell")
6203            .args(["-NoProfile", "-Command", &app_script])
6204            .output()
6205        {
6206            let raw = String::from_utf8_lossy(&o.stdout);
6207            let text = raw.trim();
6208            if text == "NO_CRASHES" {
6209                out.push_str("Application crashes: None in recent history\n");
6210            } else if text.starts_with("ERROR_APP:") {
6211                out.push_str("Application crashes: unable to query\n");
6212            } else {
6213                out.push_str("Application crashes:\n");
6214                for line in text.lines().take(n) {
6215                    let mut it = line.splitn(2, '|');
6216                    if let (Some(a), Some(b)) = (it.next(), it.next()) {
6217                        let _ = writeln!(out, "  [{}] {}", a, b);
6218                    }
6219                }
6220            }
6221        }
6222    }
6223
6224    #[cfg(not(target_os = "windows"))]
6225    {
6226        let n_str = n.to_string();
6227        if let Ok(o) = Command::new("journalctl")
6228            .args(["-k", "--no-pager", "-n", &n_str, "-p", "0..2"])
6229            .output()
6230        {
6231            let text = String::from_utf8_lossy(&o.stdout);
6232            let trimmed = text.trim();
6233            if trimmed.is_empty() || trimmed.contains("No entries") {
6234                out.push_str("No kernel panics or critical crashes found.\n");
6235            } else {
6236                out.push_str("Kernel critical events:\n");
6237                out.push_str(trimmed);
6238                out.push('\n');
6239            }
6240        }
6241        if let Ok(o) = Command::new("coredumpctl")
6242            .args(["list", "--no-pager"])
6243            .output()
6244        {
6245            let text = String::from_utf8_lossy(&o.stdout);
6246            let count = text
6247                .lines()
6248                .filter(|l| !l.trim().is_empty() && !l.starts_with("TIME"))
6249                .count();
6250            if count > 0 {
6251                let _ = write!(
6252                    out,
6253                    "\nCore dumps on file: {count}\n  → Run: coredumpctl list\n"
6254                );
6255            }
6256        }
6257    }
6258
6259    Ok(out.trim_end().to_string())
6260}
6261
6262// ── scheduled_tasks ───────────────────────────────────────────────────────────
6263
6264fn inspect_scheduled_tasks(max_entries: usize) -> Result<String, String> {
6265    let mut out = String::from("Host inspection: scheduled_tasks\n\n");
6266    let n = max_entries.clamp(1, 30);
6267
6268    #[cfg(target_os = "windows")]
6269    {
6270        let script = format!(
6271            r#"
6272try {{
6273    $tasks = Get-ScheduledTask -ErrorAction Stop |
6274        Where-Object {{ $_.State -ne 'Disabled' }} |
6275        ForEach-Object {{
6276            $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
6277            $lastRun = if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {{
6278                $info.LastRunTime.ToString("yyyy-MM-dd HH:mm")
6279            }} else {{ "never" }}
6280            $res = if ($info) {{ "0x{{0:x}}" -f $info.LastTaskResult }} else {{ "---" }}
6281            $exec = ($_.Actions | Select-Object -First 1).Execute
6282            if (-not $exec) {{ $exec = "(no exec)" }}
6283            $_.TaskName + "|" + $_.TaskPath + "|" + $_.State + "|" + $lastRun + "|" + $res + "|" + $exec
6284        }}
6285    $tasks | Select-Object -First {n}
6286}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6287        );
6288
6289        let output = Command::new("powershell")
6290            .args(["-NoProfile", "-Command", &script])
6291            .output()
6292            .map_err(|e| format!("scheduled_tasks: {e}"))?;
6293
6294        let raw = String::from_utf8_lossy(&output.stdout);
6295        let text = raw.trim();
6296
6297        if text.starts_with("ERROR:") {
6298            let _ = writeln!(out, "Unable to query scheduled tasks: {text}");
6299        } else if text.is_empty() {
6300            out.push_str("No active scheduled tasks found.\n");
6301        } else {
6302            let _ = write!(out, "Active scheduled tasks (up to {n}):\n\n");
6303            for line in text.lines() {
6304                let mut it = line.splitn(6, '|');
6305                if let (Some(name), Some(path), Some(state), Some(last), Some(res)) =
6306                    (it.next(), it.next(), it.next(), it.next(), it.next())
6307                {
6308                    let exec = it.next().unwrap_or("").trim();
6309                    let display_path = path.trim_matches('\\');
6310                    let display_path = if display_path.is_empty() {
6311                        "Root"
6312                    } else {
6313                        display_path
6314                    };
6315                    let _ = writeln!(out, "  {name} [{display_path}]");
6316                    let _ = writeln!(out, "    State: {state} | Last run: {last} | Result: {res}");
6317                    if !exec.is_empty() && exec != "(no exec)" {
6318                        let short = if exec.len() > 80 {
6319                            safe_head(exec, 80)
6320                        } else {
6321                            exec
6322                        };
6323                        let _ = writeln!(out, "    Runs: {short}");
6324                    }
6325                }
6326            }
6327        }
6328    }
6329
6330    #[cfg(not(target_os = "windows"))]
6331    {
6332        if let Ok(o) = Command::new("systemctl")
6333            .args(["list-timers", "--no-pager", "--all"])
6334            .output()
6335        {
6336            let text = String::from_utf8_lossy(&o.stdout);
6337            out.push_str("Systemd timers:\n");
6338            for l in text
6339                .lines()
6340                .filter(|l| {
6341                    !l.trim().is_empty() && !l.starts_with("NEXT") && !l.starts_with("timers")
6342                })
6343                .take(n)
6344            {
6345                let _ = write!(out, "  {l}\n");
6346            }
6347            out.push('\n');
6348        }
6349        if let Ok(o) = Command::new("crontab").arg("-l").output() {
6350            let text = String::from_utf8_lossy(&o.stdout);
6351            let jobs: Vec<&str> = text
6352                .lines()
6353                .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
6354                .collect();
6355            if !jobs.is_empty() {
6356                out.push_str("User crontab:\n");
6357                for j in jobs.iter().take(n) {
6358                    let _ = write!(out, "  {j}\n");
6359                }
6360            }
6361        }
6362    }
6363
6364    Ok(out.trim_end().to_string())
6365}
6366
6367// ── dev_conflicts ─────────────────────────────────────────────────────────────
6368
6369fn inspect_dev_conflicts() -> Result<String, String> {
6370    let mut out = String::from("Host inspection: dev_conflicts\n\n");
6371    let mut conflicts: Vec<String> = Vec::with_capacity(4);
6372    let mut notes: Vec<String> = Vec::with_capacity(4);
6373
6374    // ── Node.js / version managers ────────────────────────────────────────────
6375    {
6376        let node_ver = Command::new("node")
6377            .arg("--version")
6378            .output()
6379            .ok()
6380            .and_then(|o| String::from_utf8(o.stdout).ok())
6381            .map(|s| s.trim().to_string());
6382        let nvm_active = Command::new("nvm")
6383            .arg("current")
6384            .output()
6385            .ok()
6386            .and_then(|o| String::from_utf8(o.stdout).ok())
6387            .map(|s| s.trim().to_string())
6388            .filter(|s| !s.is_empty() && !s.contains("none") && !s.contains("No current"));
6389        let fnm_active = Command::new("fnm")
6390            .arg("current")
6391            .output()
6392            .ok()
6393            .and_then(|o| String::from_utf8(o.stdout).ok())
6394            .map(|s| s.trim().to_string())
6395            .filter(|s| !s.is_empty() && !s.contains("none"));
6396        let volta_active = Command::new("volta")
6397            .args(["which", "node"])
6398            .output()
6399            .ok()
6400            .and_then(|o| String::from_utf8(o.stdout).ok())
6401            .map(|s| s.trim().to_string())
6402            .filter(|s| !s.is_empty());
6403
6404        out.push_str("Node.js:\n");
6405        if let Some(ref v) = node_ver {
6406            let _ = writeln!(out, "  Active: {v}");
6407        } else {
6408            out.push_str("  Not installed\n");
6409        }
6410        let managers: Vec<&str> = [
6411            nvm_active.as_deref(),
6412            fnm_active.as_deref(),
6413            volta_active.as_deref(),
6414        ]
6415        .iter()
6416        .filter_map(|x| *x)
6417        .collect();
6418        if managers.len() > 1 {
6419            conflicts.push("Multiple Node.js version managers detected (nvm/fnm/volta). Only one should be active to avoid PATH conflicts.".to_string());
6420        } else if !managers.is_empty() {
6421            let _ = writeln!(out, "  Version manager: {}", managers[0]);
6422        }
6423        out.push('\n');
6424    }
6425
6426    // ── Python ────────────────────────────────────────────────────────────────
6427    {
6428        let py3 = Command::new("python3")
6429            .arg("--version")
6430            .output()
6431            .ok()
6432            .and_then(|o| {
6433                let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6434                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6435                let v = if stdout.is_empty() { stderr } else { stdout };
6436                if v.is_empty() {
6437                    None
6438                } else {
6439                    Some(v)
6440                }
6441            });
6442        let py = Command::new("python")
6443            .arg("--version")
6444            .output()
6445            .ok()
6446            .and_then(|o| {
6447                let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6448                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6449                let v = if stdout.is_empty() { stderr } else { stdout };
6450                if v.is_empty() {
6451                    None
6452                } else {
6453                    Some(v)
6454                }
6455            });
6456        let pyenv = Command::new("pyenv")
6457            .arg("version")
6458            .output()
6459            .ok()
6460            .and_then(|o| String::from_utf8(o.stdout).ok())
6461            .map(|s| s.trim().to_string())
6462            .filter(|s| !s.is_empty());
6463        let conda_env = std::env::var("CONDA_DEFAULT_ENV").ok();
6464
6465        out.push_str("Python:\n");
6466        match (&py3, &py) {
6467            (Some(v3), Some(v)) if v3 != v => {
6468                let _ = write!(out, "  python3: {v3}\n  python:  {v}\n");
6469                if v.contains("2.") {
6470                    conflicts.push(
6471                        "python and python3 point to different major versions (2.x vs 3.x). Scripts using 'python' may break unexpectedly.".to_string()
6472                    );
6473                } else {
6474                    notes.push(
6475                        "python and python3 resolve to different minor versions.".to_string(),
6476                    );
6477                }
6478            }
6479            (Some(v3), None) => {
6480                let _ = writeln!(out, "  python3: {v3}");
6481            }
6482            (None, Some(v)) => {
6483                let _ = writeln!(out, "  python: {v}");
6484            }
6485            (Some(v3), Some(_)) => {
6486                let _ = writeln!(out, "  {v3}");
6487            }
6488            (None, None) => out.push_str("  Not installed\n"),
6489        }
6490        if let Some(ref pe) = pyenv {
6491            let _ = writeln!(out, "  pyenv: {pe}");
6492        }
6493        if let Some(env) = conda_env {
6494            if env == "base" {
6495                notes.push("Conda base environment is active — may shadow system Python. Run 'conda deactivate' if unexpected.".to_string());
6496            } else {
6497                let _ = writeln!(out, "  conda env: {env}");
6498            }
6499        }
6500        out.push('\n');
6501    }
6502
6503    // ── Rust / Cargo ──────────────────────────────────────────────────────────
6504    {
6505        let toolchain = Command::new("rustup")
6506            .args(["show", "active-toolchain"])
6507            .output()
6508            .ok()
6509            .and_then(|o| String::from_utf8(o.stdout).ok())
6510            .map(|s| s.trim().to_string())
6511            .filter(|s| !s.is_empty());
6512        let cargo_ver = Command::new("cargo")
6513            .arg("--version")
6514            .output()
6515            .ok()
6516            .and_then(|o| String::from_utf8(o.stdout).ok())
6517            .map(|s| s.trim().to_string());
6518        let rustc_ver = Command::new("rustc")
6519            .arg("--version")
6520            .output()
6521            .ok()
6522            .and_then(|o| String::from_utf8(o.stdout).ok())
6523            .map(|s| s.trim().to_string());
6524
6525        out.push_str("Rust:\n");
6526        if let Some(ref t) = toolchain {
6527            let _ = writeln!(out, "  Active toolchain: {t}");
6528        }
6529        if let Some(ref c) = cargo_ver {
6530            let _ = writeln!(out, "  {c}");
6531        }
6532        if let Some(ref r) = rustc_ver {
6533            let _ = writeln!(out, "  {r}");
6534        }
6535        if cargo_ver.is_none() && rustc_ver.is_none() {
6536            out.push_str("  Not installed\n");
6537        }
6538
6539        // Detect system rust that might shadow rustup
6540        #[cfg(not(target_os = "windows"))]
6541        if let Ok(o) = Command::new("which").arg("rustc").output() {
6542            let path = String::from_utf8_lossy(&o.stdout).trim().to_string();
6543            if !path.is_empty() && !path.contains(".cargo") && !path.contains("rustup") {
6544                conflicts.push(format!(
6545                    "rustc found at non-rustup path '{path}' — may conflict with rustup-managed toolchain"
6546                ));
6547            }
6548        }
6549        out.push('\n');
6550    }
6551
6552    // ── Git ───────────────────────────────────────────────────────────────────
6553    {
6554        let git_ver = Command::new("git")
6555            .arg("--version")
6556            .output()
6557            .ok()
6558            .and_then(|o| String::from_utf8(o.stdout).ok())
6559            .map(|s| s.trim().to_string());
6560        out.push_str("Git:\n");
6561        if let Some(ref v) = git_ver {
6562            let _ = writeln!(out, "  {v}");
6563            let email = Command::new("git")
6564                .args(["config", "--global", "user.email"])
6565                .output()
6566                .ok()
6567                .and_then(|o| String::from_utf8(o.stdout).ok())
6568                .map(|s| s.trim().to_string());
6569            if let Some(ref e) = email {
6570                if e.is_empty() {
6571                    notes.push("Git user.email is not configured globally — commits may fail or use wrong identity.".to_string());
6572                } else {
6573                    let _ = writeln!(out, "  user.email: {e}");
6574                }
6575            }
6576            let gpg_sign = Command::new("git")
6577                .args(["config", "--global", "commit.gpgsign"])
6578                .output()
6579                .ok()
6580                .and_then(|o| String::from_utf8(o.stdout).ok())
6581                .map(|s| s.trim().to_string());
6582            if gpg_sign.as_deref() == Some("true") {
6583                let key = Command::new("git")
6584                    .args(["config", "--global", "user.signingkey"])
6585                    .output()
6586                    .ok()
6587                    .and_then(|o| String::from_utf8(o.stdout).ok())
6588                    .map(|s| s.trim().to_string());
6589                if key.as_deref().map(|k| k.is_empty()).unwrap_or(true) {
6590                    conflicts.push("Git commit signing is enabled but no signing key is configured — commits will fail.".to_string());
6591                }
6592            }
6593        } else {
6594            out.push_str("  Not installed\n");
6595        }
6596        out.push('\n');
6597    }
6598
6599    // ── PATH duplicates ───────────────────────────────────────────────────────
6600    {
6601        let path_env = std::env::var("PATH").unwrap_or_default();
6602        let sep = if cfg!(windows) { ';' } else { ':' };
6603        let mut seen = HashSet::new();
6604        let mut dupes: Vec<String> = Vec::new();
6605        for p in path_env.split(sep) {
6606            let norm = p.trim().to_lowercase();
6607            if !norm.is_empty() && !seen.insert(norm) {
6608                dupes.push(p.to_string());
6609            }
6610        }
6611        if !dupes.is_empty() {
6612            let shown: Vec<&str> = dupes.iter().take(3).map(|s| s.as_str()).collect();
6613            notes.push(format!(
6614                "Duplicate PATH entries: {} {}",
6615                shown.join(", "),
6616                if dupes.len() > 3 {
6617                    format!("+{} more", dupes.len() - 3)
6618                } else {
6619                    String::new()
6620                }
6621            ));
6622        }
6623    }
6624
6625    // ── Summary ───────────────────────────────────────────────────────────────
6626    if conflicts.is_empty() && notes.is_empty() {
6627        out.push_str("No conflicts detected — dev environment looks clean.\n");
6628    } else {
6629        if !conflicts.is_empty() {
6630            out.push_str("CONFLICTS:\n");
6631            for c in &conflicts {
6632                let _ = writeln!(out, "  [!] {c}");
6633            }
6634            out.push('\n');
6635        }
6636        if !notes.is_empty() {
6637            out.push_str("NOTES:\n");
6638            for n in &notes {
6639                let _ = writeln!(out, "  [-] {n}");
6640            }
6641        }
6642    }
6643
6644    Ok(out.trim_end().to_string())
6645}
6646
6647// ── connectivity ──────────────────────────────────────────────────────────────
6648
6649async fn inspect_public_ip() -> Result<String, String> {
6650    let mut out = String::from("Host inspection: public_ip\n\n");
6651
6652    let client = reqwest::Client::builder()
6653        .timeout(std::time::Duration::from_secs(5))
6654        .build()
6655        .map_err(|e| format!("Failed to build HTTP client: {}", e))?;
6656
6657    match client.get("https://api.ipify.org?format=json").send().await {
6658        Ok(resp) => {
6659            if let Ok(json) = resp.json::<serde_json::Value>().await {
6660                let ip = json.get("ip").and_then(|v| v.as_str()).unwrap_or("Unknown");
6661                let _ = writeln!(out, "Public IP: {}", ip);
6662
6663                // Geo info
6664                if let Ok(geo_resp) = client
6665                    .get(format!("http://ip-api.com/json/{}", ip))
6666                    .send()
6667                    .await
6668                {
6669                    if let Ok(geo_json) = geo_resp.json::<serde_json::Value>().await {
6670                        if let (Some(city), Some(region), Some(country), Some(isp)) = (
6671                            geo_json.get("city").and_then(|v| v.as_str()),
6672                            geo_json.get("regionName").and_then(|v| v.as_str()),
6673                            geo_json.get("country").and_then(|v| v.as_str()),
6674                            geo_json.get("isp").and_then(|v| v.as_str()),
6675                        ) {
6676                            let _ = writeln!(out, "Location:  {}, {} ({})", city, region, country);
6677                            let _ = writeln!(out, "ISP:       {}", isp);
6678                        }
6679                    }
6680                }
6681            } else {
6682                out.push_str("Error: Failed to parse public IP response.\n");
6683            }
6684        }
6685        Err(e) => {
6686            let _ = writeln!(
6687                out,
6688                "Error: Failed to fetch public IP ({}). Check internet connectivity.",
6689                e
6690            );
6691        }
6692    }
6693
6694    Ok(out)
6695}
6696
6697fn inspect_ssl_cert(host: &str) -> Result<String, String> {
6698    let mut out = format!("Host inspection: ssl_cert (Target: {})\n\n", host);
6699
6700    #[cfg(target_os = "windows")]
6701    {
6702        use std::process::Command;
6703        let script = format!(
6704            r#"$domain = "{host}"
6705try {{
6706    $tcpClient = New-Object System.Net.Sockets.TcpClient($domain, 443)
6707    $sslStream = New-Object System.Net.Security.SslStream($tcpClient.GetStream(), $false, ({{ $true }} -as [System.Net.Security.RemoteCertificateValidationCallback]))
6708    $sslStream.AuthenticateAsClient($domain)
6709    $cert = $sslStream.RemoteCertificate
6710    $tcpClient.Close()
6711    if ($cert) {{
6712        $cert2 = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($cert)
6713        $cert2 | Select-Object Subject, Issuer, NotBefore, NotAfter, Thumbprint, SerialNumber | ConvertTo-Json
6714    }} else {{
6715        "null"
6716    }}
6717}} catch {{
6718    "ERROR:" + $_.Exception.Message
6719}}"#
6720        );
6721
6722        let ps_out = Command::new("powershell")
6723            .args(["-NoProfile", "-NonInteractive", "-Command", &script])
6724            .output()
6725            .map_err(|e| format!("powershell launch failed: {e}"))?;
6726
6727        let text = String::from_utf8_lossy(&ps_out.stdout).trim().to_string();
6728        if text.starts_with("ERROR:") {
6729            let _ = writeln!(out, "Error: {}", text.trim_start_matches("ERROR:"));
6730        } else if text == "null" || text.is_empty() {
6731            out.push_str("Error: Could not retrieve certificate. Target may be unreachable or not using SSL.\n");
6732        } else if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
6733            if let Some(obj) = json.as_object() {
6734                for (k, v) in obj {
6735                    let val_str = v.as_str().unwrap_or("");
6736                    let _ = writeln!(out, "{:<12}: {}", k, val_str);
6737                }
6738
6739                if let Some(not_after_raw) = obj.get("NotAfter").and_then(|v| v.as_str()) {
6740                    if not_after_raw.starts_with("/Date(") {
6741                        let ts = not_after_raw
6742                            .trim_start_matches("/Date(")
6743                            .trim_end_matches(")/")
6744                            .parse::<i64>()
6745                            .unwrap_or(0);
6746                        let expiry =
6747                            chrono::DateTime::from_timestamp(ts / 1000, 0).unwrap_or_default();
6748                        let now = chrono::Utc::now();
6749                        let days_left = expiry.signed_duration_since(now).num_days();
6750                        if days_left < 0 {
6751                            out.push_str("\nSTATUS: [!!] EXPIRED\n");
6752                        } else if days_left < 30 {
6753                            let _ = write!(
6754                                out,
6755                                "\nSTATUS: [!] EXPIRING SOON ({} days left)\n",
6756                                days_left
6757                            );
6758                        } else {
6759                            let _ = write!(out, "\nSTATUS: Valid ({} days left)\n", days_left);
6760                        }
6761                    } else if let Ok(expiry) = chrono::DateTime::parse_from_rfc3339(not_after_raw) {
6762                        let now = chrono::Utc::now();
6763                        let days_left = expiry.signed_duration_since(now).num_days();
6764                        if days_left < 0 {
6765                            out.push_str("\nSTATUS: [!!] EXPIRED\n");
6766                        } else if days_left < 30 {
6767                            let _ = write!(
6768                                out,
6769                                "\nSTATUS: [!] EXPIRING SOON ({} days left)\n",
6770                                days_left
6771                            );
6772                        } else {
6773                            let _ = write!(out, "\nSTATUS: Valid ({} days left)\n", days_left);
6774                        }
6775                    }
6776                }
6777            }
6778        } else {
6779            let _ = writeln!(out, "Raw Output: {}", text);
6780        }
6781    }
6782
6783    #[cfg(not(target_os = "windows"))]
6784    {
6785        out.push_str(
6786            "Note: Deep SSL inspection currently optimized for Windows (PowerShell/SslStream).\n",
6787        );
6788    }
6789
6790    Ok(out)
6791}
6792
6793async fn inspect_data_audit(path: PathBuf, _max_entries: usize) -> Result<String, String> {
6794    let mut out = format!("Host inspection: data_audit (Path: {:?})\n\n", path);
6795
6796    if !path.exists() {
6797        return Err(format!("File not found: {:?}", path));
6798    }
6799    if !path.is_file() {
6800        return Err(format!("Not a file: {:?}", path));
6801    }
6802
6803    let file_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
6804    let _ = writeln!(
6805        out,
6806        "File Size: {} bytes ({:.2} MB)",
6807        file_size,
6808        file_size as f64 / 1_048_576.0
6809    );
6810
6811    let ext = path
6812        .extension()
6813        .and_then(|s| s.to_str())
6814        .unwrap_or("")
6815        .to_lowercase();
6816    let _ = write!(out, "Format:    {}\n\n", ext.to_uppercase());
6817
6818    match ext.as_str() {
6819        "csv" | "tsv" | "txt" | "log" => {
6820            let content = std::fs::read_to_string(&path)
6821                .map_err(|e| format!("Failed to read file: {}", e))?;
6822            let lines: Vec<&str> = content.lines().collect();
6823            let _ = writeln!(out, "Row Count: {} (total lines)", lines.len());
6824
6825            if let Some(header) = lines.first() {
6826                out.push_str("Columns (Guessed from header):\n");
6827                let delimiter = if ext == "tsv" {
6828                    "\t"
6829                } else if header.contains(',') {
6830                    ","
6831                } else {
6832                    " "
6833                };
6834                for (i, col) in header.split(delimiter).map(|s| s.trim()).enumerate() {
6835                    let _ = writeln!(out, "  {}. {}", i + 1, col);
6836                }
6837            }
6838
6839            out.push_str("\nSample Data (First 5 rows):\n");
6840            for line in lines.iter().take(6) {
6841                let _ = writeln!(out, "  {}", line);
6842            }
6843        }
6844        "json" => {
6845            let content = std::fs::read_to_string(&path)
6846                .map_err(|e| format!("Failed to read file: {}", e))?;
6847            if let Ok(json) = serde_json::from_str::<Value>(&content) {
6848                if let Some(arr) = json.as_array() {
6849                    let _ = writeln!(out, "Record Count: {}", arr.len());
6850                    if let Some(first) = arr.first() {
6851                        if let Some(obj) = first.as_object() {
6852                            out.push_str("Fields (from first record):\n");
6853                            for k in obj.keys() {
6854                                let _ = writeln!(out, "  - {}", k);
6855                            }
6856                        }
6857                    }
6858                    out.push_str("\nSample Record:\n");
6859                    out.push_str(&serde_json::to_string_pretty(&arr.first()).unwrap_or_default());
6860                } else if let Some(obj) = json.as_object() {
6861                    out.push_str("Top-level Keys:\n");
6862                    for k in obj.keys() {
6863                        let _ = writeln!(out, "  - {}", k);
6864                    }
6865                }
6866            } else {
6867                out.push_str("Error: Failed to parse as JSON.\n");
6868            }
6869        }
6870        "db" | "sqlite" | "sqlite3" => {
6871            out.push_str("SQLite Database detected.\n");
6872            out.push_str("Use `query_data` to execute SQL against this database.\n");
6873        }
6874        _ => {
6875            out.push_str("Unsupported format for deep audit. Showing first 10 lines:\n\n");
6876            let content = std::fs::read_to_string(&path)
6877                .map_err(|e| format!("Failed to read file: {}", e))?;
6878            for line in content.lines().take(10) {
6879                let _ = writeln!(out, "  {}", line);
6880            }
6881        }
6882    }
6883
6884    Ok(out)
6885}
6886
6887fn inspect_connectivity() -> Result<String, String> {
6888    let mut out = String::from("Host inspection: connectivity\n\n");
6889
6890    #[cfg(target_os = "windows")]
6891    {
6892        let inet_script = r#"
6893try {
6894    $r = Test-NetConnection -ComputerName 8.8.8.8 -Port 53 -InformationLevel Quiet -WarningAction SilentlyContinue
6895    if ($r) { "REACHABLE" } else { "UNREACHABLE" }
6896} catch { "ERROR:" + $_.Exception.Message }
6897"#;
6898        if let Ok(o) = Command::new("powershell")
6899            .args(["-NoProfile", "-Command", inet_script])
6900            .output()
6901        {
6902            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6903            match text.as_str() {
6904                "REACHABLE" => out.push_str("Internet: reachable\n"),
6905                "UNREACHABLE" => out.push_str("Internet: unreachable [!]\n"),
6906                _ => {
6907                    let _ = writeln!(
6908                        out,
6909                        "Internet: {}",
6910                        text.trim_start_matches("ERROR:").trim()
6911                    );
6912                }
6913            }
6914        }
6915
6916        let dns_script = r#"
6917try {
6918    Resolve-DnsName -Name "dns.google" -Type A -ErrorAction Stop | Out-Null
6919    "DNS:ok"
6920} catch { "DNS:fail:" + $_.Exception.Message }
6921"#;
6922        if let Ok(o) = Command::new("powershell")
6923            .args(["-NoProfile", "-Command", dns_script])
6924            .output()
6925        {
6926            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6927            if text == "DNS:ok" {
6928                out.push_str("DNS: resolving correctly\n");
6929            } else {
6930                let detail = text.trim_start_matches("DNS:fail:").trim();
6931                let _ = writeln!(out, "DNS: failed — {}", detail);
6932            }
6933        }
6934
6935        let gw_script = r#"
6936(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop
6937"#;
6938        if let Ok(o) = Command::new("powershell")
6939            .args(["-NoProfile", "-Command", gw_script])
6940            .output()
6941        {
6942            let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
6943            if !gw.is_empty() && gw != "0.0.0.0" {
6944                let _ = writeln!(out, "Default gateway: {}", gw);
6945            }
6946        }
6947    }
6948
6949    #[cfg(not(target_os = "windows"))]
6950    {
6951        let reachable = Command::new("ping")
6952            .args(["-c", "1", "-W", "2", "8.8.8.8"])
6953            .output()
6954            .map(|o| o.status.success())
6955            .unwrap_or(false);
6956        out.push_str(if reachable {
6957            "Internet: reachable\n"
6958        } else {
6959            "Internet: unreachable\n"
6960        });
6961        let dns_ok = Command::new("getent")
6962            .args(["hosts", "dns.google"])
6963            .output()
6964            .map(|o| o.status.success())
6965            .unwrap_or(false);
6966        out.push_str(if dns_ok {
6967            "DNS: resolving correctly\n"
6968        } else {
6969            "DNS: failed\n"
6970        });
6971        if let Ok(o) = Command::new("ip")
6972            .args(["route", "show", "default"])
6973            .output()
6974        {
6975            let text = String::from_utf8_lossy(&o.stdout);
6976            if let Some(line) = text.lines().next() {
6977                let _ = write!(out, "Default gateway: {}\n", line.trim());
6978            }
6979        }
6980    }
6981
6982    Ok(out.trim_end().to_string())
6983}
6984
6985// ── wifi ──────────────────────────────────────────────────────────────────────
6986
6987fn inspect_wifi() -> Result<String, String> {
6988    let mut out = String::from("Host inspection: wifi\n\n");
6989
6990    #[cfg(target_os = "windows")]
6991    {
6992        let output = Command::new("netsh")
6993            .args(["wlan", "show", "interfaces"])
6994            .output()
6995            .map_err(|e| format!("wifi: {e}"))?;
6996        let text = String::from_utf8_lossy(&output.stdout).into_owned();
6997
6998        if text.contains("There is no wireless interface") || text.trim().is_empty() {
6999            out.push_str("No wireless interface detected on this machine.\n");
7000            return Ok(out.trim_end().to_string());
7001        }
7002
7003        let fields = [
7004            ("SSID", "SSID"),
7005            ("State", "State"),
7006            ("Signal", "Signal"),
7007            ("Radio type", "Radio type"),
7008            ("Channel", "Channel"),
7009            ("Receive rate (Mbps)", "Download speed (Mbps)"),
7010            ("Transmit rate (Mbps)", "Upload speed (Mbps)"),
7011            ("Authentication", "Authentication"),
7012            ("Network type", "Network type"),
7013        ];
7014
7015        let mut any = false;
7016        for line in text.lines() {
7017            let trimmed = line.trim();
7018            for (key, label) in &fields {
7019                if trimmed.starts_with(key) && trimmed.contains(':') {
7020                    let val = trimmed.split_once(':').map(|x| x.1).unwrap_or("").trim();
7021                    if !val.is_empty() {
7022                        let _ = writeln!(out, "  {label}: {val}");
7023                        any = true;
7024                    }
7025                }
7026            }
7027        }
7028        if !any {
7029            out.push_str("  (Wi-Fi adapter disconnected or no active connection)\n");
7030        }
7031    }
7032
7033    #[cfg(not(target_os = "windows"))]
7034    {
7035        if let Ok(o) = Command::new("nmcli")
7036            .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
7037            .output()
7038        {
7039            let text = String::from_utf8_lossy(&o.stdout).into_owned();
7040            let lines: Vec<&str> = text.lines().filter(|l| l.contains(":wifi:")).collect();
7041            if lines.is_empty() {
7042                out.push_str("No Wi-Fi devices found.\n");
7043            } else {
7044                for l in lines {
7045                    let _ = write!(out, "  {l}\n");
7046                }
7047            }
7048        } else if let Ok(o) = Command::new("iwconfig").output() {
7049            let text = String::from_utf8_lossy(&o.stdout).into_owned();
7050            if !text.trim().is_empty() {
7051                out.push_str(text.trim());
7052                out.push('\n');
7053            }
7054        } else {
7055            out.push_str("No wireless tool available (install nmcli or wireless-tools).\n");
7056        }
7057    }
7058
7059    Ok(out.trim_end().to_string())
7060}
7061
7062// ── connections ───────────────────────────────────────────────────────────────
7063
7064fn inspect_connections(max_entries: usize) -> Result<String, String> {
7065    let mut out = String::from("Host inspection: connections\n\n");
7066    let n = max_entries.clamp(1, 25);
7067
7068    #[cfg(target_os = "windows")]
7069    {
7070        let script = format!(
7071            r#"
7072try {{
7073    $procs = @{{}}
7074    Get-Process -ErrorAction SilentlyContinue | ForEach-Object {{ $procs[$_.Id] = $_.Name }}
7075    $all = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
7076        Sort-Object OwningProcess
7077    "TOTAL:" + $all.Count
7078    $all | Select-Object -First {n} | ForEach-Object {{
7079        $pname = if ($procs.ContainsKey($_.OwningProcess)) {{ $procs[$_.OwningProcess] }} else {{ "unknown" }}
7080        $pname + "|" + $_.OwningProcess + "|" + $_.LocalAddress + ":" + $_.LocalPort + "|" + $_.RemoteAddress + ":" + $_.RemotePort
7081    }}
7082}} catch {{ "ERROR:" + $_.Exception.Message }}"#
7083        );
7084
7085        let output = Command::new("powershell")
7086            .args(["-NoProfile", "-Command", &script])
7087            .output()
7088            .map_err(|e| format!("connections: {e}"))?;
7089
7090        let raw = String::from_utf8_lossy(&output.stdout);
7091        let text = raw.trim();
7092
7093        if text.starts_with("ERROR:") {
7094            let _ = writeln!(out, "Unable to query connections: {text}");
7095        } else {
7096            let mut total = 0usize;
7097            let mut rows = Vec::new();
7098            for line in text.lines() {
7099                if let Some(rest) = line.strip_prefix("TOTAL:") {
7100                    total = rest.trim().parse().unwrap_or(0);
7101                } else {
7102                    rows.push(line);
7103                }
7104            }
7105            let _ = write!(out, "Established TCP connections: {total}\n\n");
7106            for row in &rows {
7107                let mut it = row.splitn(4, '|');
7108                if let (Some(p0), Some(p1), Some(p2), Some(p3)) =
7109                    (it.next(), it.next(), it.next(), it.next())
7110                {
7111                    let _ = writeln!(out, "  {:<15} (pid {:<5}) | {} → {}", p0, p1, p2, p3);
7112                }
7113            }
7114            if total > n {
7115                let _ = write!(
7116                    out,
7117                    "\n  ... {} more connections not shown\n",
7118                    total.saturating_sub(n)
7119                );
7120            }
7121        }
7122    }
7123
7124    #[cfg(not(target_os = "windows"))]
7125    {
7126        if let Ok(o) = Command::new("ss")
7127            .args(["-tnp", "state", "established"])
7128            .output()
7129        {
7130            let text = String::from_utf8_lossy(&o.stdout);
7131            let lines: Vec<&str> = text
7132                .lines()
7133                .skip(1)
7134                .filter(|l| !l.trim().is_empty())
7135                .collect();
7136            let _ = write!(out, "Established TCP connections: {}\n\n", lines.len());
7137            for line in lines.iter().take(n) {
7138                let _ = write!(out, "  {}\n", line.trim());
7139            }
7140            if lines.len() > n {
7141                let _ = write!(out, "\n  ... {} more not shown\n", lines.len() - n);
7142            }
7143        } else {
7144            out.push_str("ss not available — install iproute2\n");
7145        }
7146    }
7147
7148    Ok(out.trim_end().to_string())
7149}
7150
7151// ── vpn ───────────────────────────────────────────────────────────────────────
7152
7153fn inspect_vpn() -> Result<String, String> {
7154    let mut out = String::from("Host inspection: vpn\n\n");
7155
7156    #[cfg(target_os = "windows")]
7157    {
7158        let script = r#"
7159try {
7160    $vpn = Get-NetAdapter -ErrorAction Stop | Where-Object {
7161        $_.InterfaceDescription -match 'VPN|TAP|WireGuard|OpenVPN|Cisco|Palo Alto|GlobalProtect|Juniper|Pulse|NordVPN|ExpressVPN|Mullvad|ProtonVPN' -or
7162        $_.Name -match 'VPN|TAP|WireGuard|tun|ppp|wg\d'
7163    }
7164    if ($vpn) {
7165        foreach ($a in $vpn) {
7166            $a.Name + "|" + $a.InterfaceDescription + "|" + $a.Status + "|" + $a.MediaConnectionState
7167        }
7168    } else { "NONE" }
7169} catch { "ERROR:" + $_.Exception.Message }
7170"#;
7171        let output = Command::new("powershell")
7172            .args(["-NoProfile", "-Command", script])
7173            .output()
7174            .map_err(|e| format!("vpn: {e}"))?;
7175
7176        let raw = String::from_utf8_lossy(&output.stdout);
7177        let text = raw.trim();
7178
7179        if text == "NONE" {
7180            out.push_str("No VPN adapters detected — no active VPN connection found.\n");
7181        } else if text.starts_with("ERROR:") {
7182            let _ = writeln!(out, "Unable to query adapters: {text}");
7183        } else {
7184            out.push_str("VPN adapters:\n\n");
7185            for line in text.lines() {
7186                let mut it = line.splitn(4, '|');
7187                if let (Some(name), Some(desc), Some(status)) = (it.next(), it.next(), it.next()) {
7188                    let media = it.next().unwrap_or("unknown");
7189                    let label = if status.trim() == "Up" {
7190                        "CONNECTED"
7191                    } else {
7192                        "disconnected"
7193                    };
7194                    let _ =
7195                        write!(out,
7196                        "  {name} [{label}]\n    {desc}\n    Status: {status} | Media: {media}\n\n"
7197                    );
7198                }
7199            }
7200        }
7201
7202        // Windows built-in VPN connections
7203        let ras_script = r#"
7204try {
7205    $c = Get-VpnConnection -ErrorAction Stop
7206    if ($c) { foreach ($v in $c) { $v.Name + "|" + $v.ConnectionStatus + "|" + $v.ServerAddress } }
7207    else { "NO_RAS" }
7208} catch { "NO_RAS" }
7209"#;
7210        if let Ok(o) = Command::new("powershell")
7211            .args(["-NoProfile", "-Command", ras_script])
7212            .output()
7213        {
7214            let t = String::from_utf8_lossy(&o.stdout).trim().to_string();
7215            if t != "NO_RAS" && !t.is_empty() {
7216                out.push_str("Windows VPN connections:\n");
7217                for line in t.lines() {
7218                    let mut it = line.splitn(3, '|');
7219                    if let (Some(name), Some(status)) = (it.next(), it.next()) {
7220                        let server = it.next().unwrap_or("");
7221                        let _ = writeln!(out, "  {name} → {server} [{status}]");
7222                    }
7223                }
7224            }
7225        }
7226    }
7227
7228    #[cfg(not(target_os = "windows"))]
7229    {
7230        if let Ok(o) = Command::new("ip").args(["link", "show"]).output() {
7231            let text = String::from_utf8_lossy(&o.stdout);
7232            let vpn_ifaces: Vec<&str> = text
7233                .lines()
7234                .filter(|l| {
7235                    l.contains("tun") || l.contains("tap") || l.contains(" wg") || l.contains("ppp")
7236                })
7237                .collect();
7238            if vpn_ifaces.is_empty() {
7239                out.push_str("No VPN interfaces (tun/tap/wg/ppp) detected.\n");
7240            } else {
7241                let _ = write!(out, "VPN-like interfaces ({}):\n", vpn_ifaces.len());
7242                for l in vpn_ifaces {
7243                    let _ = write!(out, "  {}\n", l.trim());
7244                }
7245            }
7246        }
7247    }
7248
7249    Ok(out.trim_end().to_string())
7250}
7251
7252// ── proxy ─────────────────────────────────────────────────────────────────────
7253
7254fn inspect_proxy() -> Result<String, String> {
7255    let mut out = String::from("Host inspection: proxy\n\n");
7256
7257    #[cfg(target_os = "windows")]
7258    {
7259        let script = r#"
7260$ie = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
7261if ($ie) {
7262    "ENABLE:" + $ie.ProxyEnable + "|SERVER:" + $ie.ProxyServer + "|OVERRIDE:" + $ie.ProxyOverride
7263} else { "NONE" }
7264"#;
7265        if let Ok(o) = Command::new("powershell")
7266            .args(["-NoProfile", "-Command", script])
7267            .output()
7268        {
7269            let raw = String::from_utf8_lossy(&o.stdout);
7270            let text = raw.trim();
7271            if text != "NONE" && !text.is_empty() {
7272                let get = |key: &str| -> &str {
7273                    text.split('|')
7274                        .find(|s| s.starts_with(key))
7275                        .and_then(|s| s.split_once(':').map(|x| x.1))
7276                        .unwrap_or("")
7277                };
7278                let enabled = get("ENABLE");
7279                let server = get("SERVER");
7280                let overrides = get("OVERRIDE");
7281                out.push_str("WinINET / IE proxy:\n");
7282                let _ = writeln!(
7283                    out,
7284                    "  Enabled: {}",
7285                    if enabled == "1" { "yes" } else { "no" }
7286                );
7287                if !server.is_empty() && server != "None" {
7288                    let _ = writeln!(out, "  Proxy server: {server}");
7289                }
7290                if !overrides.is_empty() && overrides != "None" {
7291                    let _ = writeln!(out, "  Bypass list: {overrides}");
7292                }
7293                out.push('\n');
7294            }
7295        }
7296
7297        if let Ok(o) = Command::new("netsh")
7298            .args(["winhttp", "show", "proxy"])
7299            .output()
7300        {
7301            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7302            out.push_str("WinHTTP proxy:\n");
7303            for line in text.lines() {
7304                let l = line.trim();
7305                if !l.is_empty() {
7306                    let _ = writeln!(out, "  {l}");
7307                }
7308            }
7309            out.push('\n');
7310        }
7311
7312        let mut env_found = false;
7313        for var in &[
7314            "http_proxy",
7315            "https_proxy",
7316            "HTTP_PROXY",
7317            "HTTPS_PROXY",
7318            "no_proxy",
7319            "NO_PROXY",
7320        ] {
7321            if let Ok(val) = std::env::var(var) {
7322                if !env_found {
7323                    out.push_str("Environment proxy variables:\n");
7324                    env_found = true;
7325                }
7326                let _ = writeln!(out, "  {var}: {val}");
7327            }
7328        }
7329        if !env_found {
7330            out.push_str("No proxy environment variables set.\n");
7331        }
7332    }
7333
7334    #[cfg(not(target_os = "windows"))]
7335    {
7336        let mut found = false;
7337        for var in &[
7338            "http_proxy",
7339            "https_proxy",
7340            "HTTP_PROXY",
7341            "HTTPS_PROXY",
7342            "no_proxy",
7343            "NO_PROXY",
7344            "ALL_PROXY",
7345            "all_proxy",
7346        ] {
7347            if let Ok(val) = std::env::var(var) {
7348                if !found {
7349                    out.push_str("Proxy environment variables:\n");
7350                    found = true;
7351                }
7352                let _ = write!(out, "  {var}: {val}\n");
7353            }
7354        }
7355        if !found {
7356            out.push_str("No proxy environment variables set.\n");
7357        }
7358        if let Ok(content) = std::fs::read_to_string("/etc/environment") {
7359            let proxy_lines: Vec<&str> = content
7360                .lines()
7361                .filter(|l| l.to_lowercase().contains("proxy"))
7362                .collect();
7363            if !proxy_lines.is_empty() {
7364                out.push_str("\nSystem proxy (/etc/environment):\n");
7365                for l in proxy_lines {
7366                    let _ = write!(out, "  {l}\n");
7367                }
7368            }
7369        }
7370    }
7371
7372    Ok(out.trim_end().to_string())
7373}
7374
7375// ── firewall_rules ────────────────────────────────────────────────────────────
7376
7377fn inspect_firewall_rules(max_entries: usize) -> Result<String, String> {
7378    let mut out = String::from("Host inspection: firewall_rules\n\n");
7379    let n = max_entries.clamp(1, 20);
7380
7381    #[cfg(target_os = "windows")]
7382    {
7383        let script = format!(
7384            r#"
7385try {{
7386    $rules = Get-NetFirewallRule -Enabled True -ErrorAction Stop |
7387        Where-Object {{
7388            $_.DisplayGroup -notmatch '^(@|Core Networking|Windows|File and Printer)' -and
7389            $_.Owner -eq $null
7390        }} | Select-Object -First {n} DisplayName, Direction, Action, Profile
7391    "TOTAL:" + $rules.Count
7392    $rules | ForEach-Object {{
7393        $dir = switch ($_.Direction) {{ 1 {{ "Inbound" }}; 2 {{ "Outbound" }}; default {{ "?" }} }}
7394        $act = switch ($_.Action) {{ 2 {{ "Allow" }}; 4 {{ "Block" }}; default {{ "?" }} }}
7395        $_.DisplayName + "|" + $dir + "|" + $act + "|" + $_.Profile
7396    }}
7397}} catch {{ "ERROR:" + $_.Exception.Message }}"#
7398        );
7399
7400        let output = Command::new("powershell")
7401            .args(["-NoProfile", "-Command", &script])
7402            .output()
7403            .map_err(|e| format!("firewall_rules: {e}"))?;
7404
7405        let raw = String::from_utf8_lossy(&output.stdout);
7406        let text = raw.trim();
7407
7408        if text.starts_with("ERROR:") {
7409            let _ = writeln!(
7410                out,
7411                "Unable to query firewall rules: {}",
7412                text.trim_start_matches("ERROR:").trim()
7413            );
7414            out.push_str("This query may require running as administrator.\n");
7415        } else if text.is_empty() {
7416            out.push_str("No non-default enabled firewall rules found.\n");
7417        } else {
7418            let mut total = 0usize;
7419            for line in text.lines() {
7420                if let Some(rest) = line.strip_prefix("TOTAL:") {
7421                    total = rest.trim().parse().unwrap_or(0);
7422                    let _ = write!(out, "Non-default enabled rules (showing up to {n}):\n\n");
7423                } else {
7424                    let mut it = line.splitn(4, '|');
7425                    if let (Some(name), Some(dir), Some(action)) = (it.next(), it.next(), it.next())
7426                    {
7427                        let profile = it.next().unwrap_or("Any");
7428                        let icon = if action == "Block" { "[!]" } else { "   " };
7429                        let _ = writeln!(
7430                            out,
7431                            "  {icon} [{dir}] {action}: {name} (profile: {profile})"
7432                        );
7433                    }
7434                }
7435            }
7436            if total == 0 {
7437                out.push_str("No non-default enabled rules found.\n");
7438            }
7439        }
7440    }
7441
7442    #[cfg(not(target_os = "windows"))]
7443    {
7444        if let Ok(o) = Command::new("ufw").args(["status", "numbered"]).output() {
7445            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7446            if !text.is_empty() {
7447                out.push_str(&text);
7448                out.push('\n');
7449            }
7450        } else if let Ok(o) = Command::new("iptables")
7451            .args(["-L", "-n", "--line-numbers"])
7452            .output()
7453        {
7454            let text = String::from_utf8_lossy(&o.stdout);
7455            for l in text.lines().take(n * 2) {
7456                let _ = write!(out, "  {l}\n");
7457            }
7458        } else {
7459            out.push_str("ufw and iptables not available or insufficient permissions.\n");
7460        }
7461    }
7462
7463    Ok(out.trim_end().to_string())
7464}
7465
7466// ── traceroute ────────────────────────────────────────────────────────────────
7467
7468fn inspect_traceroute(host: &str, max_entries: usize) -> Result<String, String> {
7469    let mut out = format!("Host inspection: traceroute\n\nTarget: {host}\n\n");
7470    let hops = max_entries.clamp(5, 30);
7471
7472    #[cfg(target_os = "windows")]
7473    {
7474        let output = Command::new("tracert")
7475            .args(["-d", "-h", &hops.to_string(), host])
7476            .output()
7477            .map_err(|e| format!("tracert: {e}"))?;
7478        let raw = String::from_utf8_lossy(&output.stdout);
7479        let mut hop_count = 0usize;
7480        for line in raw.lines() {
7481            let trimmed = line.trim();
7482            if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
7483                hop_count += 1;
7484                let _ = writeln!(out, "  {trimmed}");
7485            } else if trimmed.starts_with("Tracing") || trimmed.starts_with("Trace complete") {
7486                let _ = writeln!(out, "{trimmed}");
7487            }
7488        }
7489        if hop_count == 0 {
7490            out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
7491        }
7492    }
7493
7494    #[cfg(not(target_os = "windows"))]
7495    {
7496        let cmd = if std::path::Path::new("/usr/bin/traceroute").exists()
7497            || std::path::Path::new("/usr/sbin/traceroute").exists()
7498        {
7499            "traceroute"
7500        } else {
7501            "tracepath"
7502        };
7503        let output = Command::new(cmd)
7504            .args(["-m", &hops.to_string(), "-n", host])
7505            .output()
7506            .map_err(|e| format!("{cmd}: {e}"))?;
7507        let raw = String::from_utf8_lossy(&output.stdout);
7508        let mut hop_count = 0usize;
7509        for line in raw.lines().take(hops + 2) {
7510            let trimmed = line.trim();
7511            if !trimmed.is_empty() {
7512                hop_count += 1;
7513                let _ = write!(out, "  {trimmed}\n");
7514            }
7515        }
7516        if hop_count == 0 {
7517            out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
7518        }
7519    }
7520
7521    Ok(out.trim_end().to_string())
7522}
7523
7524// ── dns_cache ─────────────────────────────────────────────────────────────────
7525
7526fn inspect_dns_cache(max_entries: usize) -> Result<String, String> {
7527    let mut out = String::from("Host inspection: dns_cache\n\n");
7528    let n = max_entries.clamp(10, 100);
7529
7530    #[cfg(target_os = "windows")]
7531    {
7532        let output = Command::new("powershell")
7533            .args([
7534                "-NoProfile",
7535                "-Command",
7536                "Get-DnsClientCache | Select-Object -First 200 Entry,RecordType,Data,TimeToLive | ConvertTo-Csv -NoTypeInformation",
7537            ])
7538            .output()
7539            .map_err(|e| format!("dns_cache: {e}"))?;
7540
7541        let raw = String::from_utf8_lossy(&output.stdout);
7542        let lines: Vec<&str> = raw.lines().skip(1).collect();
7543        let total = lines.len();
7544
7545        if total == 0 {
7546            out.push_str("DNS cache is empty or could not be read.\n");
7547        } else {
7548            let _ = write!(out, "DNS cache entries (showing up to {n} of {total}):\n\n");
7549            let mut shown = 0usize;
7550            for line in lines.iter().take(n) {
7551                let mut it = line.splitn(4, ',');
7552                if let (Some(e), Some(rt), Some(d)) = (it.next(), it.next(), it.next()) {
7553                    let entry = e.trim_matches('"');
7554                    let rtype = rt.trim_matches('"');
7555                    let data = d.trim_matches('"');
7556                    let ttl = it.next().map(|s| s.trim_matches('"')).unwrap_or("?");
7557                    let _ = writeln!(out, "  {entry:<45} {rtype:<6} {data}  (TTL {ttl}s)");
7558                    shown += 1;
7559                }
7560            }
7561            if total > shown {
7562                let _ = write!(out, "\n  ... and {} more entries\n", total - shown);
7563            }
7564        }
7565    }
7566
7567    #[cfg(not(target_os = "windows"))]
7568    {
7569        if let Ok(o) = Command::new("resolvectl").args(["statistics"]).output() {
7570            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7571            if !text.is_empty() {
7572                out.push_str("systemd-resolved statistics:\n");
7573                for line in text.lines().take(n) {
7574                    let _ = write!(out, "  {line}\n");
7575                }
7576                out.push('\n');
7577            }
7578        }
7579        if let Ok(o) = Command::new("dscacheutil")
7580            .args(["-cachedump", "-entries", "Host"])
7581            .output()
7582        {
7583            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7584            if !text.is_empty() {
7585                out.push_str("DNS cache (macOS dscacheutil):\n");
7586                for line in text.lines().take(n) {
7587                    let _ = write!(out, "  {line}\n");
7588                }
7589            } else {
7590                out.push_str("DNS cache is empty or not accessible on this platform.\n");
7591            }
7592        } else {
7593            out.push_str(
7594                "DNS cache inspection not available (no resolvectl or dscacheutil found).\n",
7595            );
7596        }
7597    }
7598
7599    Ok(out.trim_end().to_string())
7600}
7601
7602// ── arp ───────────────────────────────────────────────────────────────────────
7603
7604fn inspect_arp() -> Result<String, String> {
7605    let mut out = String::from("Host inspection: arp\n\n");
7606
7607    #[cfg(target_os = "windows")]
7608    {
7609        let output = Command::new("arp")
7610            .args(["-a"])
7611            .output()
7612            .map_err(|e| format!("arp: {e}"))?;
7613        let raw = String::from_utf8_lossy(&output.stdout);
7614        let mut count = 0usize;
7615        for line in raw.lines() {
7616            let t = line.trim();
7617            if t.is_empty() {
7618                continue;
7619            }
7620            let _ = writeln!(out, "  {t}");
7621            if t.contains("dynamic") || t.contains("static") {
7622                count += 1;
7623            }
7624        }
7625        let _ = write!(out, "\nTotal entries: {count}\n");
7626    }
7627
7628    #[cfg(not(target_os = "windows"))]
7629    {
7630        if let Ok(o) = Command::new("arp").args(["-n"]).output() {
7631            let raw = String::from_utf8_lossy(&o.stdout);
7632            let mut count = 0usize;
7633            for line in raw.lines() {
7634                let t = line.trim();
7635                if !t.is_empty() {
7636                    let _ = write!(out, "  {t}\n");
7637                    count += 1;
7638                }
7639            }
7640            let _ = write!(out, "\nTotal entries: {}\n", count.saturating_sub(1));
7641        } else if let Ok(o) = Command::new("ip").args(["neigh"]).output() {
7642            let raw = String::from_utf8_lossy(&o.stdout);
7643            let mut count = 0usize;
7644            for line in raw.lines() {
7645                let t = line.trim();
7646                if !t.is_empty() {
7647                    let _ = write!(out, "  {t}\n");
7648                    count += 1;
7649                }
7650            }
7651            let _ = write!(out, "\nTotal entries: {count}\n");
7652        } else {
7653            out.push_str("arp and ip neigh not available.\n");
7654        }
7655    }
7656
7657    Ok(out.trim_end().to_string())
7658}
7659
7660// ── route_table ───────────────────────────────────────────────────────────────
7661
7662fn inspect_route_table(max_entries: usize) -> Result<String, String> {
7663    let mut out = String::from("Host inspection: route_table\n\n");
7664    let n = max_entries.clamp(10, 50);
7665
7666    #[cfg(target_os = "windows")]
7667    {
7668        let script = r#"
7669try {
7670    $routes = Get-NetRoute -ErrorAction Stop |
7671        Where-Object { $_.RouteMetric -lt 9000 } |
7672        Sort-Object RouteMetric |
7673        Select-Object DestinationPrefix, NextHop, RouteMetric, InterfaceAlias
7674    "TOTAL:" + $routes.Count
7675    $routes | ForEach-Object {
7676        $_.DestinationPrefix + "|" + $_.NextHop + "|" + $_.RouteMetric + "|" + $_.InterfaceAlias
7677    }
7678} catch { "ERROR:" + $_.Exception.Message }
7679"#;
7680        let output = Command::new("powershell")
7681            .args(["-NoProfile", "-Command", script])
7682            .output()
7683            .map_err(|e| format!("route_table: {e}"))?;
7684        let raw = String::from_utf8_lossy(&output.stdout);
7685        let text = raw.trim();
7686
7687        if text.starts_with("ERROR:") {
7688            let _ = writeln!(
7689                out,
7690                "Unable to read route table: {}",
7691                text.trim_start_matches("ERROR:").trim()
7692            );
7693        } else {
7694            let mut shown = 0usize;
7695            for line in text.lines() {
7696                if let Some(rest) = line.strip_prefix("TOTAL:") {
7697                    let total: usize = rest.trim().parse().unwrap_or(0);
7698                    let _ = write!(
7699                        out,
7700                        "Routing table (showing up to {n} of {total} routes):\n\n"
7701                    );
7702                    let _ = writeln!(
7703                        out,
7704                        "  {:<22} {:<18} {:>8}  Interface",
7705                        "Destination", "Next Hop", "Metric"
7706                    );
7707                    let _ = writeln!(out, "  {}", "-".repeat(70));
7708                } else if shown < n {
7709                    let mut it = line.splitn(4, '|');
7710                    if let (Some(dest), Some(p1), Some(metric), Some(iface)) =
7711                        (it.next(), it.next(), it.next(), it.next())
7712                    {
7713                        let hop = if p1.is_empty() || p1 == "0.0.0.0" || p1 == "::" {
7714                            "on-link"
7715                        } else {
7716                            p1
7717                        };
7718                        let _ = writeln!(out, "  {dest:<22} {hop:<18} {metric:>8}  {iface}");
7719                        shown += 1;
7720                    }
7721                }
7722            }
7723        }
7724    }
7725
7726    #[cfg(not(target_os = "windows"))]
7727    {
7728        if let Ok(o) = Command::new("ip").args(["route", "show"]).output() {
7729            let raw = String::from_utf8_lossy(&o.stdout);
7730            let lines: Vec<&str> = raw.lines().collect();
7731            let total = lines.len();
7732            let _ = write!(
7733                out,
7734                "Routing table (showing up to {n} of {total} routes):\n\n"
7735            );
7736            for line in lines.iter().take(n) {
7737                let _ = write!(out, "  {line}\n");
7738            }
7739            if total > n {
7740                let _ = write!(out, "\n  ... and {} more routes\n", total - n);
7741            }
7742        } else if let Ok(o) = Command::new("netstat").args(["-rn"]).output() {
7743            let raw = String::from_utf8_lossy(&o.stdout);
7744            for line in raw.lines().take(n) {
7745                let _ = write!(out, "  {line}\n");
7746            }
7747        } else {
7748            out.push_str("ip route and netstat not available.\n");
7749        }
7750    }
7751
7752    Ok(out.trim_end().to_string())
7753}
7754
7755// ── env ───────────────────────────────────────────────────────────────────────
7756
7757fn inspect_env(max_entries: usize) -> Result<String, String> {
7758    let mut out = String::from("Host inspection: env\n\n");
7759    let n = max_entries.clamp(10, 50);
7760
7761    fn looks_like_secret(name: &str) -> bool {
7762        let n = name.to_uppercase();
7763        n.contains("KEY")
7764            || n.contains("SECRET")
7765            || n.contains("TOKEN")
7766            || n.contains("PASSWORD")
7767            || n.contains("PASSWD")
7768            || n.contains("CREDENTIAL")
7769            || n.contains("AUTH")
7770            || n.contains("CERT")
7771            || n.contains("PRIVATE")
7772    }
7773
7774    let known_dev_vars: &[&str] = &[
7775        "CARGO_HOME",
7776        "RUSTUP_HOME",
7777        "GOPATH",
7778        "GOROOT",
7779        "GOBIN",
7780        "JAVA_HOME",
7781        "ANDROID_HOME",
7782        "ANDROID_SDK_ROOT",
7783        "PYTHONPATH",
7784        "PYTHONHOME",
7785        "VIRTUAL_ENV",
7786        "CONDA_DEFAULT_ENV",
7787        "CONDA_PREFIX",
7788        "NODE_PATH",
7789        "NVM_DIR",
7790        "NVM_BIN",
7791        "PNPM_HOME",
7792        "DENO_INSTALL",
7793        "DENO_DIR",
7794        "DOTNET_ROOT",
7795        "NUGET_PACKAGES",
7796        "CMAKE_HOME",
7797        "VCPKG_ROOT",
7798        "AWS_PROFILE",
7799        "AWS_REGION",
7800        "AWS_DEFAULT_REGION",
7801        "GCP_PROJECT",
7802        "GOOGLE_CLOUD_PROJECT",
7803        "GOOGLE_APPLICATION_CREDENTIALS",
7804        "AZURE_SUBSCRIPTION_ID",
7805        "DATABASE_URL",
7806        "REDIS_URL",
7807        "MONGO_URI",
7808        "EDITOR",
7809        "VISUAL",
7810        "SHELL",
7811        "TERM",
7812        "XDG_CONFIG_HOME",
7813        "XDG_DATA_HOME",
7814        "XDG_CACHE_HOME",
7815        "HOME",
7816        "USERPROFILE",
7817        "APPDATA",
7818        "LOCALAPPDATA",
7819        "TEMP",
7820        "TMP",
7821        "COMPUTERNAME",
7822        "USERNAME",
7823        "USERDOMAIN",
7824        "PROCESSOR_ARCHITECTURE",
7825        "NUMBER_OF_PROCESSORS",
7826        "OS",
7827        "HOMEDRIVE",
7828        "HOMEPATH",
7829        "HTTP_PROXY",
7830        "HTTPS_PROXY",
7831        "NO_PROXY",
7832        "ALL_PROXY",
7833        "http_proxy",
7834        "https_proxy",
7835        "no_proxy",
7836        "DOCKER_HOST",
7837        "DOCKER_BUILDKIT",
7838        "COMPOSE_PROJECT_NAME",
7839        "KUBECONFIG",
7840        "KUBE_CONTEXT",
7841        "CI",
7842        "GITHUB_ACTIONS",
7843        "GITLAB_CI",
7844        "LMSTUDIO_HOME",
7845        "HEMATITE_URL",
7846    ];
7847
7848    let mut all_vars: Vec<(String, String)> = std::env::vars().collect();
7849    all_vars.sort_by(|a, b| a.0.cmp(&b.0));
7850    let total = all_vars.len();
7851
7852    let mut dev_found: Vec<String> = Vec::new();
7853    let mut secret_found: Vec<String> = Vec::new();
7854
7855    for (k, v) in &all_vars {
7856        if k == "PATH" {
7857            continue;
7858        }
7859        if looks_like_secret(k) {
7860            secret_found.push(format!("{k} = [SET, {} chars]", v.len()));
7861        } else {
7862            let k_upper = k.to_uppercase();
7863            let is_known = known_dev_vars
7864                .iter()
7865                .any(|kv| k_upper.as_str() == kv.to_uppercase().as_str());
7866            if is_known {
7867                let display = if v.len() > 120 {
7868                    format!("{k} = {}…", safe_head(v, 117))
7869                } else {
7870                    format!("{k} = {v}")
7871                };
7872                dev_found.push(display);
7873            }
7874        }
7875    }
7876
7877    let _ = write!(out, "Total environment variables: {total}\n\n");
7878
7879    if let Ok(p) = std::env::var("PATH") {
7880        let sep = if cfg!(target_os = "windows") {
7881            ';'
7882        } else {
7883            ':'
7884        };
7885        let count = p.split(sep).count();
7886        let _ = write!(
7887            out,
7888            "PATH: {count} entries (use topic=path for full audit)\n\n"
7889        );
7890    }
7891
7892    if !secret_found.is_empty() {
7893        let _ = writeln!(
7894            out,
7895            "=== Secret/credential variables ({} detected, values hidden) ===",
7896            secret_found.len()
7897        );
7898        for s in secret_found.iter().take(n) {
7899            let _ = writeln!(out, "  {s}");
7900        }
7901        out.push('\n');
7902    }
7903
7904    if !dev_found.is_empty() {
7905        let _ = writeln!(
7906            out,
7907            "=== Developer & tool variables ({}) ===",
7908            dev_found.len()
7909        );
7910        for d in dev_found.iter().take(n) {
7911            let _ = writeln!(out, "  {d}");
7912        }
7913        out.push('\n');
7914    }
7915
7916    let other_count = all_vars
7917        .iter()
7918        .filter(|(k, _)| {
7919            k != "PATH"
7920                && !looks_like_secret(k)
7921                && !known_dev_vars
7922                    .iter()
7923                    .any(|kv| k.to_uppercase().as_str() == kv.to_uppercase().as_str())
7924        })
7925        .count();
7926    if other_count > 0 {
7927        let _ = writeln!(
7928            out,
7929            "Other variables: {other_count} (use 'env' in shell to see all)"
7930        );
7931    }
7932
7933    Ok(out.trim_end().to_string())
7934}
7935
7936// ── hosts_file ────────────────────────────────────────────────────────────────
7937
7938fn inspect_hosts_file() -> Result<String, String> {
7939    let mut out = String::from("Host inspection: hosts_file\n\n");
7940
7941    let hosts_path = if cfg!(target_os = "windows") {
7942        std::path::PathBuf::from(r"C:\Windows\System32\drivers\etc\hosts")
7943    } else {
7944        std::path::PathBuf::from("/etc/hosts")
7945    };
7946
7947    let _ = write!(out, "Path: {}\n\n", hosts_path.display());
7948
7949    match fs::read_to_string(&hosts_path) {
7950        Ok(content) => {
7951            let mut active_entries: Vec<String> = Vec::new();
7952            let mut comment_lines = 0usize;
7953            let mut blank_lines = 0usize;
7954
7955            for line in content.lines() {
7956                let t = line.trim();
7957                if t.is_empty() {
7958                    blank_lines += 1;
7959                } else if t.starts_with('#') {
7960                    comment_lines += 1;
7961                } else {
7962                    active_entries.push(line.to_string());
7963                }
7964            }
7965
7966            let _ = write!(
7967                out,
7968                "Active entries: {}  |  Comment lines: {}  |  Blank lines: {}\n\n",
7969                active_entries.len(),
7970                comment_lines,
7971                blank_lines
7972            );
7973
7974            if active_entries.is_empty() {
7975                out.push_str(
7976                    "No active host entries (file contains only comments/blanks — standard default state).\n",
7977                );
7978            } else {
7979                out.push_str("=== Active entries ===\n");
7980                for entry in &active_entries {
7981                    let _ = writeln!(out, "  {entry}");
7982                }
7983                out.push('\n');
7984
7985                let custom: Vec<&String> = active_entries
7986                    .iter()
7987                    .filter(|e| {
7988                        let t = e.trim_start();
7989                        !t.starts_with("127.") && !t.starts_with("::1") && !t.starts_with("0.0.0.0")
7990                    })
7991                    .collect();
7992                if !custom.is_empty() {
7993                    let _ = writeln!(out, "[!] Custom (non-loopback) entries: {}", custom.len());
7994                    for e in &custom {
7995                        let _ = writeln!(out, "  {e}");
7996                    }
7997                } else {
7998                    out.push_str("All active entries are standard loopback or block entries.\n");
7999                }
8000            }
8001
8002            out.push_str("\n=== Full file ===\n");
8003            for line in content.lines() {
8004                let _ = writeln!(out, "  {line}");
8005            }
8006        }
8007        Err(e) => {
8008            let _ = writeln!(out, "Could not read hosts file: {e}");
8009            if cfg!(target_os = "windows") {
8010                out.push_str(
8011                    "On Windows, run Hematite as Administrator if permission is denied.\n",
8012                );
8013            }
8014        }
8015    }
8016
8017    Ok(out.trim_end().to_string())
8018}
8019
8020// ── docker ────────────────────────────────────────────────────────────────────
8021
8022struct AuditFinding {
8023    finding: String,
8024    impact: String,
8025    fix: String,
8026}
8027
8028#[cfg(target_os = "windows")]
8029#[derive(Debug, Clone)]
8030struct WindowsPnpDevice {
8031    name: String,
8032    status: String,
8033    problem: Option<u64>,
8034    class_name: Option<String>,
8035    instance_id: Option<String>,
8036}
8037
8038#[cfg(target_os = "windows")]
8039#[derive(Debug, Clone)]
8040struct WindowsSoundDevice {
8041    name: String,
8042    status: String,
8043    manufacturer: Option<String>,
8044}
8045
8046struct DockerMountAudit {
8047    mount_type: String,
8048    source: Option<String>,
8049    destination: String,
8050    name: Option<String>,
8051    read_write: Option<bool>,
8052    driver: Option<String>,
8053    exists_on_host: Option<bool>,
8054}
8055
8056struct DockerContainerAudit {
8057    name: String,
8058    image: String,
8059    status: String,
8060    mounts: Vec<DockerMountAudit>,
8061}
8062
8063struct DockerVolumeAudit {
8064    name: String,
8065    driver: String,
8066    mountpoint: Option<String>,
8067    scope: Option<String>,
8068}
8069
8070#[cfg(target_os = "windows")]
8071struct WslDistroAudit {
8072    name: String,
8073    state: String,
8074    version: String,
8075}
8076
8077#[cfg(target_os = "windows")]
8078struct WslRootUsage {
8079    total_kb: u64,
8080    used_kb: u64,
8081    avail_kb: u64,
8082    use_percent: String,
8083    mnt_c_present: Option<bool>,
8084}
8085
8086fn docker_engine_version() -> Result<String, String> {
8087    let version_output = Command::new("docker")
8088        .args(["version", "--format", "{{.Server.Version}}"])
8089        .output();
8090
8091    match version_output {
8092        Err(_) => Err(
8093            "Docker: not found on PATH.\nInstall Docker Desktop: https://www.docker.com/products/docker-desktop".to_string(),
8094        ),
8095        Ok(o) if !o.status.success() => {
8096            let stderr = String::from_utf8_lossy(&o.stderr);
8097            if stderr.contains("cannot connect")
8098                || stderr.contains("Is the docker daemon running")
8099                || stderr.contains("pipe")
8100                || stderr.contains("socket")
8101            {
8102                Err(
8103                    "Docker: installed but daemon is NOT running.\nStart Docker Desktop or run: sudo systemctl start docker".to_string(),
8104                )
8105            } else {
8106                Err(format!("Docker: error - {}", stderr.trim()))
8107            }
8108        }
8109        Ok(o) => Ok(String::from_utf8_lossy(&o.stdout).trim().to_string()),
8110    }
8111}
8112
8113fn parse_docker_mounts(raw: &str) -> Vec<DockerMountAudit> {
8114    let Ok(value) = serde_json::from_str::<Value>(raw.trim()) else {
8115        return Vec::new();
8116    };
8117    let Value::Array(entries) = value else {
8118        return Vec::new();
8119    };
8120
8121    let mut mounts = Vec::with_capacity(entries.len());
8122    for entry in entries {
8123        let mount_type = entry
8124            .get("Type")
8125            .and_then(|v| v.as_str())
8126            .unwrap_or("unknown")
8127            .to_string();
8128        let source = entry
8129            .get("Source")
8130            .and_then(|v| v.as_str())
8131            .map(|v| v.to_string());
8132        let destination = entry
8133            .get("Destination")
8134            .and_then(|v| v.as_str())
8135            .unwrap_or("?")
8136            .to_string();
8137        let name = entry
8138            .get("Name")
8139            .and_then(|v| v.as_str())
8140            .map(|v| v.to_string());
8141        let read_write = entry.get("RW").and_then(|v| v.as_bool());
8142        let driver = entry
8143            .get("Driver")
8144            .and_then(|v| v.as_str())
8145            .map(|v| v.to_string());
8146        let exists_on_host = if mount_type == "bind" {
8147            source.as_deref().map(|path| Path::new(path).exists())
8148        } else {
8149            None
8150        };
8151        mounts.push(DockerMountAudit {
8152            mount_type,
8153            source,
8154            destination,
8155            name,
8156            read_write,
8157            driver,
8158            exists_on_host,
8159        });
8160    }
8161
8162    mounts
8163}
8164
8165fn inspect_docker_volume(name: &str) -> DockerVolumeAudit {
8166    let mut audit = DockerVolumeAudit {
8167        name: name.to_string(),
8168        driver: "unknown".to_string(),
8169        mountpoint: None,
8170        scope: None,
8171    };
8172
8173    if let Ok(output) = Command::new("docker")
8174        .args(["volume", "inspect", name, "--format", "{{json .}}"])
8175        .output()
8176    {
8177        if output.status.success() {
8178            if let Ok(value) =
8179                serde_json::from_str::<Value>(String::from_utf8_lossy(&output.stdout).trim())
8180            {
8181                audit.driver = value
8182                    .get("Driver")
8183                    .and_then(|v| v.as_str())
8184                    .unwrap_or("unknown")
8185                    .to_string();
8186                audit.mountpoint = value
8187                    .get("Mountpoint")
8188                    .and_then(|v| v.as_str())
8189                    .map(|v| v.to_string());
8190                audit.scope = value
8191                    .get("Scope")
8192                    .and_then(|v| v.as_str())
8193                    .map(|v| v.to_string());
8194            }
8195        }
8196    }
8197
8198    audit
8199}
8200
8201#[cfg(target_os = "windows")]
8202fn docker_desktop_disk_image() -> Option<(PathBuf, u64)> {
8203    let local_app_data = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)?;
8204    for file_name in ["docker_data.vhdx", "ext4.vhdx"] {
8205        let path = local_app_data
8206            .join("Docker")
8207            .join("wsl")
8208            .join("disk")
8209            .join(file_name);
8210        if let Ok(metadata) = fs::metadata(&path) {
8211            return Some((path, metadata.len()));
8212        }
8213    }
8214    None
8215}
8216
8217#[cfg(target_os = "windows")]
8218fn clean_wsl_text(raw: &[u8]) -> String {
8219    String::from_utf8_lossy(raw)
8220        .chars()
8221        .filter(|c| *c != '\0')
8222        .collect()
8223}
8224
8225#[cfg(target_os = "windows")]
8226fn parse_wsl_distros(raw: &str) -> Vec<WslDistroAudit> {
8227    let mut distros = Vec::new();
8228    for line in raw.lines() {
8229        let trimmed = line.trim();
8230        if trimmed.is_empty()
8231            || trimmed.to_uppercase().starts_with("NAME")
8232            || trimmed.starts_with("---")
8233        {
8234            continue;
8235        }
8236        let normalized = trimmed.trim_start_matches('*').trim();
8237        let cols: Vec<&str> = normalized.split_whitespace().collect();
8238        if cols.len() < 3 {
8239            continue;
8240        }
8241        let version = cols[cols.len() - 1].to_string();
8242        let state = cols[cols.len() - 2].to_string();
8243        let name = cols[..cols.len() - 2].join(" ");
8244        if !name.is_empty() {
8245            distros.push(WslDistroAudit {
8246                name,
8247                state,
8248                version,
8249            });
8250        }
8251    }
8252    distros
8253}
8254
8255#[cfg(target_os = "windows")]
8256fn wsl_root_usage(distro_name: &str) -> Option<WslRootUsage> {
8257    let output = Command::new("wsl")
8258        .args([
8259            "-d",
8260            distro_name,
8261            "--",
8262            "sh",
8263            "-lc",
8264            "df -k / 2>/dev/null | tail -n 1; if [ -d /mnt/c ]; then echo __MNTC__:ok; else echo __MNTC__:missing; fi",
8265        ])
8266        .output()
8267        .ok()?;
8268    if !output.status.success() {
8269        return None;
8270    }
8271
8272    let text = clean_wsl_text(&output.stdout);
8273    let mut total_kb = 0;
8274    let mut used_kb = 0;
8275    let mut avail_kb = 0;
8276    let mut use_percent = String::from("unknown");
8277    let mut mnt_c_present = None;
8278
8279    for line in text.lines() {
8280        let trimmed = line.trim();
8281        if trimmed.starts_with("__MNTC__:") {
8282            mnt_c_present = Some(trimmed.ends_with("ok"));
8283            continue;
8284        }
8285        let mut it = trimmed.split_whitespace();
8286        if let (Some(_), Some(total), Some(used), Some(avail), Some(pct), Some(_)) = (
8287            it.next(),
8288            it.next(),
8289            it.next(),
8290            it.next(),
8291            it.next(),
8292            it.next(),
8293        ) {
8294            total_kb = total.parse::<u64>().unwrap_or(0);
8295            used_kb = used.parse::<u64>().unwrap_or(0);
8296            avail_kb = avail.parse::<u64>().unwrap_or(0);
8297            use_percent = pct.to_string();
8298        }
8299    }
8300
8301    Some(WslRootUsage {
8302        total_kb,
8303        used_kb,
8304        avail_kb,
8305        use_percent,
8306        mnt_c_present,
8307    })
8308}
8309
8310#[cfg(target_os = "windows")]
8311fn collect_wsl_vhdx_files() -> Vec<(PathBuf, u64)> {
8312    let mut vhds = Vec::new();
8313    let Some(local_app_data) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) else {
8314        return vhds;
8315    };
8316    let packages_dir = local_app_data.join("Packages");
8317    let Ok(entries) = fs::read_dir(packages_dir) else {
8318        return vhds;
8319    };
8320
8321    for entry in entries.flatten() {
8322        let path = entry.path().join("LocalState").join("ext4.vhdx");
8323        if let Ok(metadata) = fs::metadata(&path) {
8324            vhds.push((path, metadata.len()));
8325        }
8326    }
8327    vhds.sort_by_key(|b| std::cmp::Reverse(b.1));
8328    vhds
8329}
8330
8331fn inspect_docker(max_entries: usize) -> Result<String, String> {
8332    let mut out = String::from("Host inspection: docker\n\n");
8333    let n = max_entries.clamp(5, 25);
8334
8335    let version_output = Command::new("docker")
8336        .args(["version", "--format", "{{.Server.Version}}"])
8337        .output();
8338
8339    match version_output {
8340        Err(_) => {
8341            out.push_str("Docker: not found on PATH.\n");
8342            out.push_str(
8343                "Install Docker Desktop: https://www.docker.com/products/docker-desktop\n",
8344            );
8345            return Ok(out.trim_end().to_string());
8346        }
8347        Ok(o) if !o.status.success() => {
8348            let stderr = String::from_utf8_lossy(&o.stderr);
8349            if stderr.contains("cannot connect")
8350                || stderr.contains("Is the docker daemon running")
8351                || stderr.contains("pipe")
8352                || stderr.contains("socket")
8353            {
8354                out.push_str("Docker: installed but daemon is NOT running.\n");
8355                out.push_str("Start Docker Desktop or run: sudo systemctl start docker\n");
8356            } else {
8357                let _ = writeln!(out, "Docker: error — {}", stderr.trim());
8358            }
8359            return Ok(out.trim_end().to_string());
8360        }
8361        Ok(o) => {
8362            let version = String::from_utf8_lossy(&o.stdout).trim().to_string();
8363            let _ = writeln!(out, "Docker Engine: {version}");
8364        }
8365    }
8366
8367    if let Ok(o) = Command::new("docker")
8368        .args([
8369            "info",
8370            "--format",
8371            "Containers: {{.Containers}} (running: {{.ContainersRunning}}, stopped: {{.ContainersStopped}})\nImages: {{.Images}}\nStorage driver: {{.Driver}}\nOS/Arch: {{.OSType}}/{{.Architecture}}\nCPUs: {{.NCPU}}",
8372        ])
8373        .output()
8374    {
8375        let info = String::from_utf8_lossy(&o.stdout);
8376        for line in info.lines() {
8377            let t = line.trim();
8378            if !t.is_empty() {
8379                let _ = writeln!(out, "  {t}");
8380            }
8381        }
8382        out.push('\n');
8383    }
8384
8385    if let Ok(o) = Command::new("docker")
8386        .args([
8387            "ps",
8388            "--format",
8389            "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
8390        ])
8391        .output()
8392    {
8393        let raw = String::from_utf8_lossy(&o.stdout);
8394        let lines: Vec<&str> = raw.lines().collect();
8395        if lines.len() <= 1 {
8396            out.push_str("Running containers: none\n\n");
8397        } else {
8398            let _ = writeln!(
8399                out,
8400                "=== Running containers ({}) ===",
8401                lines.len().saturating_sub(1)
8402            );
8403            for line in lines.iter().take(n + 1) {
8404                let _ = writeln!(out, "  {line}");
8405            }
8406            if lines.len() > n + 1 {
8407                let _ = writeln!(out, "  ... and {} more", lines.len() - n - 1);
8408            }
8409            out.push('\n');
8410        }
8411    }
8412
8413    if let Ok(o) = Command::new("docker")
8414        .args([
8415            "images",
8416            "--format",
8417            "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}",
8418        ])
8419        .output()
8420    {
8421        let raw = String::from_utf8_lossy(&o.stdout);
8422        let lines: Vec<&str> = raw.lines().collect();
8423        if lines.len() > 1 {
8424            let _ = writeln!(
8425                out,
8426                "=== Local images ({}) ===",
8427                lines.len().saturating_sub(1)
8428            );
8429            for line in lines.iter().take(n + 1) {
8430                let _ = writeln!(out, "  {line}");
8431            }
8432            if lines.len() > n + 1 {
8433                let _ = writeln!(out, "  ... and {} more", lines.len() - n - 1);
8434            }
8435            out.push('\n');
8436        }
8437    }
8438
8439    if let Ok(o) = Command::new("docker")
8440        .args([
8441            "compose",
8442            "ls",
8443            "--format",
8444            "table {{.Name}}\t{{.Status}}\t{{.ConfigFiles}}",
8445        ])
8446        .output()
8447    {
8448        let raw = String::from_utf8_lossy(&o.stdout);
8449        let lines: Vec<&str> = raw.lines().collect();
8450        if lines.len() > 1 {
8451            let _ = writeln!(
8452                out,
8453                "=== Compose projects ({}) ===",
8454                lines.len().saturating_sub(1)
8455            );
8456            for line in lines.iter().take(n + 1) {
8457                let _ = writeln!(out, "  {line}");
8458            }
8459            out.push('\n');
8460        }
8461    }
8462
8463    if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
8464        let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
8465        if !ctx.is_empty() {
8466            let _ = writeln!(out, "Active context: {ctx}");
8467        }
8468    }
8469
8470    Ok(out.trim_end().to_string())
8471}
8472
8473// ── wsl ───────────────────────────────────────────────────────────────────────
8474
8475fn inspect_docker_filesystems(max_entries: usize) -> Result<String, String> {
8476    let mut out = String::from("Host inspection: docker_filesystems\n\n");
8477    let n = max_entries.clamp(3, 12);
8478
8479    match docker_engine_version() {
8480        Ok(version) => {
8481            let _ = writeln!(out, "Docker Engine: {version}");
8482        }
8483        Err(message) => {
8484            out.push_str(&message);
8485            return Ok(out.trim_end().to_string());
8486        }
8487    }
8488
8489    if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
8490        let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
8491        if !ctx.is_empty() {
8492            let _ = writeln!(out, "Active context: {ctx}");
8493        }
8494    }
8495    out.push('\n');
8496
8497    let mut containers = Vec::with_capacity(n);
8498    if let Ok(o) = Command::new("docker")
8499        .args([
8500            "ps",
8501            "-a",
8502            "--format",
8503            "{{.Names}}\t{{.Image}}\t{{.Status}}",
8504        ])
8505        .output()
8506    {
8507        for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
8508            let mut it = line.splitn(3, '\t');
8509            let (Some(name_raw), Some(image_raw), Some(status_raw)) =
8510                (it.next(), it.next(), it.next())
8511            else {
8512                continue;
8513            };
8514            let name = name_raw.trim().to_string();
8515            if name.is_empty() {
8516                continue;
8517            }
8518            let inspect_output = Command::new("docker")
8519                .args(["inspect", &name, "--format", "{{json .Mounts}}"])
8520                .output();
8521            let mounts = match inspect_output {
8522                Ok(result) if result.status.success() => {
8523                    parse_docker_mounts(String::from_utf8_lossy(&result.stdout).trim())
8524                }
8525                _ => Vec::new(),
8526            };
8527            containers.push(DockerContainerAudit {
8528                name,
8529                image: image_raw.trim().to_string(),
8530                status: status_raw.trim().to_string(),
8531                mounts,
8532            });
8533        }
8534    }
8535
8536    let mut volumes = Vec::with_capacity(n);
8537    if let Ok(o) = Command::new("docker")
8538        .args(["volume", "ls", "--format", "{{.Name}}\t{{.Driver}}"])
8539        .output()
8540    {
8541        for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
8542            let mut it = line.split('\t');
8543            let Some(name) = it.next().map(|v| v.trim()).filter(|v| !v.is_empty()) else {
8544                continue;
8545            };
8546            let driver_hint = it.next().map(|v| v.trim()).filter(|v| !v.is_empty());
8547            let mut audit = inspect_docker_volume(name);
8548            if audit.driver == "unknown" {
8549                audit.driver = driver_hint.unwrap_or("unknown").to_string();
8550            }
8551            volumes.push(audit);
8552        }
8553    }
8554
8555    let mut findings = Vec::with_capacity(4);
8556    for container in &containers {
8557        for mount in &container.mounts {
8558            if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
8559                let source = mount.source.as_deref().unwrap_or("<unknown>");
8560                findings.push(AuditFinding {
8561                    finding: format!(
8562                        "Container '{}' has a bind mount whose host source is missing: {} -> {}",
8563                        container.name, source, mount.destination
8564                    ),
8565                    impact: "The container may fail to start, or it may see an empty or incomplete directory at the target path.".to_string(),
8566                    fix: "Create the host path or correct the bind source in docker-compose.yml / docker run, then recreate the container.".to_string(),
8567                });
8568            }
8569        }
8570    }
8571
8572    #[cfg(target_os = "windows")]
8573    if let Some((path, size_bytes)) = docker_desktop_disk_image() {
8574        if size_bytes >= 20 * 1024 * 1024 * 1024 {
8575            findings.push(AuditFinding {
8576                finding: format!(
8577                    "Docker Desktop disk image is large: {} at {}",
8578                    human_bytes(size_bytes),
8579                    path.display()
8580                ),
8581                impact: "Unused layers, volumes, and build cache can silently consume Windows disk even after projects are deleted.".to_string(),
8582                fix: "Review `docker system df`, prune unused images, containers, and volumes if safe, then compact the Docker Desktop disk with your normal maintenance workflow.".to_string(),
8583            });
8584        }
8585    }
8586
8587    out.push_str("=== Findings ===\n");
8588    if findings.is_empty() {
8589        out.push_str("- Finding: No missing bind-mount sources or oversized Docker Desktop disk images were detected.\n");
8590        out.push_str("  Impact: The Docker host-side filesystem wiring looks sane from this inspection pass.\n");
8591        out.push_str("  Fix: If a workload still cannot see files, compare the mount destinations below against the app's expected paths.\n");
8592    } else {
8593        for finding in &findings {
8594            let _ = writeln!(out, "- Finding: {}", finding.finding);
8595            let _ = writeln!(out, "  Impact: {}", finding.impact);
8596            let _ = writeln!(out, "  Fix: {}", finding.fix);
8597        }
8598    }
8599
8600    out.push_str("\n=== Container mount summary ===\n");
8601    if containers.is_empty() {
8602        out.push_str("- No containers found.\n");
8603    } else {
8604        for container in &containers {
8605            let _ = writeln!(
8606                out,
8607                "- {} ({}) [{}]",
8608                container.name, container.image, container.status
8609            );
8610            if container.mounts.is_empty() {
8611                out.push_str("  - no mounts reported\n");
8612                continue;
8613            }
8614            for mount in &container.mounts {
8615                let mut source = mount
8616                    .name
8617                    .clone()
8618                    .or_else(|| mount.source.clone())
8619                    .unwrap_or_else(|| "<unknown>".to_string());
8620                if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
8621                    source.push_str(" [missing]");
8622                }
8623                let mut extras = Vec::with_capacity(2);
8624                if let Some(rw) = mount.read_write {
8625                    extras.push(if rw { "rw" } else { "ro" }.to_string());
8626                }
8627                if let Some(driver) = &mount.driver {
8628                    extras.push(format!("driver={driver}"));
8629                }
8630                let extra_suffix = if extras.is_empty() {
8631                    String::new()
8632                } else {
8633                    format!(" ({})", extras.join(", "))
8634                };
8635                let _ = writeln!(
8636                    out,
8637                    "  - {}: {} -> {}{}",
8638                    mount.mount_type, source, mount.destination, extra_suffix
8639                );
8640            }
8641        }
8642    }
8643
8644    out.push_str("\n=== Named volumes ===\n");
8645    if volumes.is_empty() {
8646        out.push_str("- No named volumes found.\n");
8647    } else {
8648        for volume in &volumes {
8649            let mut detail = format!("- {} (driver: {})", volume.name, volume.driver);
8650            if let Some(scope) = &volume.scope {
8651                let _ = write!(detail, ", scope: {scope}");
8652            }
8653            if let Some(mountpoint) = &volume.mountpoint {
8654                let _ = write!(detail, ", mountpoint: {mountpoint}");
8655            }
8656            let _ = writeln!(out, "{detail}");
8657        }
8658    }
8659
8660    #[cfg(target_os = "windows")]
8661    if let Some((path, size_bytes)) = docker_desktop_disk_image() {
8662        out.push_str("\n=== Docker Desktop disk ===\n");
8663        let _ = writeln!(out, "- {} at {}", human_bytes(size_bytes), path.display());
8664    }
8665
8666    Ok(out.trim_end().to_string())
8667}
8668
8669fn inspect_wsl() -> Result<String, String> {
8670    let mut out = String::from("Host inspection: wsl\n\n");
8671
8672    #[cfg(target_os = "windows")]
8673    {
8674        if let Ok(o) = Command::new("wsl").args(["--version"]).output() {
8675            let raw = String::from_utf8_lossy(&o.stdout);
8676            let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8677            for line in cleaned.lines().take(4) {
8678                let t = line.trim();
8679                if !t.is_empty() {
8680                    let _ = writeln!(out, "  {t}");
8681                }
8682            }
8683            out.push('\n');
8684        }
8685
8686        let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8687        match list_output {
8688            Err(e) => {
8689                let _ = writeln!(out, "WSL: wsl.exe error: {e}");
8690                out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8691            }
8692            Ok(o) if !o.status.success() => {
8693                let stderr = String::from_utf8_lossy(&o.stderr);
8694                let cleaned: String = stderr.chars().filter(|c| *c != '\0').collect();
8695                let _ = writeln!(out, "WSL: error — {}", cleaned.trim());
8696                out.push_str("Run: wsl --install\n");
8697            }
8698            Ok(o) => {
8699                let raw = String::from_utf8_lossy(&o.stdout);
8700                let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8701                let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
8702                let distro_lines: Vec<&str> = lines
8703                    .iter()
8704                    .filter(|l| {
8705                        let t = l.trim();
8706                        !t.is_empty()
8707                            && !t.to_uppercase().starts_with("NAME")
8708                            && !t.starts_with("---")
8709                    })
8710                    .copied()
8711                    .collect();
8712
8713                if distro_lines.is_empty() {
8714                    out.push_str("WSL: installed but no distributions found.\n");
8715                    out.push_str("Install a distro: wsl --install -d Ubuntu\n");
8716                } else {
8717                    out.push_str("=== WSL Distributions ===\n");
8718                    for line in &lines {
8719                        let _ = writeln!(out, "  {}", line.trim());
8720                    }
8721                    let _ = write!(out, "\nTotal distributions: {}\n", distro_lines.len());
8722                }
8723            }
8724        }
8725
8726        if let Ok(o) = Command::new("wsl").args(["--status"]).output() {
8727            let raw = String::from_utf8_lossy(&o.stdout);
8728            let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8729            let status_lines: Vec<&str> = cleaned
8730                .lines()
8731                .filter(|l| !l.trim().is_empty())
8732                .take(8)
8733                .collect();
8734            if !status_lines.is_empty() {
8735                out.push_str("\n=== WSL status ===\n");
8736                for line in status_lines {
8737                    let _ = writeln!(out, "  {}", line.trim());
8738                }
8739            }
8740        }
8741    }
8742
8743    #[cfg(not(target_os = "windows"))]
8744    {
8745        out.push_str("WSL (Windows Subsystem for Linux) is a Windows-only feature.\n");
8746        out.push_str("On Linux/macOS, use native virtualization (KVM, UTM, Parallels) instead.\n");
8747    }
8748
8749    Ok(out.trim_end().to_string())
8750}
8751
8752// ── ssh ───────────────────────────────────────────────────────────────────────
8753
8754fn inspect_wsl_filesystems(max_entries: usize) -> Result<String, String> {
8755    let mut out = String::from("Host inspection: wsl_filesystems\n\n");
8756
8757    #[cfg(target_os = "windows")]
8758    {
8759        let n = max_entries.clamp(3, 12);
8760        let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8761        let distros = match list_output {
8762            Err(e) => {
8763                let _ = writeln!(out, "WSL: wsl.exe error: {e}");
8764                out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8765                return Ok(out.trim_end().to_string());
8766            }
8767            Ok(o) if !o.status.success() => {
8768                let cleaned = clean_wsl_text(&o.stderr);
8769                let _ = writeln!(out, "WSL: error - {}", cleaned.trim());
8770                out.push_str("Run: wsl --install\n");
8771                return Ok(out.trim_end().to_string());
8772            }
8773            Ok(o) => parse_wsl_distros(&clean_wsl_text(&o.stdout)),
8774        };
8775
8776        let _ = write!(out, "Distributions detected: {}\n\n", distros.len());
8777
8778        let vhdx_files = collect_wsl_vhdx_files();
8779        let mut findings = Vec::with_capacity(4);
8780        let mut live_usage = Vec::with_capacity(n);
8781
8782        for distro in distros.iter().take(n) {
8783            if distro.state.eq_ignore_ascii_case("Running") {
8784                if let Some(usage) = wsl_root_usage(&distro.name) {
8785                    if let Some(false) = usage.mnt_c_present {
8786                        findings.push(AuditFinding {
8787                            finding: format!(
8788                                "Distro '{}' is running without /mnt/c available",
8789                                distro.name
8790                            ),
8791                            impact: "Windows to WSL path bridging is broken, so projects under C:\\ may not be reachable from Linux tools.".to_string(),
8792                            fix: "Check /etc/wsl.conf automount settings, restart WSL with `wsl --shutdown`, then confirm drvfs automount is enabled.".to_string(),
8793                        });
8794                    }
8795
8796                    let percent_num = usage
8797                        .use_percent
8798                        .trim_end_matches('%')
8799                        .parse::<u32>()
8800                        .unwrap_or(0);
8801                    if percent_num >= 85 {
8802                        findings.push(AuditFinding {
8803                            finding: format!(
8804                                "Distro '{}' root filesystem is {} full",
8805                                distro.name, usage.use_percent
8806                            ),
8807                            impact: "Package installs, git checkouts, and build caches inside WSL can start failing even when Windows still has free space.".to_string(),
8808                            fix: "Free space inside the distro first, then shut WSL down and compact the VHDX if the host-side file stays large.".to_string(),
8809                        });
8810                    }
8811                    live_usage.push((distro.name.clone(), usage));
8812                }
8813            }
8814        }
8815
8816        for (path, size_bytes) in vhdx_files.iter().take(n) {
8817            if *size_bytes >= 20 * 1024 * 1024 * 1024 {
8818                findings.push(AuditFinding {
8819                    finding: format!(
8820                        "Host-side WSL disk image is large: {} at {}",
8821                        human_bytes(*size_bytes),
8822                        path.display()
8823                    ),
8824                    impact: "Sparse VHDX files can keep consuming Windows disk after files are deleted inside the distro.".to_string(),
8825                    fix: "Clean files inside the distro first, then shut WSL down and compact the VHDX with your normal maintenance workflow.".to_string(),
8826                });
8827            }
8828        }
8829
8830        out.push_str("=== Findings ===\n");
8831        if findings.is_empty() {
8832            out.push_str("- Finding: No oversized WSL disk images or broken /mnt/c bridge mounts were detected in the sampled distros.\n");
8833            out.push_str("  Impact: WSL storage and Windows path bridging look healthy from this inspection pass.\n");
8834            out.push_str("  Fix: If a specific project path still fails, inspect the per-distro bridge and disk details below.\n");
8835        } else {
8836            for finding in &findings {
8837                let _ = writeln!(out, "- Finding: {}", finding.finding);
8838                let _ = writeln!(out, "  Impact: {}", finding.impact);
8839                let _ = writeln!(out, "  Fix: {}", finding.fix);
8840            }
8841        }
8842
8843        out.push_str("\n=== Distro bridge and root usage ===\n");
8844        if distros.is_empty() {
8845            out.push_str("- No WSL distributions found.\n");
8846        } else {
8847            for distro in distros.iter().take(n) {
8848                let _ = writeln!(
8849                    out,
8850                    "- {} [state: {}, version: {}]",
8851                    distro.name, distro.state, distro.version
8852                );
8853                if let Some((_, usage)) = live_usage.iter().find(|(name, _)| name == &distro.name) {
8854                    let _ = writeln!(
8855                        out,
8856                        "  - rootfs: {} used / {} total ({}), free: {}",
8857                        human_bytes(usage.used_kb * 1024),
8858                        human_bytes(usage.total_kb * 1024),
8859                        usage.use_percent,
8860                        human_bytes(usage.avail_kb * 1024)
8861                    );
8862                    match usage.mnt_c_present {
8863                        Some(true) => out.push_str("  - /mnt/c bridge: present\n"),
8864                        Some(false) => out.push_str("  - /mnt/c bridge: missing\n"),
8865                        None => out.push_str("  - /mnt/c bridge: unknown\n"),
8866                    }
8867                } else if distro.state.eq_ignore_ascii_case("Running") {
8868                    out.push_str("  - live rootfs check: unavailable\n");
8869                } else {
8870                    out.push_str(
8871                        "  - live rootfs check: skipped to avoid starting a stopped distro\n",
8872                    );
8873                }
8874            }
8875        }
8876
8877        out.push_str("\n=== Host-side VHDX files ===\n");
8878        if vhdx_files.is_empty() {
8879            out.push_str("- No ext4.vhdx files found under %LOCALAPPDATA%\\Packages. Imported distros may live elsewhere.\n");
8880        } else {
8881            for (path, size_bytes) in vhdx_files.iter().take(n) {
8882                let _ = writeln!(out, "- {} at {}", human_bytes(*size_bytes), path.display());
8883            }
8884        }
8885    }
8886
8887    #[cfg(not(target_os = "windows"))]
8888    {
8889        let _ = max_entries;
8890        out.push_str("WSL filesystem auditing is a Windows-only inspection.\n");
8891        out.push_str("On Linux/macOS, use native VM/container storage inspection instead.\n");
8892    }
8893
8894    Ok(out.trim_end().to_string())
8895}
8896
8897fn dirs_home() -> Option<PathBuf> {
8898    std::env::var("HOME")
8899        .ok()
8900        .map(PathBuf::from)
8901        .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
8902}
8903
8904fn inspect_ssh() -> Result<String, String> {
8905    let mut out = String::from("Host inspection: ssh\n\n");
8906
8907    if let Ok(o) = Command::new("ssh").args(["-V"]).output() {
8908        let ver = if o.stdout.is_empty() {
8909            String::from_utf8_lossy(&o.stderr).trim().to_string()
8910        } else {
8911            String::from_utf8_lossy(&o.stdout).trim().to_string()
8912        };
8913        if !ver.is_empty() {
8914            let _ = writeln!(out, "SSH client: {ver}");
8915        }
8916    } else {
8917        out.push_str("SSH client: not found on PATH.\n");
8918    }
8919
8920    #[cfg(target_os = "windows")]
8921    {
8922        let script = r#"
8923$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue
8924if ($svc) { "SSHD:" + $svc.Status + " | StartType:" + $svc.StartType }
8925else { "SSHD:not_installed" }
8926"#;
8927        if let Ok(o) = Command::new("powershell")
8928            .args(["-NoProfile", "-Command", script])
8929            .output()
8930        {
8931            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8932            if text.contains("not_installed") {
8933                out.push_str("SSH server (sshd): not installed\n");
8934            } else {
8935                let _ = writeln!(
8936                    out,
8937                    "SSH server (sshd): {}",
8938                    text.trim_start_matches("SSHD:")
8939                );
8940            }
8941        }
8942    }
8943
8944    #[cfg(not(target_os = "windows"))]
8945    {
8946        if let Ok(o) = Command::new("systemctl")
8947            .args(["is-active", "sshd"])
8948            .output()
8949        {
8950            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8951            let _ = write!(out, "SSH server (sshd): {status}\n");
8952        } else if let Ok(o) = Command::new("systemctl")
8953            .args(["is-active", "ssh"])
8954            .output()
8955        {
8956            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8957            let _ = write!(out, "SSH server (ssh): {status}\n");
8958        }
8959    }
8960
8961    out.push('\n');
8962
8963    if let Some(ssh_dir) = dirs_home().map(|h| h.join(".ssh")) {
8964        if ssh_dir.exists() {
8965            let _ = writeln!(out, "~/.ssh: {}", ssh_dir.display());
8966
8967            let kh = ssh_dir.join("known_hosts");
8968            if kh.exists() {
8969                let count = fs::read_to_string(&kh)
8970                    .map(|c| {
8971                        c.lines()
8972                            .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8973                            .count()
8974                    })
8975                    .unwrap_or(0);
8976                let _ = writeln!(out, "  known_hosts: {count} entries");
8977            } else {
8978                out.push_str("  known_hosts: not present\n");
8979            }
8980
8981            let ak = ssh_dir.join("authorized_keys");
8982            if ak.exists() {
8983                let count = fs::read_to_string(&ak)
8984                    .map(|c| {
8985                        c.lines()
8986                            .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8987                            .count()
8988                    })
8989                    .unwrap_or(0);
8990                let _ = writeln!(out, "  authorized_keys: {count} public keys");
8991            } else {
8992                out.push_str("  authorized_keys: not present\n");
8993            }
8994
8995            let key_names = [
8996                "id_rsa",
8997                "id_ed25519",
8998                "id_ecdsa",
8999                "id_dsa",
9000                "id_ecdsa_sk",
9001                "id_ed25519_sk",
9002            ];
9003            let found_keys: Vec<&str> = key_names
9004                .iter()
9005                .filter(|k| ssh_dir.join(k).exists())
9006                .copied()
9007                .collect();
9008            if !found_keys.is_empty() {
9009                let _ = writeln!(out, "  Private keys: {}", found_keys.join(", "));
9010            } else {
9011                out.push_str("  Private keys: none found\n");
9012            }
9013
9014            let config_path = ssh_dir.join("config");
9015            if config_path.exists() {
9016                out.push_str("\n=== SSH config hosts ===\n");
9017                match fs::read_to_string(&config_path) {
9018                    Ok(content) => {
9019                        let mut hosts: Vec<(String, Vec<String>)> = Vec::new();
9020                        let mut current: Option<(String, Vec<String>)> = None;
9021                        for line in content.lines() {
9022                            let t = line.trim();
9023                            if t.is_empty() || t.starts_with('#') {
9024                                continue;
9025                            }
9026                            if let Some(host) = t.strip_prefix("Host ") {
9027                                if let Some(prev) = current.take() {
9028                                    hosts.push(prev);
9029                                }
9030                                current = Some((host.trim().to_string(), Vec::new()));
9031                            } else if let Some((_, ref mut details)) = current {
9032                                let tu = t.to_uppercase();
9033                                if tu.starts_with("HOSTNAME ")
9034                                    || tu.starts_with("USER ")
9035                                    || tu.starts_with("PORT ")
9036                                    || tu.starts_with("IDENTITYFILE ")
9037                                {
9038                                    details.push(t.to_string());
9039                                }
9040                            }
9041                        }
9042                        if let Some(prev) = current {
9043                            hosts.push(prev);
9044                        }
9045
9046                        if hosts.is_empty() {
9047                            out.push_str("  No Host entries found.\n");
9048                        } else {
9049                            for (h, details) in &hosts {
9050                                if details.is_empty() {
9051                                    let _ = writeln!(out, "  Host {h}");
9052                                } else {
9053                                    let _ = writeln!(out, "  Host {h}  [{}]", details.join(", "));
9054                                }
9055                            }
9056                            let _ = write!(out, "\n  Total configured hosts: {}\n", hosts.len());
9057                        }
9058                    }
9059                    Err(e) => {
9060                        let _ = writeln!(out, "  Could not read config: {e}");
9061                    }
9062                }
9063            } else {
9064                out.push_str("  SSH config: not present\n");
9065            }
9066        } else {
9067            out.push_str("~/.ssh: directory not found (no SSH keys configured).\n");
9068        }
9069    }
9070
9071    Ok(out.trim_end().to_string())
9072}
9073
9074// ── installed_software ────────────────────────────────────────────────────────
9075
9076fn inspect_installed_software(max_entries: usize) -> Result<String, String> {
9077    let mut out = String::from("Host inspection: installed_software\n\n");
9078    let n = max_entries.clamp(10, 50);
9079
9080    #[cfg(target_os = "windows")]
9081    {
9082        let winget_out = Command::new("winget")
9083            .args(["list", "--accept-source-agreements"])
9084            .output();
9085
9086        if let Ok(o) = winget_out {
9087            if o.status.success() {
9088                let raw = String::from_utf8_lossy(&o.stdout);
9089                let mut header_done = false;
9090                let mut packages: Vec<&str> = Vec::new();
9091                for line in raw.lines() {
9092                    let t = line.trim();
9093                    if t.starts_with("---") {
9094                        header_done = true;
9095                        continue;
9096                    }
9097                    if header_done && !t.is_empty() {
9098                        packages.push(line);
9099                    }
9100                }
9101                let total = packages.len();
9102                let _ = write!(
9103                    out,
9104                    "=== Installed software via winget ({total} packages) ===\n\n"
9105                );
9106                for line in packages.iter().take(n) {
9107                    let _ = writeln!(out, "  {line}");
9108                }
9109                if total > n {
9110                    let _ = write!(out, "\n  ... and {} more packages\n", total - n);
9111                }
9112                out.push_str("\nFor full list: winget list\n");
9113                return Ok(out.trim_end().to_string());
9114            }
9115        }
9116
9117        // Fallback: registry scan
9118        let script = format!(
9119            r#"
9120$apps = @()
9121$reg_paths = @(
9122    'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
9123    'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
9124    'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
9125)
9126foreach ($p in $reg_paths) {{
9127    try {{
9128        $apps += Get-ItemProperty $p -ErrorAction SilentlyContinue |
9129            Where-Object {{ $_.DisplayName }} |
9130            Select-Object DisplayName, DisplayVersion, Publisher
9131    }} catch {{}}
9132}}
9133$sorted = $apps | Sort-Object DisplayName -Unique
9134"TOTAL:" + $sorted.Count
9135$sorted | Select-Object -First {n} | ForEach-Object {{
9136    $_.DisplayName + "|" + $_.DisplayVersion + "|" + $_.Publisher
9137}}
9138"#
9139        );
9140        if let Ok(o) = Command::new("powershell")
9141            .args(["-NoProfile", "-Command", &script])
9142            .output()
9143        {
9144            let raw = String::from_utf8_lossy(&o.stdout);
9145            out.push_str("=== Installed software (registry scan) ===\n");
9146            let _ = writeln!(out, "  {:<50} {:<18} Publisher", "Name", "Version");
9147            let _ = writeln!(out, "  {}", "-".repeat(90));
9148            for line in raw.lines() {
9149                if let Some(rest) = line.strip_prefix("TOTAL:") {
9150                    let total: usize = rest.trim().parse().unwrap_or(0);
9151                    let _ = write!(out, "  (Total: {total}, showing first {n})\n\n");
9152                } else if !line.trim().is_empty() {
9153                    let mut it = line.splitn(3, '|');
9154                    let name = it.next().map(str::trim).unwrap_or("");
9155                    let ver = it.next().map(str::trim).unwrap_or("");
9156                    let pub_ = it.next().map(str::trim).unwrap_or("");
9157                    let _ = writeln!(out, "  {:<50} {:<18} {pub_}", name, ver);
9158                }
9159            }
9160        } else {
9161            out.push_str(
9162                "Could not query installed software (winget and registry scan both failed).\n",
9163            );
9164        }
9165    }
9166
9167    #[cfg(target_os = "linux")]
9168    {
9169        let mut found = false;
9170        if let Ok(o) = Command::new("dpkg").args(["--get-selections"]).output() {
9171            if o.status.success() {
9172                let raw = String::from_utf8_lossy(&o.stdout);
9173                let installed: Vec<&str> = raw.lines().filter(|l| l.contains("install")).collect();
9174                let total = installed.len();
9175                let _ = write!(out, "=== Installed packages via dpkg ({total}) ===\n");
9176                for line in installed.iter().take(n) {
9177                    let _ = write!(out, "  {}\n", line.trim());
9178                }
9179                if total > n {
9180                    let _ = write!(out, "  ... and {} more\n", total - n);
9181                }
9182                out.push_str("\nFor full list: dpkg --get-selections | grep install\n");
9183                found = true;
9184            }
9185        }
9186        if !found {
9187            if let Ok(o) = Command::new("rpm")
9188                .args(["-qa", "--queryformat", "%{NAME} %{VERSION}\n"])
9189                .output()
9190            {
9191                if o.status.success() {
9192                    let raw = String::from_utf8_lossy(&o.stdout);
9193                    let lines: Vec<&str> = raw.lines().collect();
9194                    let total = lines.len();
9195                    let _ = write!(out, "=== Installed packages via rpm ({total}) ===\n");
9196                    for line in lines.iter().take(n) {
9197                        let _ = write!(out, "  {line}\n");
9198                    }
9199                    if total > n {
9200                        let _ = write!(out, "  ... and {} more\n", total - n);
9201                    }
9202                    found = true;
9203                }
9204            }
9205        }
9206        if !found {
9207            if let Ok(o) = Command::new("pacman").args(["-Q"]).output() {
9208                if o.status.success() {
9209                    let raw = String::from_utf8_lossy(&o.stdout);
9210                    let lines: Vec<&str> = raw.lines().collect();
9211                    let total = lines.len();
9212                    let _ = write!(out, "=== Installed packages via pacman ({total}) ===\n");
9213                    for line in lines.iter().take(n) {
9214                        let _ = write!(out, "  {line}\n");
9215                    }
9216                    if total > n {
9217                        let _ = write!(out, "  ... and {} more\n", total - n);
9218                    }
9219                    found = true;
9220                }
9221            }
9222        }
9223        if !found {
9224            out.push_str("No package manager found (tried dpkg, rpm, pacman).\n");
9225        }
9226    }
9227
9228    #[cfg(target_os = "macos")]
9229    {
9230        if let Ok(o) = Command::new("brew").args(["list", "--versions"]).output() {
9231            if o.status.success() {
9232                let raw = String::from_utf8_lossy(&o.stdout);
9233                let lines: Vec<&str> = raw.lines().collect();
9234                let total = lines.len();
9235                let _ = write!(out, "=== Homebrew packages ({total}) ===\n");
9236                for line in lines.iter().take(n) {
9237                    let _ = write!(out, "  {line}\n");
9238                }
9239                if total > n {
9240                    let _ = write!(out, "  ... and {} more\n", total - n);
9241                }
9242                out.push_str("\nFor full list: brew list --versions\n");
9243            }
9244        } else {
9245            out.push_str("Homebrew not found.\n");
9246        }
9247        if let Ok(o) = Command::new("mas").args(["list"]).output() {
9248            if o.status.success() {
9249                let raw = String::from_utf8_lossy(&o.stdout);
9250                let lines: Vec<&str> = raw.lines().collect();
9251                let _ = write!(out, "\n=== Mac App Store apps ({}) ===\n", lines.len());
9252                for line in lines.iter().take(n) {
9253                    let _ = write!(out, "  {line}\n");
9254                }
9255            }
9256        }
9257    }
9258
9259    Ok(out.trim_end().to_string())
9260}
9261
9262// ── git_config ────────────────────────────────────────────────────────────────
9263
9264fn inspect_git_config() -> Result<String, String> {
9265    let mut out = String::from("Host inspection: git_config\n\n");
9266
9267    if let Ok(o) = Command::new("git").args(["--version"]).output() {
9268        let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
9269        let _ = write!(out, "Git: {ver}\n\n");
9270    } else {
9271        out.push_str("Git: not found on PATH.\n");
9272        return Ok(out.trim_end().to_string());
9273    }
9274
9275    if let Ok(o) = Command::new("git")
9276        .args(["config", "--global", "--list"])
9277        .output()
9278    {
9279        if o.status.success() {
9280            let raw = String::from_utf8_lossy(&o.stdout);
9281            let mut pairs: Vec<(String, String)> = raw
9282                .lines()
9283                .filter_map(|l| {
9284                    let mut parts = l.splitn(2, '=');
9285                    let k = parts.next()?.trim().to_string();
9286                    let v = parts.next().unwrap_or("").trim().to_string();
9287                    Some((k, v))
9288                })
9289                .collect();
9290            pairs.sort_by(|a, b| a.0.cmp(&b.0));
9291
9292            out.push_str("=== Global git config ===\n");
9293
9294            let sections: &[(&str, &[&str])] = &[
9295                ("Identity", &["user.name", "user.email", "user.signingkey"]),
9296                (
9297                    "Core",
9298                    &[
9299                        "core.editor",
9300                        "core.autocrlf",
9301                        "core.eol",
9302                        "core.ignorecase",
9303                        "core.filemode",
9304                    ],
9305                ),
9306                (
9307                    "Commit/Signing",
9308                    &[
9309                        "commit.gpgsign",
9310                        "tag.gpgsign",
9311                        "gpg.format",
9312                        "gpg.ssh.allowedsignersfile",
9313                    ],
9314                ),
9315                (
9316                    "Push/Pull",
9317                    &[
9318                        "push.default",
9319                        "push.autosetupremote",
9320                        "pull.rebase",
9321                        "pull.ff",
9322                    ],
9323                ),
9324                ("Credential", &["credential.helper"]),
9325                ("Branch", &["init.defaultbranch", "branch.autosetuprebase"]),
9326            ];
9327
9328            let mut shown_keys: HashSet<String> = HashSet::new();
9329            for (section, keys) in sections {
9330                let mut section_lines: Vec<String> = Vec::new();
9331                for key in *keys {
9332                    if let Some((k, v)) = pairs.iter().find(|(kk, _)| kk == key) {
9333                        section_lines.push(format!("  {k} = {v}"));
9334                        shown_keys.insert(k.clone());
9335                    }
9336                }
9337                if !section_lines.is_empty() {
9338                    let _ = write!(out, "\n[{section}]\n");
9339                    for line in section_lines {
9340                        let _ = writeln!(out, "{line}");
9341                    }
9342                }
9343            }
9344
9345            let other: Vec<&(String, String)> = pairs
9346                .iter()
9347                .filter(|(k, _)| !shown_keys.contains(k) && !k.starts_with("alias."))
9348                .collect();
9349            if !other.is_empty() {
9350                out.push_str("\n[Other]\n");
9351                for (k, v) in other.iter().take(20) {
9352                    let _ = writeln!(out, "  {k} = {v}");
9353                }
9354                if other.len() > 20 {
9355                    let _ = writeln!(out, "  ... and {} more", other.len() - 20);
9356                }
9357            }
9358
9359            let _ = write!(out, "\nTotal global config keys: {}\n", pairs.len());
9360        } else {
9361            out.push_str("No global git config found.\n");
9362            out.push_str("Set up with:\n");
9363            out.push_str("  git config --global user.name \"Your Name\"\n");
9364            out.push_str("  git config --global user.email \"you@example.com\"\n");
9365        }
9366    }
9367
9368    if let Ok(o) = Command::new("git")
9369        .args(["config", "--local", "--list"])
9370        .output()
9371    {
9372        if o.status.success() {
9373            let raw = String::from_utf8_lossy(&o.stdout);
9374            let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
9375            if !lines.is_empty() {
9376                let _ = write!(out, "\n=== Local repo config ({} keys) ===\n", lines.len());
9377                for line in lines.iter().take(15) {
9378                    let _ = writeln!(out, "  {line}");
9379                }
9380                if lines.len() > 15 {
9381                    let _ = writeln!(out, "  ... and {} more", lines.len() - 15);
9382                }
9383            }
9384        }
9385    }
9386
9387    if let Ok(o) = Command::new("git")
9388        .args(["config", "--global", "--get-regexp", r"alias\."])
9389        .output()
9390    {
9391        if o.status.success() {
9392            let raw = String::from_utf8_lossy(&o.stdout);
9393            let aliases: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
9394            if !aliases.is_empty() {
9395                let _ = write!(out, "\n=== Git aliases ({}) ===\n", aliases.len());
9396                for a in aliases.iter().take(20) {
9397                    let _ = writeln!(out, "  {a}");
9398                }
9399                if aliases.len() > 20 {
9400                    let _ = writeln!(out, "  ... and {} more", aliases.len() - 20);
9401                }
9402            }
9403        }
9404    }
9405
9406    Ok(out.trim_end().to_string())
9407}
9408
9409// ── databases ─────────────────────────────────────────────────────────────────
9410
9411fn inspect_databases() -> Result<String, String> {
9412    let mut out = String::from("Host inspection: databases\n\n");
9413    out.push_str("Scanning for local database engines (service state, port, version)...\n\n");
9414
9415    struct DbEngine {
9416        name: &'static str,
9417        service_names: &'static [&'static str],
9418        default_port: u16,
9419        cli_name: &'static str,
9420        cli_version_args: &'static [&'static str],
9421    }
9422
9423    let engines: &[DbEngine] = &[
9424        DbEngine {
9425            name: "PostgreSQL",
9426            service_names: &[
9427                "postgresql",
9428                "postgresql-x64-14",
9429                "postgresql-x64-15",
9430                "postgresql-x64-16",
9431                "postgresql-x64-17",
9432            ],
9433
9434            default_port: 5432,
9435            cli_name: "psql",
9436            cli_version_args: &["--version"],
9437        },
9438        DbEngine {
9439            name: "MySQL",
9440            service_names: &["mysql", "mysql80", "mysql57"],
9441
9442            default_port: 3306,
9443            cli_name: "mysql",
9444            cli_version_args: &["--version"],
9445        },
9446        DbEngine {
9447            name: "MariaDB",
9448            service_names: &["mariadb", "mariadb.exe"],
9449
9450            default_port: 3306,
9451            cli_name: "mariadb",
9452            cli_version_args: &["--version"],
9453        },
9454        DbEngine {
9455            name: "MongoDB",
9456            service_names: &["mongodb", "mongod"],
9457
9458            default_port: 27017,
9459            cli_name: "mongod",
9460            cli_version_args: &["--version"],
9461        },
9462        DbEngine {
9463            name: "Redis",
9464            service_names: &["redis", "redis-server"],
9465
9466            default_port: 6379,
9467            cli_name: "redis-server",
9468            cli_version_args: &["--version"],
9469        },
9470        DbEngine {
9471            name: "SQL Server",
9472            service_names: &["mssqlserver", "mssql$sqlexpress", "mssql$localdb"],
9473
9474            default_port: 1433,
9475            cli_name: "sqlcmd",
9476            cli_version_args: &["-?"],
9477        },
9478        DbEngine {
9479            name: "SQLite",
9480            service_names: &[], // no service — file-based
9481
9482            default_port: 0, // no port — file-based
9483            cli_name: "sqlite3",
9484            cli_version_args: &["--version"],
9485        },
9486        DbEngine {
9487            name: "CouchDB",
9488            service_names: &["couchdb", "apache-couchdb"],
9489
9490            default_port: 5984,
9491            cli_name: "couchdb",
9492            cli_version_args: &["--version"],
9493        },
9494        DbEngine {
9495            name: "Cassandra",
9496            service_names: &["cassandra"],
9497
9498            default_port: 9042,
9499            cli_name: "cqlsh",
9500            cli_version_args: &["--version"],
9501        },
9502        DbEngine {
9503            name: "Elasticsearch",
9504            service_names: &["elasticsearch-service-x64", "elasticsearch"],
9505
9506            default_port: 9200,
9507            cli_name: "elasticsearch",
9508            cli_version_args: &["--version"],
9509        },
9510    ];
9511
9512    // Helper: check if port is listening
9513    fn port_listening(port: u16) -> bool {
9514        if port == 0 {
9515            return false;
9516        }
9517        // Use netstat-style check via connecting
9518        std::net::TcpStream::connect_timeout(
9519            &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
9520            std::time::Duration::from_millis(150),
9521        )
9522        .is_ok()
9523    }
9524
9525    let mut found_any = false;
9526
9527    for engine in engines {
9528        let mut status_parts: Vec<String> = Vec::new();
9529        let mut detected = false;
9530
9531        // 1. CLI version check (fastest — works cross-platform)
9532        let version = Command::new(engine.cli_name)
9533            .args(engine.cli_version_args)
9534            .output()
9535            .ok()
9536            .and_then(|o| {
9537                let combined = if o.stdout.is_empty() {
9538                    String::from_utf8_lossy(&o.stderr).trim().to_string()
9539                } else {
9540                    String::from_utf8_lossy(&o.stdout).trim().to_string()
9541                };
9542                // Take just the first line
9543                combined.lines().next().map(|l| l.trim().to_string())
9544            });
9545
9546        if let Some(ref ver) = version {
9547            if !ver.is_empty() {
9548                status_parts.push(format!("version: {ver}"));
9549                detected = true;
9550            }
9551        }
9552
9553        // 2. Port check
9554        if engine.default_port > 0 && port_listening(engine.default_port) {
9555            status_parts.push(format!("listening on :{}", engine.default_port));
9556            detected = true;
9557        } else if engine.default_port > 0 && detected {
9558            status_parts.push(format!("not listening on :{}", engine.default_port));
9559        }
9560
9561        // 3. Windows service check
9562        #[cfg(target_os = "windows")]
9563        {
9564            if !engine.service_names.is_empty() {
9565                let service_list = engine.service_names.join("','");
9566                let script = format!(
9567                    r#"$names = @('{}'); foreach ($n in $names) {{ $s = Get-Service -Name $n -ErrorAction SilentlyContinue; if ($s) {{ $n + ':' + $s.Status; break }} }}"#,
9568                    service_list
9569                );
9570                if let Ok(o) = Command::new("powershell")
9571                    .args(["-NoProfile", "-Command", &script])
9572                    .output()
9573                {
9574                    let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
9575                    if !text.is_empty() {
9576                        let mut it = text.splitn(2, ':');
9577                        let svc_name = it.next().map(str::trim).unwrap_or("");
9578                        let svc_state = it.next().map(str::trim).unwrap_or("unknown");
9579                        status_parts.push(format!("service '{svc_name}': {svc_state}"));
9580                        detected = true;
9581                    }
9582                }
9583            }
9584        }
9585
9586        // 4. Linux/macOS systemctl / launchctl check
9587        #[cfg(not(target_os = "windows"))]
9588        {
9589            for svc in engine.service_names {
9590                if let Ok(o) = Command::new("systemctl").args(["is-active", svc]).output() {
9591                    let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
9592                    if !state.is_empty() && state != "inactive" {
9593                        status_parts.push(format!("systemd '{svc}': {state}"));
9594                        detected = true;
9595                        break;
9596                    }
9597                }
9598            }
9599        }
9600
9601        if detected {
9602            found_any = true;
9603            let label = if engine.default_port > 0 {
9604                format!("{} (default port: {})", engine.name, engine.default_port)
9605            } else {
9606                format!("{} (file-based, no port)", engine.name)
9607            };
9608            let _ = writeln!(out, "[FOUND] {label}");
9609            for part in &status_parts {
9610                let _ = writeln!(out, "  {part}");
9611            }
9612            out.push('\n');
9613        }
9614    }
9615
9616    if !found_any {
9617        out.push_str("No local database engines detected.\n");
9618        out.push_str("(Checked: PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQL Server, SQLite, CouchDB, Cassandra, Elasticsearch)\n\n");
9619        out.push_str(
9620            "Note: databases running inside Docker containers are listed under topic='docker'.\n",
9621        );
9622    } else {
9623        out.push_str("---\n");
9624        out.push_str(
9625            "Note: databases running inside Docker containers are listed under topic='docker'.\n",
9626        );
9627        out.push_str("This topic checks service state and port reachability only — no credentials or queries are used.\n");
9628    }
9629
9630    Ok(out.trim_end().to_string())
9631}
9632
9633// ── user_accounts ─────────────────────────────────────────────────────────────
9634
9635fn inspect_user_accounts(max_entries: usize) -> Result<String, String> {
9636    let mut out = String::from("Host inspection: user_accounts\n\n");
9637
9638    #[cfg(target_os = "windows")]
9639    {
9640        let users_out = Command::new("powershell")
9641            .args([
9642                "-NoProfile", "-NonInteractive", "-Command",
9643                "Get-LocalUser | ForEach-Object { $logon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'never' }; \"  $($_.Name) | Enabled: $($_.Enabled) | LastLogon: $logon | PwdRequired: $($_.PasswordRequired) | $($_.Description)\" }",
9644            ])
9645            .output()
9646            .ok()
9647            .and_then(|o| String::from_utf8(o.stdout).ok())
9648            .unwrap_or_default();
9649
9650        out.push_str("=== Local User Accounts ===\n");
9651        if users_out.trim().is_empty() {
9652            out.push_str("  (requires elevation or Get-LocalUser unavailable)\n");
9653        } else {
9654            for line in users_out.lines().take(max_entries) {
9655                if !line.trim().is_empty() {
9656                    out.push_str(line);
9657                    out.push('\n');
9658                }
9659            }
9660        }
9661
9662        let admins_out = Command::new("powershell")
9663            .args([
9664                "-NoProfile", "-NonInteractive", "-Command",
9665                "Get-LocalGroupMember -Group 'Administrators' 2>$null | ForEach-Object { \"  $($_.ObjectClass): $($_.Name)\" }",
9666            ])
9667            .output()
9668            .ok()
9669            .and_then(|o| String::from_utf8(o.stdout).ok())
9670            .unwrap_or_default();
9671
9672        out.push_str("\n=== Administrators Group Members ===\n");
9673        if admins_out.trim().is_empty() {
9674            out.push_str("  (unable to retrieve)\n");
9675        } else {
9676            out.push_str(admins_out.trim());
9677            out.push('\n');
9678        }
9679
9680        let sessions_out = Command::new("powershell")
9681            .args([
9682                "-NoProfile",
9683                "-NonInteractive",
9684                "-Command",
9685                "query user 2>$null",
9686            ])
9687            .output()
9688            .ok()
9689            .and_then(|o| String::from_utf8(o.stdout).ok())
9690            .unwrap_or_default();
9691
9692        out.push_str("\n=== Active Logon Sessions ===\n");
9693        if sessions_out.trim().is_empty() {
9694            out.push_str("  (none or requires elevation)\n");
9695        } else {
9696            for line in sessions_out.lines().take(max_entries) {
9697                if !line.trim().is_empty() {
9698                    let _ = writeln!(out, "  {}", line);
9699                }
9700            }
9701        }
9702
9703        let is_admin = Command::new("powershell")
9704            .args([
9705                "-NoProfile", "-NonInteractive", "-Command",
9706                "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
9707            ])
9708            .output()
9709            .ok()
9710            .and_then(|o| String::from_utf8(o.stdout).ok())
9711            .map(|s| s.trim().to_lowercase())
9712            .unwrap_or_default();
9713
9714        out.push_str("\n=== Current Session Elevation ===\n");
9715        let _ = writeln!(
9716            out,
9717            "  Running as Administrator: {}",
9718            if is_admin.contains("true") {
9719                "YES"
9720            } else {
9721                "no"
9722            }
9723        );
9724    }
9725
9726    #[cfg(not(target_os = "windows"))]
9727    {
9728        let who_out = Command::new("who")
9729            .output()
9730            .ok()
9731            .and_then(|o| String::from_utf8(o.stdout).ok())
9732            .unwrap_or_default();
9733        out.push_str("=== Active Sessions ===\n");
9734        if who_out.trim().is_empty() {
9735            out.push_str("  (none)\n");
9736        } else {
9737            for line in who_out.lines().take(max_entries) {
9738                let _ = write!(out, "  {}\n", line);
9739            }
9740        }
9741        let id_out = Command::new("id")
9742            .output()
9743            .ok()
9744            .and_then(|o| String::from_utf8(o.stdout).ok())
9745            .unwrap_or_default();
9746        let _ = write!(out, "\n=== Current User ===\n  {}\n", id_out.trim());
9747    }
9748
9749    Ok(out.trim_end().to_string())
9750}
9751
9752// ── audit_policy ──────────────────────────────────────────────────────────────
9753
9754fn inspect_audit_policy() -> Result<String, String> {
9755    let mut out = String::from("Host inspection: audit_policy\n\n");
9756
9757    #[cfg(target_os = "windows")]
9758    {
9759        let auditpol_out = Command::new("auditpol")
9760            .args(["/get", "/category:*"])
9761            .output()
9762            .ok()
9763            .and_then(|o| String::from_utf8(o.stdout).ok())
9764            .unwrap_or_default();
9765
9766        if auditpol_out.trim().is_empty()
9767            || auditpol_out.to_lowercase().contains("access is denied")
9768        {
9769            out.push_str("Audit policy requires Administrator elevation to read.\n");
9770            out.push_str(
9771                "Run Hematite as Administrator, or check manually: auditpol /get /category:*\n",
9772            );
9773        } else {
9774            out.push_str("=== Windows Audit Policy ===\n");
9775            let mut any_enabled = false;
9776            for line in auditpol_out.lines() {
9777                let trimmed = line.trim();
9778                if trimmed.is_empty() {
9779                    continue;
9780                }
9781                if trimmed.contains("Success") || trimmed.contains("Failure") {
9782                    let _ = writeln!(out, "  [ENABLED] {}", trimmed);
9783                    any_enabled = true;
9784                } else {
9785                    let _ = writeln!(out, "  {}", trimmed);
9786                }
9787            }
9788            if !any_enabled {
9789                out.push_str("\n[WARNING] No audit categories are enabled — security events will not be logged.\n");
9790                out.push_str(
9791                    "Minimum recommended: enable Logon/Logoff and Account Logon success+failure.\n",
9792                );
9793            }
9794        }
9795
9796        let evtlog = Command::new("powershell")
9797            .args([
9798                "-NoProfile", "-NonInteractive", "-Command",
9799                "Get-Service EventLog -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Status",
9800            ])
9801            .output()
9802            .ok()
9803            .and_then(|o| String::from_utf8(o.stdout).ok())
9804            .map(|s| s.trim().to_string())
9805            .unwrap_or_default();
9806
9807        let _ = write!(
9808            out,
9809            "\n=== Windows Event Log Service ===\n  Status: {}\n",
9810            if evtlog.is_empty() {
9811                "unknown".to_string()
9812            } else {
9813                evtlog
9814            }
9815        );
9816    }
9817
9818    #[cfg(not(target_os = "windows"))]
9819    {
9820        let auditd_status = Command::new("systemctl")
9821            .args(["is-active", "auditd"])
9822            .output()
9823            .ok()
9824            .and_then(|o| String::from_utf8(o.stdout).ok())
9825            .map(|s| s.trim().to_string())
9826            .unwrap_or_else(|| "not found".to_string());
9827
9828        let _ = write!(out, "=== auditd service ===\n  Status: {}\n", auditd_status);
9829
9830        if auditd_status == "active" {
9831            let rules = Command::new("auditctl")
9832                .args(["-l"])
9833                .output()
9834                .ok()
9835                .and_then(|o| String::from_utf8(o.stdout).ok())
9836                .unwrap_or_default();
9837            out.push_str("\n=== Active Audit Rules ===\n");
9838            if rules.trim().is_empty() || rules.contains("No rules") {
9839                out.push_str("  No rules configured.\n");
9840            } else {
9841                for line in rules.lines() {
9842                    let _ = write!(out, "  {}\n", line);
9843                }
9844            }
9845        }
9846    }
9847
9848    Ok(out.trim_end().to_string())
9849}
9850
9851// ── shares ────────────────────────────────────────────────────────────────────
9852
9853fn inspect_shares(max_entries: usize) -> Result<String, String> {
9854    let mut out = String::from("Host inspection: shares\n\n");
9855
9856    #[cfg(target_os = "windows")]
9857    {
9858        let smb_out = Command::new("powershell")
9859            .args([
9860                "-NoProfile", "-NonInteractive", "-Command",
9861                "Get-SmbShare | ForEach-Object { \"  $($_.Name) | Path: $($_.Path) | State: $($_.ShareState) | Encrypted: $($_.EncryptData) | $($_.Description)\" }",
9862            ])
9863            .output()
9864            .ok()
9865            .and_then(|o| String::from_utf8(o.stdout).ok())
9866            .unwrap_or_default();
9867
9868        out.push_str("=== SMB Shares (exposed by this machine) ===\n");
9869        let smb_lines: Vec<&str> = smb_out
9870            .lines()
9871            .filter(|l| !l.trim().is_empty())
9872            .take(max_entries)
9873            .collect();
9874        if smb_lines.is_empty() {
9875            out.push_str("  No SMB shares or unable to retrieve.\n");
9876        } else {
9877            for line in &smb_lines {
9878                let name = line.trim().split('|').next().unwrap_or("").trim();
9879                if name.ends_with('$') {
9880                    let _ = writeln!(out, "  {}", line.trim());
9881                } else {
9882                    let _ = writeln!(out, "  [CUSTOM] {}", line.trim());
9883                }
9884            }
9885        }
9886
9887        let smb_security = Command::new("powershell")
9888            .args([
9889                "-NoProfile", "-NonInteractive", "-Command",
9890                "Get-SmbServerConfiguration | ForEach-Object { \"  SMB1: $($_.EnableSMB1Protocol) | SMB2: $($_.EnableSMB2Protocol) | Signing Required: $($_.RequireSecuritySignature) | Encryption: $($_.EncryptData)\" }",
9891            ])
9892            .output()
9893            .ok()
9894            .and_then(|o| String::from_utf8(o.stdout).ok())
9895            .unwrap_or_default();
9896
9897        out.push_str("\n=== SMB Server Security Settings ===\n");
9898        if smb_security.trim().is_empty() {
9899            out.push_str("  (unable to retrieve)\n");
9900        } else {
9901            out.push_str(smb_security.trim());
9902            out.push('\n');
9903            if smb_security.to_lowercase().contains("smb1: true") {
9904                out.push_str("  [WARNING] SMB1 is ENABLED — disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\n");
9905            }
9906        }
9907
9908        let drives_out = Command::new("powershell")
9909            .args([
9910                "-NoProfile", "-NonInteractive", "-Command",
9911                "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { \"  $($_.Name): -> $($_.DisplayRoot)\" }",
9912            ])
9913            .output()
9914            .ok()
9915            .and_then(|o| String::from_utf8(o.stdout).ok())
9916            .unwrap_or_default();
9917
9918        out.push_str("\n=== Mapped Network Drives ===\n");
9919        if drives_out.trim().is_empty() {
9920            out.push_str("  None.\n");
9921        } else {
9922            for line in drives_out.lines().take(max_entries) {
9923                if !line.trim().is_empty() {
9924                    out.push_str(line);
9925                    out.push('\n');
9926                }
9927            }
9928        }
9929    }
9930
9931    #[cfg(not(target_os = "windows"))]
9932    {
9933        let smb_conf = std::fs::read_to_string("/etc/samba/smb.conf").unwrap_or_default();
9934        out.push_str("=== Samba Config (/etc/samba/smb.conf) ===\n");
9935        if smb_conf.is_empty() {
9936            out.push_str("  Not found or Samba not installed.\n");
9937        } else {
9938            for line in smb_conf.lines().take(max_entries) {
9939                let _ = write!(out, "  {}\n", line);
9940            }
9941        }
9942        let nfs_exports = std::fs::read_to_string("/etc/exports").unwrap_or_default();
9943        out.push_str("\n=== NFS Exports (/etc/exports) ===\n");
9944        if nfs_exports.is_empty() {
9945            out.push_str("  Not configured.\n");
9946        } else {
9947            for line in nfs_exports.lines().take(max_entries) {
9948                let _ = write!(out, "  {}\n", line);
9949            }
9950        }
9951    }
9952
9953    Ok(out.trim_end().to_string())
9954}
9955
9956// ── dns_servers ───────────────────────────────────────────────────────────────
9957
9958fn inspect_dns_servers() -> Result<String, String> {
9959    let mut out = String::from("Host inspection: dns_servers\n\n");
9960
9961    #[cfg(target_os = "windows")]
9962    {
9963        let dns_out = Command::new("powershell")
9964            .args([
9965                "-NoProfile", "-NonInteractive", "-Command",
9966                "Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses.Count -gt 0 } | ForEach-Object { $addrs = $_.ServerAddresses -join ', '; \"  $($_.InterfaceAlias) (AF $($_.AddressFamily)): $addrs\" }",
9967            ])
9968            .output()
9969            .ok()
9970            .and_then(|o| String::from_utf8(o.stdout).ok())
9971            .unwrap_or_default();
9972
9973        out.push_str("=== Configured DNS Resolvers (per adapter) ===\n");
9974        if dns_out.trim().is_empty() {
9975            out.push_str("  (unable to retrieve)\n");
9976        } else {
9977            for line in dns_out.lines() {
9978                if line.trim().is_empty() {
9979                    continue;
9980                }
9981                let mut annotation = "";
9982                if line.contains("8.8.8.8") || line.contains("8.8.4.4") {
9983                    annotation = "  <- Google Public DNS";
9984                } else if line.contains("1.1.1.1") || line.contains("1.0.0.1") {
9985                    annotation = "  <- Cloudflare DNS";
9986                } else if line.contains("9.9.9.9") {
9987                    annotation = "  <- Quad9";
9988                } else if line.contains("208.67.222") || line.contains("208.67.220") {
9989                    annotation = "  <- OpenDNS";
9990                }
9991                out.push_str(line);
9992                out.push_str(annotation);
9993                out.push('\n');
9994            }
9995        }
9996
9997        let doh_out = Command::new("powershell")
9998            .args([
9999                "-NoProfile", "-NonInteractive", "-Command",
10000                "Get-DnsClientDohServerAddress 2>$null | ForEach-Object { \"  $($_.ServerAddress): $($_.DohTemplate)\" }",
10001            ])
10002            .output()
10003            .ok()
10004            .and_then(|o| String::from_utf8(o.stdout).ok())
10005            .unwrap_or_default();
10006
10007        out.push_str("\n=== DNS over HTTPS (DoH) ===\n");
10008        if doh_out.trim().is_empty() {
10009            out.push_str("  Not configured (plain DNS).\n");
10010        } else {
10011            out.push_str(doh_out.trim());
10012            out.push('\n');
10013        }
10014
10015        let suffixes = Command::new("powershell")
10016            .args([
10017                "-NoProfile", "-NonInteractive", "-Command",
10018                "Get-DnsClientGlobalSetting | Select-Object -ExpandProperty SuffixSearchList | ForEach-Object { \"  $_\" }",
10019            ])
10020            .output()
10021            .ok()
10022            .and_then(|o| String::from_utf8(o.stdout).ok())
10023            .unwrap_or_default();
10024
10025        if !suffixes.trim().is_empty() {
10026            out.push_str("\n=== DNS Search Suffix List ===\n");
10027            out.push_str(suffixes.trim());
10028            out.push('\n');
10029        }
10030    }
10031
10032    #[cfg(not(target_os = "windows"))]
10033    {
10034        let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
10035        out.push_str("=== /etc/resolv.conf ===\n");
10036        if resolv.is_empty() {
10037            out.push_str("  Not found.\n");
10038        } else {
10039            for line in resolv.lines() {
10040                if !line.trim().is_empty() && !line.starts_with('#') {
10041                    let _ = write!(out, "  {}\n", line);
10042                }
10043            }
10044        }
10045        let resolved_out = Command::new("resolvectl")
10046            .args(["status", "--no-pager"])
10047            .output()
10048            .ok()
10049            .and_then(|o| String::from_utf8(o.stdout).ok())
10050            .unwrap_or_default();
10051        if !resolved_out.is_empty() {
10052            out.push_str("\n=== systemd-resolved ===\n");
10053            for line in resolved_out.lines().take(30) {
10054                let _ = write!(out, "  {}\n", line);
10055            }
10056        }
10057    }
10058
10059    Ok(out.trim_end().to_string())
10060}
10061
10062fn inspect_bitlocker() -> Result<String, String> {
10063    let mut out = String::from("Host inspection: bitlocker\n\n");
10064
10065    #[cfg(target_os = "windows")]
10066    {
10067        let ps_cmd = "Get-BitLockerVolume | Select-Object MountPoint, VolumeStatus, ProtectionStatus, EncryptionPercentage | ForEach-Object { \"$($_.MountPoint) [$($_.VolumeStatus)] Protection:$($_.ProtectionStatus) ($($_.EncryptionPercentage)%)\" }";
10068        let output = Command::new("powershell")
10069            .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
10070            .output()
10071            .map_err(|e| format!("Failed to execute PowerShell: {e}"))?;
10072
10073        let stdout = String::from_utf8(output.stdout).unwrap_or_default();
10074        let stderr = String::from_utf8(output.stderr).unwrap_or_default();
10075
10076        if !stdout.trim().is_empty() {
10077            out.push_str("=== BitLocker Volumes ===\n");
10078            for line in stdout.lines() {
10079                let _ = writeln!(out, "  {}", line);
10080            }
10081        } else if !stderr.trim().is_empty() {
10082            if stderr.contains("Access is denied") {
10083                out.push_str("Error: Access denied. BitLocker diagnostics require Administrator elevation.\n");
10084            } else {
10085                let _ = writeln!(out, "Error retrieving BitLocker info: {}", stderr.trim());
10086            }
10087        } else {
10088            out.push_str("No BitLocker volumes detected or access denied.\n");
10089        }
10090    }
10091
10092    #[cfg(not(target_os = "windows"))]
10093    {
10094        out.push_str(
10095            "BitLocker is a Windows-specific technology. Checking for LUKS/dm-crypt...\n\n",
10096        );
10097        let lsblk = Command::new("lsblk")
10098            .args(["-f", "-o", "NAME,FSTYPE,MOUNTPOINT"])
10099            .output()
10100            .ok()
10101            .and_then(|o| String::from_utf8(o.stdout).ok())
10102            .unwrap_or_default();
10103        if lsblk.contains("crypto_LUKS") {
10104            out.push_str("=== LUKS Encrypted Volumes ===\n");
10105            for line in lsblk.lines().filter(|l| l.contains("crypto_LUKS")) {
10106                let _ = write!(out, "  {}\n", line);
10107            }
10108        } else {
10109            out.push_str("No LUKS encrypted volumes detected via lsblk.\n");
10110        }
10111    }
10112
10113    Ok(out.trim_end().to_string())
10114}
10115
10116fn inspect_rdp() -> Result<String, String> {
10117    let mut out = String::from("Host inspection: rdp\n\n");
10118
10119    #[cfg(target_os = "windows")]
10120    {
10121        let reg_path = "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server";
10122        let f_deny = Command::new("powershell")
10123            .args([
10124                "-NoProfile",
10125                "-Command",
10126                &format!("(Get-ItemProperty '{}').fDenyTSConnections", reg_path),
10127            ])
10128            .output()
10129            .ok()
10130            .and_then(|o| String::from_utf8(o.stdout).ok())
10131            .unwrap_or_default()
10132            .trim()
10133            .to_string();
10134
10135        let status = if f_deny == "0" { "ENABLED" } else { "DISABLED" };
10136        let _ = writeln!(out, "=== RDP Status: {} ===", status);
10137
10138        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"])
10139            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default().trim().to_string();
10140        let _ = writeln!(
10141            out,
10142            "  Port: {}",
10143            if port.is_empty() {
10144                "3389 (default)"
10145            } else {
10146                &port
10147            }
10148        );
10149
10150        let nla = Command::new("powershell")
10151            .args([
10152                "-NoProfile",
10153                "-Command",
10154                &format!("(Get-ItemProperty '{}').UserAuthentication", reg_path),
10155            ])
10156            .output()
10157            .ok()
10158            .and_then(|o| String::from_utf8(o.stdout).ok())
10159            .unwrap_or_default()
10160            .trim()
10161            .to_string();
10162        let _ = writeln!(
10163            out,
10164            "  NLA Required: {}",
10165            if nla == "1" { "Yes" } else { "No" }
10166        );
10167
10168        let rdp_tcp_path =
10169            "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp";
10170        let sec_layer = Command::new("powershell")
10171            .args([
10172                "-NoProfile",
10173                "-Command",
10174                &format!("(Get-ItemProperty '{}').SecurityLayer", rdp_tcp_path),
10175            ])
10176            .output()
10177            .ok()
10178            .and_then(|o| String::from_utf8(o.stdout).ok())
10179            .unwrap_or_default()
10180            .trim()
10181            .to_string();
10182        let sec_label = match sec_layer.as_str() {
10183            "0" => "RDP Security (no SSL)",
10184            "1" => "Negotiate (prefer TLS)",
10185            "2" => "SSL/TLS required",
10186            _ => &sec_layer,
10187        };
10188        let _ = writeln!(out, "  Security Layer: {} ({})", sec_layer, sec_label);
10189
10190        let enc_level = Command::new("powershell")
10191            .args([
10192                "-NoProfile",
10193                "-Command",
10194                &format!("(Get-ItemProperty '{}').MinEncryptionLevel", rdp_tcp_path),
10195            ])
10196            .output()
10197            .ok()
10198            .and_then(|o| String::from_utf8(o.stdout).ok())
10199            .unwrap_or_default()
10200            .trim()
10201            .to_string();
10202        let enc_label = match enc_level.as_str() {
10203            "1" => "Low",
10204            "2" => "Client Compatible",
10205            "3" => "High",
10206            "4" => "FIPS Compliant",
10207            _ => "Unknown",
10208        };
10209        let _ = writeln!(out, "  Encryption Level: {} ({})", enc_level, enc_label);
10210
10211        out.push_str("\n=== Active Sessions ===\n");
10212        let qwinsta = Command::new("qwinsta")
10213            .output()
10214            .ok()
10215            .and_then(|o| String::from_utf8(o.stdout).ok())
10216            .unwrap_or_default();
10217        if qwinsta.trim().is_empty() {
10218            out.push_str("  No active sessions listed.\n");
10219        } else {
10220            for line in qwinsta.lines() {
10221                let _ = writeln!(out, "  {}", line);
10222            }
10223        }
10224
10225        out.push_str("\n=== Firewall Rule Check ===\n");
10226        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))\" }"])
10227            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10228        if fw.trim().is_empty() {
10229            out.push_str("  No enabled RDP firewall rules found.\n");
10230        } else {
10231            out.push_str(fw.trim_end());
10232            out.push('\n');
10233        }
10234    }
10235
10236    #[cfg(not(target_os = "windows"))]
10237    {
10238        out.push_str("Checking for common RDP/VNC listeners (3389, 5900-5905)...\n");
10239        let ss = Command::new("ss")
10240            .args(["-tlnp"])
10241            .output()
10242            .ok()
10243            .and_then(|o| String::from_utf8(o.stdout).ok())
10244            .unwrap_or_default();
10245        let matches: Vec<&str> = ss
10246            .lines()
10247            .filter(|l| l.contains(":3389") || l.contains(":590"))
10248            .collect();
10249        if matches.is_empty() {
10250            out.push_str("  No RDP/VNC listeners detected via 'ss'.\n");
10251        } else {
10252            for m in matches {
10253                let _ = write!(out, "  {}\n", m);
10254            }
10255        }
10256    }
10257
10258    Ok(out.trim_end().to_string())
10259}
10260
10261fn inspect_shadow_copies() -> Result<String, String> {
10262    let mut out = String::from("Host inspection: shadow_copies\n\n");
10263
10264    #[cfg(target_os = "windows")]
10265    {
10266        let output = Command::new("vssadmin")
10267            .args(["list", "shadows"])
10268            .output()
10269            .map_err(|e| format!("Failed to run vssadmin: {e}"))?;
10270        let stdout = String::from_utf8(output.stdout).unwrap_or_default();
10271
10272        if stdout.contains("No items found") || stdout.trim().is_empty() {
10273            out.push_str("No Volume Shadow Copies found.\n");
10274        } else {
10275            out.push_str("=== Volume Shadow Copies ===\n");
10276            for line in stdout.lines().take(50) {
10277                if line.contains("Creation Time:")
10278                    || line.contains("Contents:")
10279                    || line.contains("Volume Name:")
10280                {
10281                    let _ = writeln!(out, "  {}", line.trim());
10282                }
10283            }
10284        }
10285
10286        // Most recent snapshot age
10287        let age_script = r#"
10288try {
10289    $snaps = Get-WmiObject Win32_ShadowCopy -ErrorAction SilentlyContinue | Sort-Object InstallDate -Descending
10290    if ($snaps) {
10291        $newest = $snaps[0]
10292        $created = [Management.ManagementDateTimeConverter]::ToDateTime($newest.InstallDate)
10293        $age = [math]::Round(([datetime]::Now - $created).TotalDays, 1)
10294        $count = @($snaps).Count
10295        "Most recent snapshot: $($created.ToString('yyyy-MM-dd HH:mm'))  ($age days ago)  — $count total snapshots"
10296    } else { "No snapshots found via WMI." }
10297} catch { "WMI snapshot query unavailable: $_" }
10298"#;
10299        if let Ok(age_out) = Command::new("powershell")
10300            .args(["-NoProfile", "-Command", age_script])
10301            .output()
10302        {
10303            let age_text = String::from_utf8_lossy(&age_out.stdout).trim().to_string();
10304            if !age_text.is_empty() {
10305                out.push_str("\n=== Snapshot Age ===\n");
10306                let _ = writeln!(out, "  {}", age_text);
10307            }
10308        }
10309
10310        out.push_str("\n=== Shadow Copy Storage ===\n");
10311        let storage_out = Command::new("vssadmin")
10312            .args(["list", "shadowstorage"])
10313            .output()
10314            .ok();
10315        if let Some(o) = storage_out {
10316            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10317            for line in stdout.lines() {
10318                if line.contains("Used Shadow Copy Storage space:")
10319                    || line.contains("Max Shadow Copy Storage space:")
10320                {
10321                    let _ = writeln!(out, "  {}", line.trim());
10322                }
10323            }
10324        }
10325    }
10326
10327    #[cfg(not(target_os = "windows"))]
10328    {
10329        out.push_str("Checking for LVM snapshots or Btrfs subvolumes...\n\n");
10330        let lvs = Command::new("lvs")
10331            .output()
10332            .ok()
10333            .and_then(|o| String::from_utf8(o.stdout).ok())
10334            .unwrap_or_default();
10335        if !lvs.is_empty() {
10336            out.push_str("=== LVM Volumes (checking for snapshots) ===\n");
10337            out.push_str(&lvs);
10338        } else {
10339            out.push_str("No LVM volumes detected.\n");
10340        }
10341    }
10342
10343    Ok(out.trim_end().to_string())
10344}
10345
10346fn inspect_pagefile() -> Result<String, String> {
10347    let mut out = String::from("Host inspection: pagefile\n\n");
10348
10349    #[cfg(target_os = "windows")]
10350    {
10351        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)\" }";
10352        let output = Command::new("powershell")
10353            .args(["-NoProfile", "-Command", ps_cmd])
10354            .output()
10355            .ok()
10356            .and_then(|o| String::from_utf8(o.stdout).ok())
10357            .unwrap_or_default();
10358
10359        if output.trim().is_empty() {
10360            out.push_str("No page files detected (system may be running without a page file or managed differently).\n");
10361            let managed = Command::new("powershell")
10362                .args([
10363                    "-NoProfile",
10364                    "-Command",
10365                    "(Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile",
10366                ])
10367                .output()
10368                .ok()
10369                .and_then(|o| String::from_utf8(o.stdout).ok())
10370                .unwrap_or_default()
10371                .trim()
10372                .to_string();
10373            let _ = writeln!(out, "Automatic Managed Pagefile: {}", managed);
10374        } else {
10375            out.push_str("=== Page File Usage ===\n");
10376            out.push_str(&output);
10377        }
10378    }
10379
10380    #[cfg(not(target_os = "windows"))]
10381    {
10382        out.push_str("=== Swap Usage (Linux/macOS) ===\n");
10383        let swap = Command::new("swapon")
10384            .args(["--show"])
10385            .output()
10386            .ok()
10387            .and_then(|o| String::from_utf8(o.stdout).ok())
10388            .unwrap_or_default();
10389        if swap.is_empty() {
10390            let free = Command::new("free")
10391                .args(["-h"])
10392                .output()
10393                .ok()
10394                .and_then(|o| String::from_utf8(o.stdout).ok())
10395                .unwrap_or_default();
10396            out.push_str(&free);
10397        } else {
10398            out.push_str(&swap);
10399        }
10400    }
10401
10402    Ok(out.trim_end().to_string())
10403}
10404
10405fn inspect_windows_features(max_entries: usize) -> Result<String, String> {
10406    let mut out = String::from("Host inspection: windows_features\n\n");
10407
10408    #[cfg(target_os = "windows")]
10409    {
10410        out.push_str("=== Quick Check: Notable Features ===\n");
10411        let quick_ps = "Get-WindowsOptionalFeature -Online | Where-Object { $_.FeatureName -match 'IIS|Hyper-V|VirtualMachinePlatform|Subsystem-Linux' -and $_.State -eq 'Enabled' } | Select-Object -ExpandProperty FeatureName";
10412        let output = Command::new("powershell")
10413            .args(["-NoProfile", "-Command", quick_ps])
10414            .output()
10415            .ok();
10416
10417        if let Some(o) = output {
10418            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10419            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10420
10421            if !stdout.trim().is_empty() {
10422                for f in stdout.lines() {
10423                    let _ = writeln!(out, "  [ENABLED] {}", f);
10424                }
10425            } else if stderr.contains("Access is denied") || stderr.contains("requires elevation") {
10426                out.push_str("  Error: Access denied. Listing Windows Features requires Administrator elevation.\n");
10427            } else if quick_ps.contains("-Online") && stdout.trim().is_empty() {
10428                out.push_str(
10429                    "  No major features (IIS, Hyper-V, WSL) appear enabled in the quick check.\n",
10430                );
10431            }
10432        }
10433
10434        let _ = write!(
10435            out,
10436            "\n=== All Enabled Features (capped at {}) ===\n",
10437            max_entries
10438        );
10439        let all_ps = format!("Get-WindowsOptionalFeature -Online | Where-Object {{$_.State -eq 'Enabled'}} | Select-Object -First {} -ExpandProperty FeatureName", max_entries);
10440        let all_out = Command::new("powershell")
10441            .args(["-NoProfile", "-Command", &all_ps])
10442            .output()
10443            .ok();
10444        if let Some(o) = all_out {
10445            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10446            if !stdout.trim().is_empty() {
10447                out.push_str(&stdout);
10448            }
10449        }
10450    }
10451
10452    #[cfg(not(target_os = "windows"))]
10453    {
10454        let _ = max_entries;
10455        out.push_str("Windows Optional Features are Windows-specific. On Linux, check your package manager.\n");
10456    }
10457
10458    Ok(out.trim_end().to_string())
10459}
10460
10461fn inspect_audio(max_entries: usize) -> Result<String, String> {
10462    let mut out = String::from("Host inspection: audio\n\n");
10463
10464    #[cfg(target_os = "windows")]
10465    {
10466        let n = max_entries.clamp(5, 20);
10467        let services = collect_services().unwrap_or_default();
10468        let core_service_names = ["Audiosrv", "AudioEndpointBuilder"];
10469        let bluetooth_audio_service_names = ["BthAvctpSvc", "BTAGService"];
10470
10471        let core_services: Vec<&ServiceEntry> = services
10472            .iter()
10473            .filter(|entry| {
10474                core_service_names
10475                    .iter()
10476                    .any(|name| entry.name.eq_ignore_ascii_case(name))
10477            })
10478            .collect();
10479        let bluetooth_audio_services: Vec<&ServiceEntry> = services
10480            .iter()
10481            .filter(|entry| {
10482                bluetooth_audio_service_names
10483                    .iter()
10484                    .any(|name| entry.name.eq_ignore_ascii_case(name))
10485            })
10486            .collect();
10487
10488        let probe_script = r#"
10489$media = @(Get-PnpDevice -Class Media -ErrorAction SilentlyContinue |
10490    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10491$endpoints = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
10492    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10493$sound = @(Get-CimInstance Win32_SoundDevice -ErrorAction SilentlyContinue |
10494    Select-Object Name, Status, Manufacturer, PNPDeviceID)
10495[pscustomobject]@{
10496    Media = $media
10497    Endpoints = $endpoints
10498    SoundDevices = $sound
10499} | ConvertTo-Json -Compress -Depth 4
10500"#;
10501        let probe_raw = Command::new("powershell")
10502            .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
10503            .output()
10504            .ok()
10505            .and_then(|o| String::from_utf8(o.stdout).ok())
10506            .unwrap_or_default();
10507        let probe_loaded = !probe_raw.trim().is_empty();
10508        let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
10509
10510        let endpoints = parse_windows_pnp_devices(probe_value.get("Endpoints"));
10511        let media_devices = parse_windows_pnp_devices(probe_value.get("Media"));
10512        let sound_devices = parse_windows_sound_devices(probe_value.get("SoundDevices"));
10513
10514        let playback_endpoints: Vec<&WindowsPnpDevice> = endpoints
10515            .iter()
10516            .filter(|device| !is_microphone_like_name(&device.name))
10517            .collect();
10518        let recording_endpoints: Vec<&WindowsPnpDevice> = endpoints
10519            .iter()
10520            .filter(|device| is_microphone_like_name(&device.name))
10521            .collect();
10522        let bluetooth_endpoints: Vec<&WindowsPnpDevice> = endpoints
10523            .iter()
10524            .filter(|device| is_bluetooth_like_name(&device.name))
10525            .collect();
10526        let endpoint_problems: Vec<&WindowsPnpDevice> = endpoints
10527            .iter()
10528            .filter(|device| windows_device_has_issue(device))
10529            .collect();
10530        let media_problems: Vec<&WindowsPnpDevice> = media_devices
10531            .iter()
10532            .filter(|device| windows_device_has_issue(device))
10533            .collect();
10534        let sound_problems: Vec<&WindowsSoundDevice> = sound_devices
10535            .iter()
10536            .filter(|device| windows_sound_device_has_issue(device))
10537            .collect();
10538
10539        let mut findings = Vec::with_capacity(4);
10540
10541        let stopped_core_services: Vec<&ServiceEntry> = core_services
10542            .iter()
10543            .copied()
10544            .filter(|service| !service_is_running(service))
10545            .collect();
10546        if !stopped_core_services.is_empty() {
10547            let names = {
10548                let mut s = String::new();
10549                for (i, svc) in stopped_core_services.iter().enumerate() {
10550                    if i > 0 {
10551                        s.push_str(", ");
10552                    }
10553                    s.push_str(&svc.name);
10554                }
10555                s
10556            };
10557            findings.push(AuditFinding {
10558                finding: format!("Core audio services are not running: {names}"),
10559                impact: "Playback and recording devices can vanish or fail even when the hardware is physically present.".to_string(),
10560                fix: "Start Windows Audio (`Audiosrv`) and Windows Audio Endpoint Builder, then recheck the endpoint inventory before reinstalling drivers.".to_string(),
10561            });
10562        }
10563
10564        if probe_loaded
10565            && endpoints.is_empty()
10566            && media_devices.is_empty()
10567            && sound_devices.is_empty()
10568        {
10569            findings.push(AuditFinding {
10570                finding: "No audio endpoints or sound hardware were detected in the Windows device inventory.".to_string(),
10571                impact: "Windows currently has no obvious playback or recording path to hand to apps, so 'no sound' or 'mic missing' behavior is expected.".to_string(),
10572                fix: "Check whether the audio device is disabled in Device Manager, disconnected at the hardware level, or blocked by a vendor driver package that failed to load.".to_string(),
10573            });
10574        }
10575
10576        if !endpoint_problems.is_empty() || !media_problems.is_empty() || !sound_problems.is_empty()
10577        {
10578            let mut problem_labels = Vec::with_capacity(9);
10579            problem_labels.extend(
10580                endpoint_problems
10581                    .iter()
10582                    .take(3)
10583                    .map(|device| device.name.clone()),
10584            );
10585            problem_labels.extend(
10586                media_problems
10587                    .iter()
10588                    .take(3)
10589                    .map(|device| device.name.clone()),
10590            );
10591            problem_labels.extend(
10592                sound_problems
10593                    .iter()
10594                    .take(3)
10595                    .map(|device| device.name.clone()),
10596            );
10597            findings.push(AuditFinding {
10598                finding: format!(
10599                    "Windows reports audio device issues for: {}",
10600                    problem_labels.join(", ")
10601                ),
10602                impact: "Apps can lose speakers, microphones, or headset paths when endpoint or media-class devices are degraded, disabled, or driver-broken.".to_string(),
10603                fix: "Inspect the affected audio devices in Device Manager, confirm the vendor driver is healthy, and re-enable or reinstall the failing endpoint before troubleshooting apps.".to_string(),
10604            });
10605        }
10606
10607        let stopped_bt_audio_services: Vec<&ServiceEntry> = bluetooth_audio_services
10608            .iter()
10609            .copied()
10610            .filter(|service| !service_is_running(service))
10611            .collect();
10612        if !bluetooth_endpoints.is_empty() && !stopped_bt_audio_services.is_empty() {
10613            let names = {
10614                let mut s = String::new();
10615                for (i, svc) in stopped_bt_audio_services.iter().enumerate() {
10616                    if i > 0 {
10617                        s.push_str(", ");
10618                    }
10619                    s.push_str(&svc.name);
10620                }
10621                s
10622            };
10623            findings.push(AuditFinding {
10624                finding: format!(
10625                    "Bluetooth-branded audio endpoints exist, but Bluetooth audio services are not fully running: {names}"
10626                ),
10627                impact: "Headsets may pair yet fail to expose the correct playback or microphone profile, especially after wake or reconnect events.".to_string(),
10628                fix: "Restart the Bluetooth audio services and reconnect the headset before blaming the application layer.".to_string(),
10629            });
10630        }
10631
10632        out.push_str("=== Findings ===\n");
10633        if findings.is_empty() {
10634            out.push_str("- Finding: No obvious Windows audio-service outage or device-inventory failure was detected.\n");
10635            out.push_str("  Impact: Playback and recording look structurally present from this inspection pass.\n");
10636            out.push_str("  Fix: If a specific app still has no sound or mic input, compare the endpoint inventory below against that app's selected input/output devices.\n");
10637        } else {
10638            for finding in &findings {
10639                let _ = writeln!(out, "- Finding: {}", finding.finding);
10640                let _ = writeln!(out, "  Impact: {}", finding.impact);
10641                let _ = writeln!(out, "  Fix: {}", finding.fix);
10642            }
10643        }
10644
10645        out.push_str("\n=== Audio services ===\n");
10646        if core_services.is_empty() && bluetooth_audio_services.is_empty() {
10647            out.push_str(
10648                "- No Windows audio services were retrieved from the service inventory.\n",
10649            );
10650        } else {
10651            for service in core_services.iter().chain(bluetooth_audio_services.iter()) {
10652                let _ = writeln!(
10653                    out,
10654                    "- {} | Status: {} | Startup: {}",
10655                    service.name,
10656                    service.status,
10657                    service.startup.as_deref().unwrap_or("Unknown")
10658                );
10659            }
10660        }
10661
10662        out.push_str("\n=== Playback and recording endpoints ===\n");
10663        if !probe_loaded {
10664            out.push_str("- Windows endpoint inventory probe returned no data.\n");
10665        } else if endpoints.is_empty() {
10666            out.push_str("- No audio endpoints detected.\n");
10667        } else {
10668            let _ = writeln!(
10669                out,
10670                "- Playback-style endpoints: {} | Recording-style endpoints: {}",
10671                playback_endpoints.len(),
10672                recording_endpoints.len()
10673            );
10674            for device in playback_endpoints.iter().take(n) {
10675                let _ = writeln!(
10676                    out,
10677                    "- [PLAYBACK] {} | Status: {}{}",
10678                    device.name,
10679                    device.status,
10680                    device
10681                        .problem
10682                        .filter(|problem| *problem != 0)
10683                        .map(|problem| format!(" | ProblemCode: {problem}"))
10684                        .unwrap_or_default()
10685                );
10686            }
10687            for device in recording_endpoints.iter().take(n) {
10688                let _ = writeln!(
10689                    out,
10690                    "- [MIC] {} | Status: {}{}",
10691                    device.name,
10692                    device.status,
10693                    device
10694                        .problem
10695                        .filter(|problem| *problem != 0)
10696                        .map(|problem| format!(" | ProblemCode: {problem}"))
10697                        .unwrap_or_default()
10698                );
10699            }
10700        }
10701
10702        out.push_str("\n=== Sound hardware devices ===\n");
10703        if sound_devices.is_empty() {
10704            out.push_str("- No Win32_SoundDevice entries were returned.\n");
10705        } else {
10706            for device in sound_devices.iter().take(n) {
10707                let _ = writeln!(
10708                    out,
10709                    "- {} | Status: {}{}",
10710                    device.name,
10711                    device.status,
10712                    device
10713                        .manufacturer
10714                        .as_deref()
10715                        .map(|manufacturer| format!(" | Vendor: {manufacturer}"))
10716                        .unwrap_or_default()
10717                );
10718            }
10719        }
10720
10721        out.push_str("\n=== Media-class device inventory ===\n");
10722        if media_devices.is_empty() {
10723            out.push_str("- No media-class PnP devices were returned.\n");
10724        } else {
10725            for device in media_devices.iter().take(n) {
10726                let _ = writeln!(
10727                    out,
10728                    "- {} | Status: {}{}",
10729                    device.name,
10730                    device.status,
10731                    device
10732                        .class_name
10733                        .as_deref()
10734                        .map(|class_name| format!(" | Class: {class_name}"))
10735                        .unwrap_or_default()
10736                );
10737            }
10738        }
10739    }
10740
10741    #[cfg(not(target_os = "windows"))]
10742    {
10743        let _ = max_entries;
10744        out.push_str(
10745            "Audio inspection currently provides deep endpoint and service coverage on Windows.\n",
10746        );
10747        out.push_str(
10748            "On Linux/macOS, ask narrower questions about PipeWire/PulseAudio/ALSA state if you want a dedicated native audit path added.\n",
10749        );
10750    }
10751
10752    Ok(out.trim_end().to_string())
10753}
10754
10755fn inspect_bluetooth(max_entries: usize) -> Result<String, String> {
10756    let mut out = String::from("Host inspection: bluetooth\n\n");
10757
10758    #[cfg(target_os = "windows")]
10759    {
10760        let n = max_entries.clamp(5, 20);
10761        let services = collect_services().unwrap_or_default();
10762        let bluetooth_services: Vec<&ServiceEntry> = services
10763            .iter()
10764            .filter(|entry| {
10765                entry.name.eq_ignore_ascii_case("bthserv")
10766                    || entry.name.eq_ignore_ascii_case("BthAvctpSvc")
10767                    || entry.name.eq_ignore_ascii_case("BTAGService")
10768                    || entry.name.starts_with("BluetoothUserService")
10769                    || entry
10770                        .display_name
10771                        .as_deref()
10772                        .unwrap_or("")
10773                        .to_ascii_lowercase()
10774                        .contains("bluetooth")
10775            })
10776            .collect();
10777
10778        let probe_script = r#"
10779$radios = @(Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue |
10780    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10781$devices = @(Get-PnpDevice -ErrorAction SilentlyContinue |
10782    Where-Object {
10783        $_.Class -eq 'Bluetooth' -or
10784        $_.FriendlyName -match 'Bluetooth' -or
10785        $_.InstanceId -like 'BTH*'
10786    } |
10787    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10788$audio = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
10789    Where-Object { $_.FriendlyName -match 'Bluetooth|Hands-Free|A2DP' } |
10790    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10791[pscustomobject]@{
10792    Radios = $radios
10793    Devices = $devices
10794    AudioEndpoints = $audio
10795} | ConvertTo-Json -Compress -Depth 4
10796"#;
10797        let probe_raw = Command::new("powershell")
10798            .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
10799            .output()
10800            .ok()
10801            .and_then(|o| String::from_utf8(o.stdout).ok())
10802            .unwrap_or_default();
10803        let probe_loaded = !probe_raw.trim().is_empty();
10804        let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
10805
10806        let radios = parse_windows_pnp_devices(probe_value.get("Radios"));
10807        let devices = parse_windows_pnp_devices(probe_value.get("Devices"));
10808        let audio_endpoints = parse_windows_pnp_devices(probe_value.get("AudioEndpoints"));
10809        let radio_problems: Vec<&WindowsPnpDevice> = radios
10810            .iter()
10811            .filter(|device| windows_device_has_issue(device))
10812            .collect();
10813        let device_problems: Vec<&WindowsPnpDevice> = devices
10814            .iter()
10815            .filter(|device| windows_device_has_issue(device))
10816            .collect();
10817
10818        let mut findings = Vec::with_capacity(4);
10819
10820        if probe_loaded && radios.is_empty() {
10821            findings.push(AuditFinding {
10822                finding: "No Bluetooth radio or adapter was detected in the device inventory.".to_string(),
10823                impact: "Pairing, reconnects, and Bluetooth audio paths cannot work without a healthy local radio.".to_string(),
10824                fix: "Check whether Bluetooth is disabled in firmware, turned off in Windows, or missing its vendor driver.".to_string(),
10825            });
10826        }
10827
10828        let stopped_bluetooth_services: Vec<&ServiceEntry> = bluetooth_services
10829            .iter()
10830            .copied()
10831            .filter(|service| !service_is_running(service))
10832            .collect();
10833        if !stopped_bluetooth_services.is_empty() {
10834            let names = {
10835                let mut s = String::new();
10836                for (i, svc) in stopped_bluetooth_services.iter().enumerate() {
10837                    if i > 0 {
10838                        s.push_str(", ");
10839                    }
10840                    s.push_str(&svc.name);
10841                }
10842                s
10843            };
10844            findings.push(AuditFinding {
10845                finding: format!("Bluetooth-related services are not fully running: {names}"),
10846                impact: "Discovery, pairing, reconnects, and headset profile switching can all fail even when the adapter appears installed.".to_string(),
10847                fix: "Start the Bluetooth Support Service first, then reconnect the device and recheck the adapter and endpoint state.".to_string(),
10848            });
10849        }
10850
10851        if !radio_problems.is_empty() || !device_problems.is_empty() {
10852            let problem_labels = {
10853                let mut s = String::new();
10854                for (i, device) in radio_problems
10855                    .iter()
10856                    .chain(device_problems.iter())
10857                    .take(5)
10858                    .enumerate()
10859                {
10860                    if i > 0 {
10861                        s.push_str(", ");
10862                    }
10863                    s.push_str(&device.name);
10864                }
10865                s
10866            };
10867            findings.push(AuditFinding {
10868                finding: format!("Windows reports Bluetooth device issues for: {problem_labels}"),
10869                impact: "A degraded radio or paired-device node can cause pairing loops, sudden disconnects, or one-way headset behavior.".to_string(),
10870                fix: "Inspect the failing Bluetooth devices in Device Manager, confirm the driver stack is healthy, then remove and re-pair the affected endpoint if needed.".to_string(),
10871            });
10872        }
10873
10874        if !audio_endpoints.is_empty()
10875            && bluetooth_services
10876                .iter()
10877                .any(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10878            && bluetooth_services
10879                .iter()
10880                .filter(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10881                .any(|service| !service_is_running(service))
10882        {
10883            findings.push(AuditFinding {
10884                finding: "Bluetooth audio endpoints exist, but the Bluetooth AVCTP service is not running.".to_string(),
10885                impact: "Headsets can connect yet expose the wrong audio role or lose media controls and microphone availability.".to_string(),
10886                fix: "Restart the AVCTP service and reconnect the headset before troubleshooting the app or conferencing tool.".to_string(),
10887            });
10888        }
10889
10890        out.push_str("=== Findings ===\n");
10891        if findings.is_empty() {
10892            out.push_str("- Finding: No obvious Bluetooth radio, service, or paired-device failure was detected.\n");
10893            out.push_str("  Impact: The Bluetooth stack looks structurally healthy from this inspection pass.\n");
10894            out.push_str("  Fix: If one specific device still fails, focus next on that device's pairing history, driver node, and audio endpoint role.\n");
10895        } else {
10896            for finding in &findings {
10897                let _ = writeln!(out, "- Finding: {}", finding.finding);
10898                let _ = writeln!(out, "  Impact: {}", finding.impact);
10899                let _ = writeln!(out, "  Fix: {}", finding.fix);
10900            }
10901        }
10902
10903        out.push_str("\n=== Bluetooth services ===\n");
10904        if bluetooth_services.is_empty() {
10905            out.push_str(
10906                "- No Bluetooth-related services were retrieved from the service inventory.\n",
10907            );
10908        } else {
10909            for service in bluetooth_services.iter().take(n) {
10910                let _ = writeln!(
10911                    out,
10912                    "- {} | Status: {} | Startup: {}",
10913                    service.name,
10914                    service.status,
10915                    service.startup.as_deref().unwrap_or("Unknown")
10916                );
10917            }
10918        }
10919
10920        out.push_str("\n=== Bluetooth radios and adapters ===\n");
10921        if !probe_loaded {
10922            out.push_str("- Windows Bluetooth adapter inventory probe returned no data.\n");
10923        } else if radios.is_empty() {
10924            out.push_str("- No Bluetooth radios detected.\n");
10925        } else {
10926            for device in radios.iter().take(n) {
10927                let _ = writeln!(
10928                    out,
10929                    "- {} | Status: {}{}",
10930                    device.name,
10931                    device.status,
10932                    device
10933                        .problem
10934                        .filter(|problem| *problem != 0)
10935                        .map(|problem| format!(" | ProblemCode: {problem}"))
10936                        .unwrap_or_default()
10937                );
10938            }
10939        }
10940
10941        out.push_str("\n=== Bluetooth-associated devices ===\n");
10942        if devices.is_empty() {
10943            out.push_str("- No Bluetooth-associated device nodes detected.\n");
10944        } else {
10945            for device in devices.iter().take(n) {
10946                let _ = writeln!(
10947                    out,
10948                    "- {} | Status: {}{}",
10949                    device.name,
10950                    device.status,
10951                    device
10952                        .class_name
10953                        .as_deref()
10954                        .map(|class_name| format!(" | Class: {class_name}"))
10955                        .unwrap_or_default()
10956                );
10957            }
10958        }
10959
10960        out.push_str("\n=== Bluetooth audio endpoints ===\n");
10961        if audio_endpoints.is_empty() {
10962            out.push_str("- No Bluetooth-branded audio endpoints detected.\n");
10963        } else {
10964            for device in audio_endpoints.iter().take(n) {
10965                let _ = writeln!(
10966                    out,
10967                    "- {} | Status: {}{}",
10968                    device.name,
10969                    device.status,
10970                    device
10971                        .instance_id
10972                        .as_deref()
10973                        .map(|instance_id| format!(" | Instance: {instance_id}"))
10974                        .unwrap_or_default()
10975                );
10976            }
10977        }
10978    }
10979
10980    #[cfg(not(target_os = "windows"))]
10981    {
10982        let _ = max_entries;
10983        out.push_str("Bluetooth inspection currently provides deep service and device coverage on Windows.\n");
10984        out.push_str(
10985            "On Linux/macOS, ask a narrower Bluetooth question if you want a dedicated native audit path added.\n",
10986        );
10987    }
10988
10989    Ok(out.trim_end().to_string())
10990}
10991
10992fn inspect_printers(max_entries: usize) -> Result<String, String> {
10993    let mut out = String::from("Host inspection: printers\n\n");
10994
10995    #[cfg(target_os = "windows")]
10996    {
10997        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)])
10998            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10999        if list.trim().is_empty() {
11000            out.push_str("No printers detected.\n");
11001        } else {
11002            out.push_str("=== Installed Printers ===\n");
11003            out.push_str(&list);
11004        }
11005
11006        let jobs = Command::new("powershell").args(["-NoProfile", "-Command", "Get-PrintJob | Select-Object PrinterName, ID, DocumentName, Status | ForEach-Object { \"  [$($_.PrinterName)] Job $($_.ID): $($_.DocumentName) - $($_.Status)\" }"])
11007            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11008        if !jobs.trim().is_empty() {
11009            out.push_str("\n=== Active Print Jobs ===\n");
11010            out.push_str(&jobs);
11011        }
11012    }
11013
11014    #[cfg(not(target_os = "windows"))]
11015    {
11016        let _ = max_entries;
11017        out.push_str("Checking LPSTAT for printers...\n");
11018        let lpstat = Command::new("lpstat")
11019            .args(["-p", "-d"])
11020            .output()
11021            .ok()
11022            .and_then(|o| String::from_utf8(o.stdout).ok())
11023            .unwrap_or_default();
11024        if lpstat.is_empty() {
11025            out.push_str("  No CUPS/LP printers found.\n");
11026        } else {
11027            out.push_str(&lpstat);
11028        }
11029    }
11030
11031    Ok(out.trim_end().to_string())
11032}
11033
11034fn inspect_winrm() -> Result<String, String> {
11035    let mut out = String::from("Host inspection: winrm\n\n");
11036
11037    #[cfg(target_os = "windows")]
11038    {
11039        let svc = Command::new("powershell")
11040            .args(["-NoProfile", "-Command", "(Get-Service WinRM).Status"])
11041            .output()
11042            .ok()
11043            .and_then(|o| String::from_utf8(o.stdout).ok())
11044            .unwrap_or_default()
11045            .trim()
11046            .to_string();
11047        let _ = write!(
11048            out,
11049            "WinRM Service Status: {}\n\n",
11050            if svc.is_empty() { "NOT_FOUND" } else { &svc }
11051        );
11052
11053        out.push_str("=== WinRM Listeners ===\n");
11054        let output = Command::new("powershell")
11055            .args([
11056                "-NoProfile",
11057                "-Command",
11058                "winrm enumerate winrm/config/listener 2>$null",
11059            ])
11060            .output()
11061            .ok();
11062        if let Some(o) = output {
11063            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11064            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
11065
11066            if !stdout.trim().is_empty() {
11067                for line in stdout.lines() {
11068                    if line.contains("Address =")
11069                        || line.contains("Transport =")
11070                        || line.contains("Port =")
11071                    {
11072                        let _ = writeln!(out, "  {}", line.trim());
11073                    }
11074                }
11075            } else if stderr.contains("Access is denied") {
11076                out.push_str("  Error: Access denied to WinRM configuration.\n");
11077            } else {
11078                out.push_str("  No listeners configured.\n");
11079            }
11080        }
11081
11082        out.push_str("\n=== PowerShell Remoting Test (Local) ===\n");
11083        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))\" }"])
11084            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11085        if test_out.trim().is_empty() {
11086            out.push_str("  WinRM not responding to local WS-Man requests.\n");
11087        } else {
11088            out.push_str(&test_out);
11089        }
11090    }
11091
11092    #[cfg(not(target_os = "windows"))]
11093    {
11094        out.push_str(
11095            "WinRM is primarily a Windows technology. Checking for listening port 5985/5986...\n",
11096        );
11097        let ss = Command::new("ss")
11098            .args(["-tln"])
11099            .output()
11100            .ok()
11101            .and_then(|o| String::from_utf8(o.stdout).ok())
11102            .unwrap_or_default();
11103        if ss.contains(":5985") || ss.contains(":5986") {
11104            out.push_str("  WinRM ports (5985/5986) are listening.\n");
11105        } else {
11106            out.push_str("  WinRM ports not detected.\n");
11107        }
11108    }
11109
11110    Ok(out.trim_end().to_string())
11111}
11112
11113fn inspect_network_stats(max_entries: usize) -> Result<String, String> {
11114    let mut out = String::from("Host inspection: network_stats\n\n");
11115
11116    #[cfg(target_os = "windows")]
11117    {
11118        let ps_cmd = format!(
11119            "$s1 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes; \
11120             Start-Sleep -Milliseconds 250; \
11121             $s2 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes, ReceivedPacketErrors, OutboundPacketErrors | Select-Object -First {}; \
11122             $s2 | ForEach-Object {{ \
11123                $name = $_.Name; \
11124                $prev = $s1 | Where-Object {{ $_.Name -eq $name }}; \
11125                if ($prev) {{ \
11126                    $rb = ($_.ReceivedBytes - $prev.ReceivedBytes) / 0.25; \
11127                    $sb = ($_.SentBytes - $prev.SentBytes) / 0.25; \
11128                    $rmbps = [math]::Round(($rb * 8) / 1000000, 2); \
11129                    $smbps = [math]::Round(($sb * 8) / 1000000, 2); \
11130                    $tr = [math]::Round($_.ReceivedBytes / 1MB, 2); \
11131                    $tt = [math]::Round($_.SentBytes / 1MB, 2); \
11132                    \"  $($name): Rate(RX/TX): $($rmbps)/$($smbps) Mbps | Total: $($tr)/$($tt) MB | Errors: $($_.ReceivedPacketErrors)/$($_.OutboundPacketErrors)\" \
11133                }} \
11134             }}",
11135            max_entries
11136        );
11137        let output = Command::new("powershell")
11138            .args(["-NoProfile", "-Command", &ps_cmd])
11139            .output()
11140            .ok()
11141            .and_then(|o| String::from_utf8(o.stdout).ok())
11142            .unwrap_or_default();
11143        if output.trim().is_empty() {
11144            out.push_str("No network adapter statistics available.\n");
11145        } else {
11146            out.push_str("=== Adapter Throughput (Mbps) & Health ===\n");
11147            out.push_str(&output);
11148        }
11149
11150        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)\" } }"])
11151            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11152        if !discards.trim().is_empty() {
11153            out.push_str("\n=== Packet Discards (Non-Zero Only) ===\n");
11154            out.push_str(&discards);
11155        }
11156    }
11157
11158    #[cfg(not(target_os = "windows"))]
11159    {
11160        let _ = max_entries;
11161        out.push_str("=== Network Stats (ip -s link) ===\n");
11162        let ip_s = Command::new("ip")
11163            .args(["-s", "link"])
11164            .output()
11165            .ok()
11166            .and_then(|o| String::from_utf8(o.stdout).ok())
11167            .unwrap_or_default();
11168        if ip_s.is_empty() {
11169            let netstat = Command::new("netstat")
11170                .args(["-i"])
11171                .output()
11172                .ok()
11173                .and_then(|o| String::from_utf8(o.stdout).ok())
11174                .unwrap_or_default();
11175            out.push_str(&netstat);
11176        } else {
11177            out.push_str(&ip_s);
11178        }
11179    }
11180
11181    Ok(out.trim_end().to_string())
11182}
11183
11184fn inspect_udp_ports(max_entries: usize) -> Result<String, String> {
11185    let mut out = String::from("Host inspection: udp_ports\n\n");
11186
11187    #[cfg(target_os = "windows")]
11188    {
11189        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);
11190        let output = Command::new("powershell")
11191            .args(["-NoProfile", "-Command", &ps_cmd])
11192            .output()
11193            .ok();
11194
11195        if let Some(o) = output {
11196            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11197            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
11198
11199            if !stdout.trim().is_empty() {
11200                out.push_str("=== UDP Listeners (Local:Port) ===\n");
11201                for line in stdout.lines() {
11202                    let mut note = "";
11203                    if line.contains(":53 ") {
11204                        note = " [DNS]";
11205                    } else if line.contains(":67 ") || line.contains(":68 ") {
11206                        note = " [DHCP]";
11207                    } else if line.contains(":123 ") {
11208                        note = " [NTP]";
11209                    } else if line.contains(":161 ") {
11210                        note = " [SNMP]";
11211                    } else if line.contains(":1900 ") {
11212                        note = " [SSDP/UPnP]";
11213                    } else if line.contains(":5353 ") {
11214                        note = " [mDNS]";
11215                    }
11216
11217                    let _ = writeln!(out, "{}{}", line, note);
11218                }
11219            } else if stderr.contains("Access is denied") {
11220                out.push_str("Error: Access denied. Full UDP listener details require Administrator elevation.\n");
11221            } else {
11222                out.push_str("No UDP listeners detected.\n");
11223            }
11224        }
11225    }
11226
11227    #[cfg(not(target_os = "windows"))]
11228    {
11229        let ss_out = Command::new("ss")
11230            .args(["-ulnp"])
11231            .output()
11232            .ok()
11233            .and_then(|o| String::from_utf8(o.stdout).ok())
11234            .unwrap_or_default();
11235        out.push_str("=== UDP Listeners (ss -ulnp) ===\n");
11236        if ss_out.is_empty() {
11237            let netstat_out = Command::new("netstat")
11238                .args(["-ulnp"])
11239                .output()
11240                .ok()
11241                .and_then(|o| String::from_utf8(o.stdout).ok())
11242                .unwrap_or_default();
11243            if netstat_out.is_empty() {
11244                out.push_str("  Neither 'ss' nor 'netstat' available.\n");
11245            } else {
11246                for line in netstat_out.lines().take(max_entries) {
11247                    let _ = write!(out, "  {}\n", line);
11248                }
11249            }
11250        } else {
11251            for line in ss_out.lines().take(max_entries) {
11252                let _ = write!(out, "  {}\n", line);
11253            }
11254        }
11255    }
11256
11257    Ok(out.trim_end().to_string())
11258}
11259
11260fn inspect_gpo() -> Result<String, String> {
11261    let mut out = String::from("Host inspection: gpo\n\n");
11262
11263    #[cfg(target_os = "windows")]
11264    {
11265        let output = Command::new("gpresult")
11266            .args(["/r", "/scope", "computer"])
11267            .output()
11268            .ok();
11269
11270        if let Some(o) = output {
11271            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11272            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
11273
11274            if stdout.contains("Applied Group Policy Objects") {
11275                out.push_str("=== Applied Group Policy Objects (Computer Scope) ===\n");
11276                let mut capture = false;
11277                for line in stdout.lines() {
11278                    if line.contains("Applied Group Policy Objects") {
11279                        capture = true;
11280                    } else if capture && line.contains("The following GPOs were not applied") {
11281                        break;
11282                    }
11283                    if capture && !line.trim().is_empty() {
11284                        let _ = writeln!(out, "  {}", line.trim());
11285                    }
11286                }
11287            } else if stderr.contains("Access is denied") || stdout.contains("Access is denied") {
11288                out.push_str("Error: Access denied. Group Policy inspection requires Administrator elevation.\n");
11289            } else {
11290                out.push_str("No applied Group Policy Objects detected or insufficient permissions to query computer scope.\n");
11291            }
11292        }
11293    }
11294
11295    #[cfg(not(target_os = "windows"))]
11296    {
11297        out.push_str("Group Policy (GPO) is a Windows-only topic.\n");
11298    }
11299
11300    Ok(out.trim_end().to_string())
11301}
11302
11303fn inspect_certificates(max_entries: usize) -> Result<String, String> {
11304    let mut out = String::from("Host inspection: certificates\n\n");
11305
11306    #[cfg(target_os = "windows")]
11307    {
11308        let ps_cmd = format!(
11309            "Get-ChildItem -Path Cert:\\LocalMachine\\My | Select-Object Subject, NotAfter, Thumbprint | Select-Object -First {} | ForEach-Object {{ \
11310                $days = ($_.NotAfter - (Get-Date)).Days; \
11311                $status = if ($days -lt 0) {{ \"[EXPIRED]\" }} else if ($days -lt 30) {{ \"[EXPIRING SOON ($days days)]\" }} else {{ \"\" }}; \
11312                \"  $($_.Subject) - Expires: $($_.NotAfter.ToString('yyyy-MM-dd')) $status (Thumb: $($_.Thumbprint.Substring(0,8))...)\" \
11313            }}", 
11314            max_entries
11315        );
11316        let output = Command::new("powershell")
11317            .args(["-NoProfile", "-Command", &ps_cmd])
11318            .output()
11319            .ok();
11320
11321        if let Some(o) = output {
11322            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11323            if !stdout.trim().is_empty() {
11324                out.push_str("=== Local Machine Certificates (Personal Store) ===\n");
11325                out.push_str(&stdout);
11326            } else {
11327                out.push_str("No certificates found in the Local Machine Personal store.\n");
11328            }
11329        }
11330    }
11331
11332    #[cfg(not(target_os = "windows"))]
11333    {
11334        let _ = max_entries;
11335        out.push_str("Host inspection: certificates (Linux/macOS)\n\n");
11336        // Check standard cert locations
11337        for path in ["/etc/ssl/certs", "/etc/pki/tls/certs"] {
11338            if Path::new(path).exists() {
11339                let _ = write!(out, "  Cert directory found: {}\n", path);
11340            }
11341        }
11342    }
11343
11344    Ok(out.trim_end().to_string())
11345}
11346
11347fn inspect_integrity() -> Result<String, String> {
11348    let mut out = String::from("Host inspection: integrity\n\n");
11349
11350    #[cfg(target_os = "windows")]
11351    {
11352        let ps_cmd = "Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing' | Select-Object Corrupt, AutoRepairNeeded, LastRepairAttempted | ConvertTo-Json";
11353        let output = Command::new("powershell")
11354            .args(["-NoProfile", "-Command", ps_cmd])
11355            .output()
11356            .ok();
11357
11358        if let Some(o) = output {
11359            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11360            if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
11361                out.push_str("=== Windows Component Store Health (CBS) ===\n");
11362                let corrupt = val.get("Corrupt").and_then(|v| v.as_u64()).unwrap_or(0);
11363                let repair = val
11364                    .get("AutoRepairNeeded")
11365                    .and_then(|v| v.as_u64())
11366                    .unwrap_or(0);
11367
11368                let _ = writeln!(
11369                    out,
11370                    "  Corruption Detected: {}",
11371                    if corrupt != 0 {
11372                        "YES (SFC/DISM recommended)"
11373                    } else {
11374                        "No"
11375                    }
11376                );
11377                let _ = writeln!(
11378                    out,
11379                    "  Auto-Repair Needed: {}",
11380                    if repair != 0 { "YES" } else { "No" }
11381                );
11382
11383                if let Some(last) = val.get("LastRepairAttempted").and_then(|v| v.as_u64()) {
11384                    let _ = writeln!(out, "  Last Repair Attempt: (Raw code: {})", last);
11385                }
11386            } else {
11387                out.push_str("Could not retrieve CBS health from registry. System may be healthy or state is unknown.\n");
11388            }
11389        }
11390
11391        if Path::new("C:\\Windows\\Logs\\CBS\\CBS.log").exists() {
11392            out.push_str(
11393                "\nNote: Detailed integrity logs available at C:\\Windows\\Logs\\CBS\\CBS.log\n",
11394            );
11395        }
11396    }
11397
11398    #[cfg(not(target_os = "windows"))]
11399    {
11400        out.push_str("System integrity check (Linux)\n\n");
11401        let pkg_check = Command::new("rpm")
11402            .args(["-Va"])
11403            .output()
11404            .or_else(|_| Command::new("dpkg").args(["--verify"]).output())
11405            .ok();
11406        if let Some(o) = pkg_check {
11407            out.push_str("  Package verification system active.\n");
11408            if o.status.success() {
11409                out.push_str("  No major package integrity issues detected.\n");
11410            }
11411        }
11412    }
11413
11414    Ok(out.trim_end().to_string())
11415}
11416
11417fn inspect_domain() -> Result<String, String> {
11418    let mut out = String::from("Host inspection: domain\n\n");
11419
11420    #[cfg(target_os = "windows")]
11421    {
11422        out.push_str("=== Windows Domain / Workgroup Identity ===\n");
11423        let ps_cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Name, Domain, PartOfDomain, Workgroup | ConvertTo-Json";
11424        let output = Command::new("powershell")
11425            .args(["-NoProfile", "-Command", ps_cmd])
11426            .output()
11427            .ok();
11428
11429        if let Some(o) = output {
11430            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11431            if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
11432                let part_of_domain = val
11433                    .get("PartOfDomain")
11434                    .and_then(|v| v.as_bool())
11435                    .unwrap_or(false);
11436                let domain = val
11437                    .get("Domain")
11438                    .and_then(|v| v.as_str())
11439                    .unwrap_or("Unknown");
11440                let workgroup = val
11441                    .get("Workgroup")
11442                    .and_then(|v| v.as_str())
11443                    .unwrap_or("Unknown");
11444
11445                let _ = writeln!(
11446                    out,
11447                    "  Join Status: {}",
11448                    if part_of_domain {
11449                        "DOMAIN JOINED"
11450                    } else {
11451                        "WORKGROUP"
11452                    }
11453                );
11454                if part_of_domain {
11455                    let _ = writeln!(out, "  Active Directory Domain: {}", domain);
11456                } else {
11457                    let _ = writeln!(out, "  Workgroup Name: {}", workgroup);
11458                }
11459
11460                if let Some(name) = val.get("Name").and_then(|v| v.as_str()) {
11461                    let _ = writeln!(out, "  NetBIOS Name: {}", name);
11462                }
11463            } else {
11464                out.push_str("  Domain identity data unavailable from WMI.\n");
11465            }
11466        } else {
11467            out.push_str("  Domain identity data unavailable from WMI.\n");
11468        }
11469    }
11470
11471    #[cfg(not(target_os = "windows"))]
11472    {
11473        let domainname = Command::new("domainname")
11474            .output()
11475            .ok()
11476            .and_then(|o| String::from_utf8(o.stdout).ok())
11477            .unwrap_or_default();
11478        out.push_str("=== Linux Domain Identity ===\n");
11479        if !domainname.trim().is_empty() && domainname.trim() != "(none)" {
11480            let _ = write!(out, "  NIS/YP Domain: {}\n", domainname.trim());
11481        } else {
11482            out.push_str("  No NIS domain configured.\n");
11483        }
11484    }
11485
11486    Ok(out.trim_end().to_string())
11487}
11488
11489fn inspect_device_health() -> Result<String, String> {
11490    let mut out = String::from("Host inspection: device_health\n\n");
11491
11492    #[cfg(target_os = "windows")]
11493    {
11494        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)\" }";
11495        let output = Command::new("powershell")
11496            .args(["-NoProfile", "-Command", ps_cmd])
11497            .output()
11498            .ok()
11499            .and_then(|o| String::from_utf8(o.stdout).ok())
11500            .unwrap_or_default();
11501
11502        if output.trim().is_empty() {
11503            out.push_str("All PnP devices report as healthy (no ConfigManager errors detected).\n");
11504        } else {
11505            out.push_str("=== Malfunctioning Devices (Yellow Bangs) ===\n");
11506            out.push_str(&output);
11507            out.push_str(
11508                "\nTip: Error codes 10 and 28 usually indicate missing or incompatible drivers.\n",
11509            );
11510        }
11511    }
11512
11513    #[cfg(not(target_os = "windows"))]
11514    {
11515        out.push_str("Checking dmesg for hardware errors...\n");
11516        let dmesg = Command::new("dmesg")
11517            .args(["--level=err,crit,alert"])
11518            .output()
11519            .ok()
11520            .and_then(|o| String::from_utf8(o.stdout).ok())
11521            .unwrap_or_default();
11522        if dmesg.is_empty() {
11523            out.push_str("  No critical hardware errors found in dmesg.\n");
11524        } else {
11525            for (i, line) in dmesg.lines().take(20).enumerate() {
11526                if i > 0 {
11527                    out.push('\n');
11528                }
11529                out.push_str(line);
11530            }
11531        }
11532    }
11533
11534    Ok(out.trim_end().to_string())
11535}
11536
11537fn inspect_drivers(max_entries: usize) -> Result<String, String> {
11538    let mut out = String::from("Host inspection: drivers\n\n");
11539
11540    #[cfg(target_os = "windows")]
11541    {
11542        out.push_str("=== Active System Drivers (CIM Snapshot) ===\n");
11543        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);
11544        let output = Command::new("powershell")
11545            .args(["-NoProfile", "-Command", &ps_cmd])
11546            .output()
11547            .ok()
11548            .and_then(|o| String::from_utf8(o.stdout).ok())
11549            .unwrap_or_default();
11550
11551        if output.trim().is_empty() {
11552            out.push_str("  No drivers retrieved via WMI.\n");
11553        } else {
11554            out.push_str(&output);
11555        }
11556    }
11557
11558    #[cfg(not(target_os = "windows"))]
11559    {
11560        out.push_str("=== Loaded Kernel Modules (lsmod) ===\n");
11561        let lsmod = Command::new("lsmod")
11562            .output()
11563            .ok()
11564            .and_then(|o| String::from_utf8(o.stdout).ok())
11565            .unwrap_or_default();
11566        for (i, line) in lsmod.lines().take(max_entries).enumerate() {
11567            if i > 0 {
11568                out.push('\n');
11569            }
11570            out.push_str(line);
11571        }
11572    }
11573
11574    Ok(out.trim_end().to_string())
11575}
11576
11577fn inspect_peripherals(max_entries: usize) -> Result<String, String> {
11578    let mut out = String::from("Host inspection: peripherals\n\n");
11579
11580    #[cfg(target_os = "windows")]
11581    {
11582        let _ = max_entries;
11583        out.push_str("=== USB Controllers & Hubs ===\n");
11584        let usb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_USBController | ForEach-Object { \"  $($_.Name) ($($_.Status))\" }"])
11585            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11586        out.push_str(if usb.is_empty() {
11587            "  None detected.\n"
11588        } else {
11589            &usb
11590        });
11591
11592        out.push_str("\n=== Input Devices (Keyboard/Pointer) ===\n");
11593        let kb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_Keyboard | ForEach-Object { \"  [KB] $($_.Name) ($($_.Status))\" }"])
11594            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11595        let mouse = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_PointingDevice | ForEach-Object { \"  [PTR] $($_.Name) ($($_.Status))\" }"])
11596            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11597        out.push_str(&kb);
11598        out.push_str(&mouse);
11599
11600        out.push_str("\n=== Connected Monitors (WMI) ===\n");
11601        let mon = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams | ForEach-Object { \"  Display ($($_.Active ? 'Active' : 'Inactive'))\" }"])
11602            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11603        out.push_str(if mon.is_empty() {
11604            "  No active monitors identified via WMI.\n"
11605        } else {
11606            &mon
11607        });
11608    }
11609
11610    #[cfg(not(target_os = "windows"))]
11611    {
11612        out.push_str("=== Connected USB Devices (lsusb) ===\n");
11613        let lsusb = Command::new("lsusb")
11614            .output()
11615            .ok()
11616            .and_then(|o| String::from_utf8(o.stdout).ok())
11617            .unwrap_or_default();
11618        for (i, line) in lsusb.lines().take(max_entries).enumerate() {
11619            if i > 0 {
11620                out.push('\n');
11621            }
11622            out.push_str(line);
11623        }
11624    }
11625
11626    Ok(out.trim_end().to_string())
11627}
11628
11629fn inspect_sessions(max_entries: usize) -> Result<String, String> {
11630    let mut out = String::from("Host inspection: sessions\n\n");
11631
11632    #[cfg(target_os = "windows")]
11633    {
11634        out.push_str("=== Active Logon Sessions (WMI Snapshot) ===\n");
11635        let script = r#"Get-CimInstance Win32_LogonSession | ForEach-Object {
11636    "$($_.LogonId)|$($_.StartTime)|$($_.LogonType)|$($_.AuthenticationPackage)"
11637}"#;
11638        if let Ok(o) = Command::new("powershell")
11639            .args(["-NoProfile", "-Command", script])
11640            .output()
11641        {
11642            let text = String::from_utf8_lossy(&o.stdout);
11643            let lines: Vec<&str> = text.lines().collect();
11644            if lines.is_empty() {
11645                out.push_str("  No active logon sessions enumerated via WMI.\n");
11646            } else {
11647                for line in lines
11648                    .iter()
11649                    .take(max_entries)
11650                    .filter(|l| !l.trim().is_empty())
11651                {
11652                    let mut it = line.trim().splitn(5, '|');
11653                    if let (Some(p0), Some(p1), Some(p2), Some(p3)) =
11654                        (it.next(), it.next(), it.next(), it.next())
11655                    {
11656                        let logon_type = match p2 {
11657                            "2" => "Interactive",
11658                            "3" => "Network",
11659                            "4" => "Batch",
11660                            "5" => "Service",
11661                            "7" => "Unlock",
11662                            "8" => "NetworkCleartext",
11663                            "9" => "NewCredentials",
11664                            "10" => "RemoteInteractive",
11665                            "11" => "CachedInteractive",
11666                            _ => "Other",
11667                        };
11668                        let _ = writeln!(
11669                            out,
11670                            "- ID: {} | Type: {} | Started: {} | Auth: {}",
11671                            p0, logon_type, p1, p3
11672                        );
11673                    }
11674                }
11675            }
11676        } else {
11677            out.push_str("  Active logon session data unavailable from WMI.\n");
11678        }
11679    }
11680
11681    #[cfg(not(target_os = "windows"))]
11682    {
11683        out.push_str("=== Logged-in Users (who) ===\n");
11684        let who = Command::new("who")
11685            .output()
11686            .ok()
11687            .and_then(|o| String::from_utf8(o.stdout).ok())
11688            .unwrap_or_default();
11689        for (i, line) in who.lines().take(max_entries).enumerate() {
11690            if i > 0 {
11691                out.push('\n');
11692            }
11693            out.push_str(line);
11694        }
11695    }
11696
11697    Ok(out.trim_end().to_string())
11698}
11699
11700async fn inspect_disk_benchmark(path: PathBuf) -> Result<String, String> {
11701    let mut out = String::from("Host inspection: disk_benchmark\n\n");
11702    let mut final_path = path;
11703
11704    if !final_path.exists() {
11705        if let Ok(current_exe) = std::env::current_exe() {
11706            let _ = writeln!(out,
11707                "Note: Requested target '{}' not found. Falling back to current binary for silicon-aware intensity report.",
11708                final_path.display()
11709            );
11710            final_path = current_exe;
11711        } else {
11712            return Err(format!("Target not found: {}", final_path.display()));
11713        }
11714    }
11715
11716    let target = if final_path.is_dir() {
11717        // Find a representative file to read
11718        let mut target_file = final_path.join("Cargo.toml");
11719        if !target_file.exists() {
11720            target_file = final_path.join("README.md");
11721        }
11722        if !target_file.exists() {
11723            return Err("Target path is a directory but no representative file (Cargo.toml/README.md) found for benchmarking.".to_string());
11724        }
11725        target_file
11726    } else {
11727        final_path
11728    };
11729
11730    let _ = writeln!(out, "Target: {}", target.display());
11731    out.push_str("Running diagnostic stress test (5s read-thrash + kernel counter trace)...\n\n");
11732
11733    #[cfg(target_os = "windows")]
11734    {
11735        let script = format!(
11736            r#"
11737$target = "{}"
11738if (-not (Test-Path $target)) {{ "ERROR:Target not found"; exit }}
11739
11740$diskQueue = @()
11741$readStats = @()
11742$startTime = Get-Date
11743$duration = 5
11744
11745# Background reader job
11746$job = Start-Job -ScriptBlock {{
11747    param($t, $d)
11748    $stop = (Get-Date).AddSeconds($d)
11749    while ((Get-Date) -lt $stop) {{
11750        try {{ [System.IO.File]::ReadAllBytes($t) | Out-Null }} catch {{ }}
11751    }}
11752}} -ArgumentList $target, $duration
11753
11754# Metrics collector loop
11755$stopTime = (Get-Date).AddSeconds($duration)
11756while ((Get-Date) -lt $stopTime) {{
11757    $q = Get-Counter '\PhysicalDisk(_Total)\Avg. Disk Queue Length' -ErrorAction SilentlyContinue
11758    if ($q) {{ $diskQueue += $q.CounterSamples[0].CookedValue }}
11759    
11760    $r = Get-Counter '\PhysicalDisk(_Total)\Disk Reads/sec' -ErrorAction SilentlyContinue
11761    if ($r) {{ $readStats += $r.CounterSamples[0].CookedValue }}
11762    
11763    Start-Sleep -Milliseconds 250
11764}}
11765
11766Stop-Job $job
11767Receive-Job $job | Out-Null
11768Remove-Job $job
11769
11770$avgQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Average).Average }} else {{ 0 }}
11771$maxQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Maximum).Maximum }} else {{ 0 }}
11772$avgR = if ($readStats) {{ ($readStats | Measure-Object -Average).Average }} else {{ 0 }}
11773
11774"AVG_Q:$([math]::Round($avgQ, 4))|MAX_Q:$([math]::Round($maxQ, 4))|AVG_R:$([math]::Round($avgR, 2))"
11775"#,
11776            target.display()
11777        );
11778
11779        let output = Command::new("powershell")
11780            .args(["-NoProfile", "-Command", &script])
11781            .output()
11782            .map_err(|e| format!("Benchmark failed: {e}"))?;
11783
11784        let raw = String::from_utf8_lossy(&output.stdout);
11785        let text = raw.trim();
11786
11787        if text.starts_with("ERROR") {
11788            return Err(text.to_string());
11789        }
11790
11791        let mut lines = text.lines();
11792        if let Some(metrics_line) = lines.next() {
11793            let mut avg_q = "unknown".to_string();
11794            let mut max_q = "unknown".to_string();
11795            let mut avg_r = "unknown".to_string();
11796
11797            for p in metrics_line.split('|') {
11798                if let Some((k, v)) = p.split_once(':') {
11799                    match k {
11800                        "AVG_Q" => avg_q = v.to_string(),
11801                        "MAX_Q" => max_q = v.to_string(),
11802                        "AVG_R" => avg_r = v.to_string(),
11803                        _ => {}
11804                    }
11805                }
11806            }
11807
11808            out.push_str("=== WORKSTATION INTENSITY REPORT ===\n");
11809            let _ = writeln!(out, "- Active Disk Queue (Avg): {}", avg_q);
11810            let _ = writeln!(out, "- Active Disk Queue (Max): {}", max_q);
11811            let _ = writeln!(out, "- Disk Throughput (Avg):  {} reads/sec", avg_r);
11812            out.push_str("\nVerdict: ");
11813            let q_num = avg_q.parse::<f64>().unwrap_or(0.0);
11814            if q_num > 1.0 {
11815                out.push_str(
11816                    "HIGH INTENSITY — the disk stack is saturated. Hardware bottleneck confirmed.",
11817                );
11818            } else if q_num > 0.1 {
11819                out.push_str("MODERATE LOAD — significant I/O pressure detected.");
11820            } else {
11821                out.push_str("LIGHT LOAD — the hardware is handling this volume comfortably.");
11822            }
11823        }
11824    }
11825
11826    #[cfg(not(target_os = "windows"))]
11827    {
11828        out.push_str("Note: Native silicon benchmarking is currently optimized for Windows performance counters.\n");
11829        out.push_str("Generic disk load simulated.\n");
11830    }
11831
11832    Ok(out)
11833}
11834
11835fn inspect_permissions(path: PathBuf, _max_entries: usize) -> Result<String, String> {
11836    let mut out = String::from("Host inspection: permissions\n\n");
11837    let _ = write!(out, "Auditing access control for: {}\n\n", path.display());
11838
11839    #[cfg(target_os = "windows")]
11840    {
11841        let script = format!(
11842            "Get-Acl -Path '{}' | Select-Object Owner, AccessToString | ForEach-Object {{ \"OWNER:$($_.Owner)\"; \"RULES:$($_.AccessToString)\" }}",
11843            path.display()
11844        );
11845        let output = Command::new("powershell")
11846            .args(["-NoProfile", "-Command", &script])
11847            .output()
11848            .map_err(|e| format!("ACL check failed: {e}"))?;
11849
11850        let text = String::from_utf8_lossy(&output.stdout);
11851        if text.trim().is_empty() {
11852            out.push_str("No ACL information returned. Ensure the path exists and you have permission to query it.\n");
11853        } else {
11854            out.push_str("=== Windows NTFS Permissions ===\n");
11855            out.push_str(&text);
11856        }
11857    }
11858
11859    #[cfg(not(target_os = "windows"))]
11860    {
11861        let output = Command::new("ls")
11862            .args(["-ld", &path.to_string_lossy()])
11863            .output()
11864            .map_err(|e| format!("ls check failed: {e}"))?;
11865        out.push_str("=== Unix File Permissions ===\n");
11866        out.push_str(&String::from_utf8_lossy(&output.stdout));
11867    }
11868
11869    Ok(out.trim_end().to_string())
11870}
11871
11872fn inspect_login_history(max_entries: usize) -> Result<String, String> {
11873    let mut out = String::from("Host inspection: login_history\n\n");
11874
11875    #[cfg(target_os = "windows")]
11876    {
11877        out.push_str("Checking recent Logon events (Event ID 4624) from the Security Log...\n");
11878        out.push_str("Note: This typically requires Administrator elevation.\n\n");
11879
11880        let n = max_entries.clamp(1, 50);
11881        let script = format!(
11882            r#"try {{
11883    $events = Get-WinEvent -FilterHashtable @{{LogName='Security'; ID=4624}} -MaxEvents {n} -ErrorAction Stop
11884    $events | ForEach-Object {{
11885        $time = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm')
11886        # Extract target user name from the XML/Properties if possible
11887        $user = $_.Properties[5].Value
11888        $type = $_.Properties[8].Value
11889        "[$time] User: $user | Type: $type"
11890    }}
11891}} catch {{ "ERROR:" + $_.Exception.Message }}"#
11892        );
11893
11894        let output = Command::new("powershell")
11895            .args(["-NoProfile", "-Command", &script])
11896            .output()
11897            .map_err(|e| format!("Login history query failed: {e}"))?;
11898
11899        let text = String::from_utf8_lossy(&output.stdout);
11900        if text.starts_with("ERROR:") {
11901            let _ = writeln!(out, "Unable to query Security Log: {}", text);
11902        } else if text.trim().is_empty() {
11903            out.push_str("No recent logon events found or access denied.\n");
11904        } else {
11905            out.push_str("=== Recent Logons (Event ID 4624) ===\n");
11906            out.push_str(&text);
11907        }
11908    }
11909
11910    #[cfg(not(target_os = "windows"))]
11911    {
11912        let output = Command::new("last")
11913            .args(["-n", &max_entries.to_string()])
11914            .output()
11915            .map_err(|e| format!("last command failed: {e}"))?;
11916        out.push_str("=== Unix Login History (last) ===\n");
11917        out.push_str(&String::from_utf8_lossy(&output.stdout));
11918    }
11919
11920    Ok(out.trim_end().to_string())
11921}
11922
11923fn inspect_share_access(path: PathBuf) -> Result<String, String> {
11924    let mut out = String::from("Host inspection: share_access\n\n");
11925    let _ = write!(out, "Testing accessibility of: {}\n\n", path.display());
11926
11927    #[cfg(target_os = "windows")]
11928    {
11929        let script = format!(
11930            r#"
11931$p = '{}'
11932$res = @{{ Reachable = $false; Readable = $false; Error = "" }}
11933if (Test-Connection -ComputerName ($p.Split('\')[2]) -Count 1 -Quiet -ErrorAction SilentlyContinue) {{
11934    $res.Reachable = $true
11935    try {{
11936        $null = Get-ChildItem -Path $p -ErrorAction Stop
11937        $res.Readable = $true
11938    }} catch {{
11939        $res.Error = $_.Exception.Message
11940    }}
11941}} else {{
11942    $res.Error = "Server unreachable (Ping failed)"
11943}}
11944"REACHABLE:$($res.Reachable)|READABLE:$($res.Readable)|ERROR:$($res.Error)""#,
11945            path.display()
11946        );
11947
11948        let output = Command::new("powershell")
11949            .args(["-NoProfile", "-Command", &script])
11950            .output()
11951            .map_err(|e| format!("Share test failed: {e}"))?;
11952
11953        let text = String::from_utf8_lossy(&output.stdout);
11954        out.push_str("=== Share Triage Results ===\n");
11955        out.push_str(&text);
11956    }
11957
11958    #[cfg(not(target_os = "windows"))]
11959    {
11960        out.push_str("Share access testing is primarily optimized for Windows UNC paths.\n");
11961    }
11962
11963    Ok(out.trim_end().to_string())
11964}
11965
11966fn inspect_dns_fix_plan(issue: &str) -> Result<String, String> {
11967    let mut out = String::from("Host inspection: fix_plan (DNS Resolution)\n\n");
11968    let _ = write!(out, "Issue: {}\n\n", issue);
11969    out.push_str("Proposed Remediation Steps:\n");
11970    out.push_str("1. **Flush DNS Cache**: Clear local resolver cache.\n");
11971    out.push_str("   `ipconfig /flushdns` (Windows) or `sudo resolvectl flush-caches` (Linux)\n");
11972    out.push_str("2. **Validate Hosts File**: Check for static overrides.\n");
11973    out.push_str("   `Get-Content C:\\Windows\\System32\\drivers\\etc\\hosts` (Windows)\n");
11974    out.push_str("3. **Test Name Resolution**: Use nslookup to query a specific server.\n");
11975    out.push_str("   `nslookup google.com 8.8.8.8` (Tests if external DNS works)\n");
11976    out.push_str("4. **Check Adapter DNS**: Ensure local settings match expected nameservers.\n");
11977    out.push_str(
11978        "   `Get-NetIPConfiguration | Select-Object InterfaceAlias, DNSServer` (Windows)\n",
11979    );
11980
11981    Ok(out)
11982}
11983
11984fn inspect_registry_audit() -> Result<String, String> {
11985    let mut out = String::from("Host inspection: registry_audit\n\n");
11986    out.push_str("Auditing advanced persistence points and shell integrity overrides...\n\n");
11987
11988    #[cfg(target_os = "windows")]
11989    {
11990        let script = r#"
11991$findings = @()
11992
11993# 1. Image File Execution Options (Debugger Hijacking)
11994$ifeo = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options"
11995if (Test-Path $ifeo) {
11996    Get-ChildItem $ifeo | ForEach-Object {
11997        $p = Get-ItemProperty $_.PSPath
11998        if ($p.debugger) { $findings += "[IFEO Hijack] $($_.PSChildName) -> Debugger defined: $($p.debugger)" }
11999    }
12000}
12001
12002# 2. Winlogon Shell Integrity
12003$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
12004$shell = (Get-ItemProperty $winlogon -Name Shell -ErrorAction SilentlyContinue).Shell
12005if ($shell -and $shell -ne "explorer.exe") {
12006    $findings += "[Winlogon Overlook] Non-standard shell defined: $shell"
12007}
12008
12009# 3. Session Manager BootExecute
12010$sm = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager"
12011$boot = (Get-ItemProperty $sm -Name BootExecute -ErrorAction SilentlyContinue).BootExecute
12012if ($boot -and $boot -notcontains "autocheck autochk *") {
12013    $findings += "[Boot Integrity] Non-standard BootExecute defined: $($boot -join ', ')"
12014}
12015
12016if ($findings.Count -eq 0) {
12017    "PASS: No common registry hijacking or shell overrides detected."
12018} else {
12019    $findings -join "`n"
12020}
12021"#;
12022        let output = Command::new("powershell")
12023            .args(["-NoProfile", "-Command", script])
12024            .output()
12025            .map_err(|e| format!("Registry audit failed: {e}"))?;
12026
12027        let text = String::from_utf8_lossy(&output.stdout);
12028        out.push_str("=== Persistence & Integrity Check ===\n");
12029        out.push_str(&text);
12030    }
12031
12032    #[cfg(not(target_os = "windows"))]
12033    {
12034        out.push_str("Registry auditing is specific to Windows environments.\n");
12035    }
12036
12037    Ok(out.trim_end().to_string())
12038}
12039
12040fn inspect_thermal() -> Result<String, String> {
12041    let mut out = String::from("Host inspection: thermal\n\n");
12042    out.push_str("Checking CPU thermal state and active throttling indicators...\n\n");
12043
12044    #[cfg(target_os = "windows")]
12045    {
12046        let script = r#"
12047$thermal = Get-CimInstance -ClassName Win32_PerfRawData_Counters_ThermalZoneInformation -ErrorAction SilentlyContinue
12048if ($thermal) {
12049    $thermal | ForEach-Object {
12050        $temp = [math]::Round(($_.Temperature - 273.15), 1)
12051        "Zone: $($_.Name) | Temp: $temp °C | Throttling: $($_.HighPrecisionTemperature -eq 0 ? 'NO' : 'ACTIVE')"
12052    }
12053} else {
12054    "Thermal counters not directly available via WMI. Checking for system throttling indicators..."
12055    $throttling = Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty LoadPercentage
12056    "Current CPU Load: $throttling%"
12057}
12058"#;
12059        let output = Command::new("powershell")
12060            .args(["-NoProfile", "-Command", script])
12061            .output()
12062            .map_err(|e| format!("Thermal check failed: {e}"))?;
12063        out.push_str("=== Windows Thermal State ===\n");
12064        out.push_str(&String::from_utf8_lossy(&output.stdout));
12065    }
12066
12067    #[cfg(not(target_os = "windows"))]
12068    {
12069        out.push_str(
12070            "Thermal inspection is currently optimized for Windows performance counters.\n",
12071        );
12072    }
12073
12074    Ok(out.trim_end().to_string())
12075}
12076
12077fn inspect_activation() -> Result<String, String> {
12078    let mut out = String::from("Host inspection: activation\n\n");
12079    out.push_str("Auditing Windows activation and license state...\n\n");
12080
12081    #[cfg(target_os = "windows")]
12082    {
12083        let script = r#"
12084$xpr = cscript //nologo C:\Windows\System32\slmgr.vbs /xpr
12085$dli = cscript //nologo C:\Windows\System32\slmgr.vbs /dli
12086"Status: $($xpr.Trim())"
12087"Details: $($dli -join ' ' | Select-String -Pattern 'License Status|Name' -AllMatches | ForEach-Object { $_.ToString().Trim() })"
12088"#;
12089        let output = Command::new("powershell")
12090            .args(["-NoProfile", "-Command", script])
12091            .output()
12092            .map_err(|e| format!("Activation check failed: {e}"))?;
12093        out.push_str("=== Windows License Report ===\n");
12094        out.push_str(&String::from_utf8_lossy(&output.stdout));
12095    }
12096
12097    #[cfg(not(target_os = "windows"))]
12098    {
12099        out.push_str("Windows activation check is specific to the Windows platform.\n");
12100    }
12101
12102    Ok(out.trim_end().to_string())
12103}
12104
12105fn inspect_patch_history(max_entries: usize) -> Result<String, String> {
12106    let mut out = String::from("Host inspection: patch_history\n\n");
12107    let _ = write!(
12108        out,
12109        "Listing the last {} installed Windows updates (KBs)...\n\n",
12110        max_entries
12111    );
12112
12113    #[cfg(target_os = "windows")]
12114    {
12115        let n = max_entries.clamp(1, 50);
12116        let script = format!(
12117            "Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First {} | ForEach-Object {{ \"[$($_.InstalledOn.ToString('yyyy-MM-dd'))] $($_.HotFixID) - $($_.Description)\" }}",
12118            n
12119        );
12120        let output = Command::new("powershell")
12121            .args(["-NoProfile", "-Command", &script])
12122            .output()
12123            .map_err(|e| format!("Patch history query failed: {e}"))?;
12124        out.push_str("=== Recent HotFixes (KBs) ===\n");
12125        out.push_str(&String::from_utf8_lossy(&output.stdout));
12126    }
12127
12128    #[cfg(not(target_os = "windows"))]
12129    {
12130        out.push_str("Patch history is currently focused on Windows HotFixes.\n");
12131    }
12132
12133    Ok(out.trim_end().to_string())
12134}
12135
12136// ── ad_user ──────────────────────────────────────────────────────────────────
12137
12138fn inspect_ad_user(identity: &str) -> Result<String, String> {
12139    let mut out = String::from("Host inspection: ad_user\n\n");
12140    let ident = identity.trim();
12141    if ident.is_empty() {
12142        out.push_str("Status: No identity specified. Performing self-discovery...\n");
12143        #[cfg(target_os = "windows")]
12144        {
12145            let script = r#"
12146$u = [System.Security.Principal.WindowsIdentity]::GetCurrent()
12147"USER: " + $u.Name
12148"SID: " + $u.User.Value
12149"GROUPS: " + (($u.Groups | ForEach-Object { try { $_.Translate([System.Security.Principal.NTAccount]).Value } catch { $_.Value } }) -join ', ')
12150"ELEVATED: " + (New-Object System.Security.Principal.WindowsPrincipal($u)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
12151"#;
12152            let output = Command::new("powershell")
12153                .args(["-NoProfile", "-Command", script])
12154                .output()
12155                .ok();
12156            if let Some(o) = output {
12157                out.push_str(&String::from_utf8_lossy(&o.stdout));
12158            }
12159        }
12160        return Ok(out);
12161    }
12162
12163    #[cfg(target_os = "windows")]
12164    {
12165        let script = format!(
12166            r#"
12167try {{
12168    $u = Get-ADUser -Identity "{ident}" -Properties MemberOf, LastLogonDate, Enabled, PasswordExpired -ErrorAction Stop
12169    "NAME: " + $u.Name
12170    "SID: " + $u.SID
12171    "ENABLED: " + $u.Enabled
12172    "EXPIRED: " + $u.PasswordExpired
12173    "LOGON: " + $u.LastLogonDate
12174    "GROUPS: " + ($u.MemberOf -replace "CN=([^,]+),.*", "$1" -join ", ")
12175}} catch {{
12176    # Fallback to net user if AD module is missing or fails
12177    $net = net user "{ident}" /domain 2>&1
12178    if ($LASTEXITCODE -eq 0) {{
12179        $net | Select-String "User name", "Full Name", "Account active", "Password expires", "Last logon", "Local Group Memberships", "Global Group memberships" | ForEach-Object {{ $_.ToString().Trim() }}
12180    }} else {{
12181        "ERROR: " + $_.Exception.Message
12182    }}
12183}}"#
12184        );
12185
12186        let output = Command::new("powershell")
12187            .args(["-NoProfile", "-Command", &script])
12188            .output()
12189            .ok();
12190
12191        if let Some(o) = output {
12192            let stdout = String::from_utf8_lossy(&o.stdout);
12193            if stdout.contains("ERROR:") && stdout.contains("Get-ADUser") {
12194                out.push_str("Active Directory PowerShell module not found. Showing basic domain user info:\n\n");
12195            }
12196            out.push_str(&stdout);
12197        }
12198    }
12199
12200    #[cfg(not(target_os = "windows"))]
12201    {
12202        let _ = ident;
12203        out.push_str("(AD User lookup only available on Windows nodes)\n");
12204    }
12205
12206    Ok(out.trim_end().to_string())
12207}
12208
12209// ── dns_lookup ───────────────────────────────────────────────────────────────
12210
12211fn inspect_dns_lookup(name: &str, record_type: &str) -> Result<String, String> {
12212    let mut out = String::from("Host inspection: dns_lookup\n\n");
12213    let target = name.trim();
12214    if target.is_empty() {
12215        return Err("Missing required target name for dns_lookup.".to_string());
12216    }
12217
12218    #[cfg(target_os = "windows")]
12219    {
12220        let script = format!("Resolve-DnsName -Name '{target}' -Type {record_type} -ErrorAction SilentlyContinue | Select-Object Name, Type, TTL, Section, NameHost, Strings, IPAddress, Address | Format-List");
12221        let output = Command::new("powershell")
12222            .args(["-NoProfile", "-Command", &script])
12223            .output()
12224            .ok();
12225        if let Some(o) = output {
12226            let stdout = String::from_utf8_lossy(&o.stdout);
12227            if stdout.trim().is_empty() {
12228                let _ = writeln!(out, "No {record_type} records found for {target}.");
12229            } else {
12230                out.push_str(&stdout);
12231            }
12232        }
12233    }
12234
12235    #[cfg(not(target_os = "windows"))]
12236    {
12237        let output = Command::new("dig")
12238            .args([target, record_type, "+short"])
12239            .output()
12240            .ok();
12241        if let Some(o) = output {
12242            out.push_str(&String::from_utf8_lossy(&o.stdout));
12243        }
12244    }
12245
12246    Ok(out.trim_end().to_string())
12247}
12248
12249// ── hyperv ───────────────────────────────────────────────────────────────────
12250
12251#[cfg(target_os = "windows")]
12252fn ps_exec(script: &str) -> String {
12253    Command::new("powershell")
12254        .args(["-NoProfile", "-NonInteractive", "-Command", script])
12255        .output()
12256        .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
12257        .unwrap_or_default()
12258}
12259
12260fn inspect_mdm_enrollment() -> Result<String, String> {
12261    #[cfg(target_os = "windows")]
12262    {
12263        let mut out = String::from("Host inspection: mdm_enrollment\n\n");
12264
12265        // ── dsregcmd /status — primary enrollment signal ──────────────────────
12266        out.push_str("=== Device join and MDM state (dsregcmd) ===\n");
12267        let ps_dsreg = r#"
12268$raw = dsregcmd /status 2>$null
12269$fields = @('AzureAdJoined','EnterpriseJoined','DomainJoined','MdmEnrolled',
12270            'WamDefaultSet','AzureAdPrt','TenantName','TenantId','MdmUrl','MdmTouUrl')
12271foreach ($line in $raw) {
12272    $t = $line.Trim()
12273    foreach ($f in $fields) {
12274        if ($t -like "$f :*") {
12275            $val = ($t -split ':',2)[1].Trim()
12276            "$f`: $val"
12277        }
12278    }
12279}
12280"#;
12281        match run_powershell(ps_dsreg) {
12282            Ok(o) if !o.trim().is_empty() => {
12283                for line in o.lines() {
12284                    let l = line.trim();
12285                    if !l.is_empty() {
12286                        let _ = writeln!(out, "- {l}");
12287                    }
12288                }
12289            }
12290            Ok(_) => out.push_str(
12291                "- dsregcmd returned no enrollment fields (device may not be AAD-joined)\n",
12292            ),
12293            Err(e) => {
12294                let _ = writeln!(out, "- dsregcmd error: {e}");
12295            }
12296        }
12297
12298        // ── Registry enrollment accounts ──────────────────────────────────────
12299        out.push_str("\n=== Enrollment accounts (registry) ===\n");
12300        let ps_enroll = r#"
12301$base = 'HKLM:\SOFTWARE\Microsoft\Enrollments'
12302if (Test-Path $base) {
12303    $accounts = Get-ChildItem $base -ErrorAction SilentlyContinue
12304    if ($accounts) {
12305        foreach ($acct in $accounts) {
12306            $p = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
12307            $upn    = if ($p.UPN)                { $p.UPN }                else { '(none)' }
12308            $server = if ($p.EnrollmentServerUrl){ $p.EnrollmentServerUrl }else { '(none)' }
12309            $type   = switch ($p.EnrollmentType) {
12310                6  { 'MDM' }
12311                13 { 'MAM' }
12312                default { "Type=$($p.EnrollmentType)" }
12313            }
12314            $state  = switch ($p.EnrollmentState) {
12315                1  { 'Enrolled' }
12316                2  { 'InProgress' }
12317                6  { 'Unenrolled' }
12318                default { "State=$($p.EnrollmentState)" }
12319            }
12320            "Account: $upn | $type | $state | $server"
12321        }
12322    } else { "No enrollment accounts found under $base" }
12323} else { "Enrollment registry key not found — device is not MDM-enrolled" }
12324"#;
12325        match run_powershell(ps_enroll) {
12326            Ok(o) => {
12327                for line in o.lines() {
12328                    let l = line.trim();
12329                    if !l.is_empty() {
12330                        let _ = writeln!(out, "- {l}");
12331                    }
12332                }
12333            }
12334            Err(e) => {
12335                let _ = writeln!(out, "- Registry read error: {e}");
12336            }
12337        }
12338
12339        // ── MDM service health ────────────────────────────────────────────────
12340        out.push_str("\n=== MDM services ===\n");
12341        let ps_svc = r#"
12342$names = @('IntuneManagementExtension','dmwappushservice','Microsoft.Management.Services.IntuneWindowsAgent')
12343foreach ($n in $names) {
12344    $s = Get-Service -Name $n -ErrorAction SilentlyContinue
12345    if ($s) { "$($s.Name): $($s.Status) (StartType: $($s.StartType))" }
12346}
12347"#;
12348        match run_powershell(ps_svc) {
12349            Ok(o) if !o.trim().is_empty() => {
12350                for line in o.lines() {
12351                    let l = line.trim();
12352                    if !l.is_empty() {
12353                        let _ = writeln!(out, "- {l}");
12354                    }
12355                }
12356            }
12357            Ok(_) => out.push_str("- No Intune management services found (unmanaged device or extension not installed)\n"),
12358            Err(e) => { let _ = writeln!(out, "- Service query error: {e}"); }
12359        }
12360
12361        // ── Recent MDM / Intune events ────────────────────────────────────────
12362        out.push_str("\n=== Recent MDM events (last 24h) ===\n");
12363        let ps_evt = r#"
12364$logs = @('Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin',
12365          'Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot')
12366$cutoff = (Get-Date).AddHours(-24)
12367$found = $false
12368foreach ($log in $logs) {
12369    $evts = Get-WinEvent -LogName $log -MaxEvents 20 -ErrorAction SilentlyContinue |
12370            Where-Object { $_.TimeCreated -gt $cutoff -and $_.Level -le 3 }
12371    foreach ($e in $evts) {
12372        $found = $true
12373        $ts = $e.TimeCreated.ToString('HH:mm')
12374        $lvl = if ($e.Level -eq 2) { 'ERR' } else { 'WARN' }
12375        "[$lvl $ts] ID=$($e.Id) — $($e.Message.Split("`n")[0].Trim())"
12376    }
12377}
12378if (-not $found) { "No MDM warning/error events in the last 24 hours" }
12379"#;
12380        match run_powershell(ps_evt) {
12381            Ok(o) => {
12382                for line in o.lines() {
12383                    let l = line.trim();
12384                    if !l.is_empty() {
12385                        let _ = writeln!(out, "- {l}");
12386                    }
12387                }
12388            }
12389            Err(e) => {
12390                let _ = writeln!(out, "- Event log read error: {e}");
12391            }
12392        }
12393
12394        // ── Findings ──────────────────────────────────────────────────────────
12395        out.push_str("\n=== Findings ===\n");
12396        let body = out.clone();
12397        let enrolled = body.contains("MdmEnrolled: YES") || body.contains("| Enrolled |");
12398        let intune_running = body.contains("IntuneManagementExtension: Running");
12399        let has_errors = body.contains("[ERR ") || body.contains("[WARN ");
12400
12401        if !enrolled {
12402            out.push_str("- NOT ENROLLED: Device shows no active MDM enrollment. If Intune enrollment is expected, check AAD join state and re-run device enrollment from Settings > Accounts > Access work or school.\n");
12403        } else {
12404            out.push_str("- ENROLLED: Device has an active MDM enrollment.\n");
12405            if !intune_running {
12406                out.push_str("- WARNING: Intune Management Extension service is not running — policies and app deployments may stall. Check service health and restart if needed.\n");
12407            }
12408        }
12409        if has_errors {
12410            out.push_str("- MDM error/warning events detected — review the events section above for blockers.\n");
12411        }
12412        if !enrolled && !has_errors {
12413            out.push_str("- No MDM error events detected. If enrollment is required, initiate from Settings > Accounts > Access work or school > Connect.\n");
12414        }
12415
12416        Ok(out)
12417    }
12418
12419    #[cfg(not(target_os = "windows"))]
12420    {
12421        Ok("Host inspection: mdm_enrollment\n\n=== Findings ===\n- MDM/Intune enrollment inspection is Windows-only.\n".into())
12422    }
12423}
12424
12425fn inspect_hyperv() -> Result<String, String> {
12426    #[cfg(target_os = "windows")]
12427    {
12428        let mut findings: Vec<String> = Vec::with_capacity(4);
12429        let mut out = String::with_capacity(2048);
12430
12431        // --- Hyper-V role / VMMS service state ---
12432        let ps_role = r#"
12433$vmms = Get-Service -Name vmms -ErrorAction SilentlyContinue
12434$feature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -ErrorAction SilentlyContinue
12435$hostInfo = Get-VMHost -ErrorAction SilentlyContinue
12436$ram = (Get-CimInstance Win32_PhysicalMemory -ErrorAction SilentlyContinue | Measure-Object -Property Capacity -Sum).Sum
12437"VMMS:{0}|FeatureState:{1}|HostName:{2}|HostRAMBytes:{3}" -f `
12438    $(if ($vmms) { "$($vmms.Status)|$($vmms.StartType)" } else { "NotFound|Unknown" }),
12439    $(if ($feature) { $feature.State } else { "Unknown" }),
12440    $(if ($hostInfo) { $hostInfo.ComputerName } else { $env:COMPUTERNAME }),
12441    $(if ($ram) { $ram } else { "0" })
12442"#;
12443        let role_out = ps_exec(ps_role);
12444        out.push_str("=== Hyper-V role state ===\n");
12445
12446        let mut vmms_running = false;
12447        let mut host_ram_bytes: u64 = 0;
12448
12449        if let Some(line) = role_out.lines().find(|l| l.contains("VMMS:")) {
12450            let kv: std::collections::HashMap<&str, &str> = line
12451                .split('|')
12452                .filter_map(|p| {
12453                    let mut it = p.splitn(2, ':');
12454                    Some((it.next()?, it.next()?))
12455                })
12456                .collect();
12457            let vmms_status = kv.get("VMMS").copied().unwrap_or("Unknown");
12458            let feature_state = kv.get("FeatureState").copied().unwrap_or("Unknown");
12459            let host_name = kv.get("HostName").copied().unwrap_or("Unknown");
12460            host_ram_bytes = kv
12461                .get("HostRAMBytes")
12462                .and_then(|v| v.parse().ok())
12463                .unwrap_or(0);
12464
12465            let hyperv_installed = feature_state.eq_ignore_ascii_case("enabled");
12466            vmms_running = vmms_status.starts_with("Running");
12467
12468            let _ = writeln!(out, "- Host: {host_name}");
12469            let _ = writeln!(
12470                out,
12471                "- Hyper-V feature: {}",
12472                if hyperv_installed {
12473                    "Enabled"
12474                } else {
12475                    "Not installed"
12476                }
12477            );
12478            let _ = writeln!(out, "- VMMS service: {vmms_status}");
12479            if host_ram_bytes > 0 {
12480                let _ = writeln!(
12481                    out,
12482                    "- Host physical RAM: {} GB",
12483                    host_ram_bytes / 1_073_741_824
12484                );
12485            }
12486
12487            if !hyperv_installed {
12488                findings.push(
12489                    "Hyper-V is not installed on this machine. Enable the Microsoft-Hyper-V-All feature to use virtualization.".into(),
12490                );
12491            } else if !vmms_running {
12492                findings.push(
12493                    "Hyper-V is installed but the Virtual Machine Management Service (vmms) is not running — VMs cannot start until the service is active.".into(),
12494                );
12495            }
12496        } else {
12497            out.push_str("- Could not determine Hyper-V role state\n");
12498            findings.push("Hyper-V does not appear to be installed on this machine.".into());
12499        }
12500
12501        // --- Virtual machines ---
12502        out.push_str("\n=== Virtual machines ===\n");
12503        if vmms_running {
12504            let ps_vms = r#"
12505Get-VM -ErrorAction SilentlyContinue | ForEach-Object {
12506    $ram_gb = [math]::Round($_.MemoryAssigned / 1GB, 2)
12507    "VM:{0}|State:{1}|CPU:{2}%|RAM:{3}GB|Uptime:{4}|Status:{5}|Generation:{6}" -f `
12508        $_.Name, $_.State, $_.CPUUsage, $ram_gb,
12509        $(if ($_.Uptime.TotalSeconds -gt 0) { "$($_.Uptime.Hours)h$($_.Uptime.Minutes)m" } else { "Off" }),
12510        $_.Status, $_.Generation
12511}
12512"#;
12513            let vms_out = ps_exec(ps_vms);
12514            let vm_lines: Vec<&str> = vms_out.lines().filter(|l| l.starts_with("VM:")).collect();
12515
12516            if vm_lines.is_empty() {
12517                out.push_str("- No virtual machines found on this host\n");
12518            } else {
12519                let mut total_ram_bytes: u64 = 0;
12520                let mut saved_vms: Vec<String> = Vec::new();
12521                for line in &vm_lines {
12522                    let kv: std::collections::HashMap<&str, &str> = line
12523                        .split('|')
12524                        .filter_map(|p| {
12525                            let mut it = p.splitn(2, ':');
12526                            Some((it.next()?, it.next()?))
12527                        })
12528                        .collect();
12529                    let name = kv.get("VM").copied().unwrap_or("Unknown");
12530                    let state = kv.get("State").copied().unwrap_or("Unknown");
12531                    let cpu = kv.get("CPU").copied().unwrap_or("0").trim_end_matches('%');
12532                    let ram = kv.get("RAM").copied().unwrap_or("0").trim_end_matches("GB");
12533                    let uptime = kv.get("Uptime").copied().unwrap_or("Off");
12534                    let status = kv.get("Status").copied().unwrap_or("");
12535                    let gen = kv.get("Generation").copied().unwrap_or("?");
12536
12537                    if let Ok(r) = ram.parse::<f64>() {
12538                        total_ram_bytes += (r * 1_073_741_824.0) as u64;
12539                    }
12540                    if state.eq_ignore_ascii_case("Saved") {
12541                        saved_vms.push(name.to_string());
12542                    }
12543
12544                    let _ = writeln!(out,
12545                        "- {name} | State: {state} | CPU: {cpu}% | RAM: {ram} GB | Uptime: {uptime} | Gen{gen}"
12546                    );
12547                    if !status.is_empty() && !status.eq_ignore_ascii_case("Operating normally") {
12548                        let _ = writeln!(out, "  Status: {status}");
12549                    }
12550                }
12551
12552                let _ = write!(out, "\n- Total VMs: {}\n", vm_lines.len());
12553                if total_ram_bytes > 0 && host_ram_bytes > 0 {
12554                    let pct = (total_ram_bytes * 100) / host_ram_bytes;
12555                    let _ = writeln!(
12556                        out,
12557                        "- Total VM RAM assigned: {} GB ({pct}% of host RAM)",
12558                        total_ram_bytes / 1_073_741_824
12559                    );
12560                    if pct > 90 {
12561                        findings.push(format!(
12562                            "VM RAM assignment is at {pct}% of host physical RAM — the host may be under severe memory pressure if all VMs run simultaneously."
12563                        ));
12564                    }
12565                }
12566                if !saved_vms.is_empty() {
12567                    findings.push(format!(
12568                        "VMs in Saved state (consuming disk space for checkpoint): {} — resume or delete to free space.",
12569                        saved_vms.join(", ")
12570                    ));
12571                }
12572            }
12573        } else {
12574            out.push_str("- VMMS not running — cannot enumerate VMs\n");
12575        }
12576
12577        // --- VM network switches ---
12578        out.push_str("\n=== VM network switches ===\n");
12579        if vmms_running {
12580            let ps_switches = r#"
12581Get-VMSwitch -ErrorAction SilentlyContinue | ForEach-Object {
12582    "Switch:{0}|Type:{1}|Adapter:{2}" -f `
12583        $_.Name, $_.SwitchType,
12584        $(if ($_.NetAdapterInterfaceDescription) { $_.NetAdapterInterfaceDescription } else { "N/A" })
12585}
12586"#;
12587            let sw_out = ps_exec(ps_switches);
12588            let switch_lines: Vec<&str> = sw_out
12589                .lines()
12590                .filter(|l| l.starts_with("Switch:"))
12591                .collect();
12592
12593            if switch_lines.is_empty() {
12594                out.push_str("- No VM switches configured\n");
12595            } else {
12596                for line in &switch_lines {
12597                    let kv: std::collections::HashMap<&str, &str> = line
12598                        .split('|')
12599                        .filter_map(|p| {
12600                            let mut it = p.splitn(2, ':');
12601                            Some((it.next()?, it.next()?))
12602                        })
12603                        .collect();
12604                    let name = kv.get("Switch").copied().unwrap_or("Unknown");
12605                    let sw_type = kv.get("Type").copied().unwrap_or("Unknown");
12606                    let adapter = kv.get("Adapter").copied().unwrap_or("N/A");
12607                    let _ = writeln!(out, "- {name} | Type: {sw_type} | NIC: {adapter}");
12608                }
12609            }
12610        } else {
12611            out.push_str("- VMMS not running — cannot enumerate switches\n");
12612        }
12613
12614        // --- VM checkpoints ---
12615        out.push_str("\n=== VM checkpoints ===\n");
12616        if vmms_running {
12617            let ps_checkpoints = r#"
12618$all = Get-VMCheckpoint -VMName * -ErrorAction SilentlyContinue
12619if ($all) {
12620    $all | ForEach-Object {
12621        "Checkpoint:{0}|VM:{1}|Created:{2}|Type:{3}" -f `
12622            $_.Name, $_.VMName,
12623            $_.CreationTime.ToString("yyyy-MM-dd HH:mm"),
12624            $_.SnapshotType
12625    }
12626} else {
12627    "NONE"
12628}
12629"#;
12630            let cp_out = ps_exec(ps_checkpoints);
12631            if cp_out.trim() == "NONE" || cp_out.trim().is_empty() {
12632                out.push_str("- No checkpoints found\n");
12633            } else {
12634                let cp_lines: Vec<&str> = cp_out
12635                    .lines()
12636                    .filter(|l| l.starts_with("Checkpoint:"))
12637                    .collect();
12638                let mut per_vm: std::collections::HashMap<&str, usize> =
12639                    std::collections::HashMap::new();
12640                for line in &cp_lines {
12641                    let kv: std::collections::HashMap<&str, &str> = line
12642                        .split('|')
12643                        .filter_map(|p| {
12644                            let mut it = p.splitn(2, ':');
12645                            Some((it.next()?, it.next()?))
12646                        })
12647                        .collect();
12648                    let cp_name = kv.get("Checkpoint").copied().unwrap_or("Unknown");
12649                    let vm_name = kv.get("VM").copied().unwrap_or("Unknown");
12650                    let created = kv.get("Created").copied().unwrap_or("");
12651                    let cp_type = kv.get("Type").copied().unwrap_or("");
12652                    let _ = writeln!(
12653                        out,
12654                        "- [{vm_name}] {cp_name} | Created: {created} | Type: {cp_type}"
12655                    );
12656                    *per_vm.entry(vm_name).or_insert(0) += 1;
12657                }
12658                for (vm, count) in &per_vm {
12659                    if *count >= 3 {
12660                        findings.push(format!(
12661                            "VM '{vm}' has {count} checkpoints — excessive checkpoints grow the VHDX chain and degrade disk performance. Delete unneeded checkpoints."
12662                        ));
12663                    }
12664                }
12665            }
12666        } else {
12667            out.push_str("- VMMS not running — cannot enumerate checkpoints\n");
12668        }
12669
12670        let mut result = String::from("Host inspection: hyperv\n\n=== Findings ===\n");
12671        if findings.is_empty() {
12672            result.push_str("- No Hyper-V health issues detected.\n");
12673        } else {
12674            for f in &findings {
12675                let _ = writeln!(result, "- Finding: {f}");
12676            }
12677        }
12678        result.push('\n');
12679        result.push_str(&out);
12680        Ok(result.trim_end().to_string())
12681    }
12682
12683    #[cfg(not(target_os = "windows"))]
12684    Ok(
12685        "Host inspection: hyperv\n\n=== Findings ===\n- Hyper-V inspection is Windows-only.\n"
12686            .into(),
12687    )
12688}
12689
12690// ── ip_config ────────────────────────────────────────────────────────────────
12691
12692fn inspect_ip_config() -> Result<String, String> {
12693    let mut out = String::from("Host inspection: ip_config\n\n");
12694
12695    #[cfg(target_os = "windows")]
12696    {
12697        let script = "Get-NetIPConfiguration -Detailed | ForEach-Object { \
12698            $_.InterfaceAlias + ' [' + $_.InterfaceDescription + ']' + \
12699            '\\n  Status: ' + $_.NetAdapter.Status + \
12700            '\\n  Initial IPv4: ' + ($_.IPv4Address.IPAddress -join ', ') + \
12701            '\\n  DHCP Enabled: ' + $_.NetAdapter.DhcpStatus + \
12702            '\\n  DHCP Server: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
12703            '\\n  IPv4 Default Gateway: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
12704            '\\n  DNSServer: ' + ($_.DNSServer.ServerAddresses -join ', ') + '\\n' \
12705        }";
12706        let output = Command::new("powershell")
12707            .args(["-NoProfile", "-Command", script])
12708            .output()
12709            .ok();
12710        if let Some(o) = output {
12711            out.push_str(&String::from_utf8_lossy(&o.stdout));
12712        }
12713    }
12714
12715    #[cfg(not(target_os = "windows"))]
12716    {
12717        let output = Command::new("ip").args(["addr", "show"]).output().ok();
12718        if let Some(o) = output {
12719            out.push_str(&String::from_utf8_lossy(&o.stdout));
12720        }
12721    }
12722
12723    Ok(out.trim_end().to_string())
12724}
12725
12726// ── event_query ──────────────────────────────────────────────────────────────
12727
12728fn inspect_event_query(
12729    event_id: Option<u32>,
12730    log_name: Option<&str>,
12731    source: Option<&str>,
12732    hours: u32,
12733    level: Option<&str>,
12734    max_entries: usize,
12735) -> Result<String, String> {
12736    #[cfg(target_os = "windows")]
12737    {
12738        let mut findings: Vec<String> = Vec::with_capacity(4);
12739
12740        // Build the PowerShell filter hash
12741        let log = log_name.unwrap_or("*");
12742        let cap = max_entries.min(50);
12743
12744        // Level mapping: Error=2, Warning=3, Information=4
12745        let level_filter = match level.map(|l| l.to_lowercase()).as_deref() {
12746            Some("error") | Some("errors") => Some(2u8),
12747            Some("warning") | Some("warnings") | Some("warn") => Some(3u8),
12748            Some("information") | Some("info") => Some(4u8),
12749            _ => None,
12750        };
12751
12752        // Build filter hashtable entries
12753        let mut filter_parts = vec![format!("StartTime = (Get-Date).AddHours(-{hours})")];
12754        if log != "*" {
12755            filter_parts.push(format!("LogName = '{log}'"));
12756        }
12757        if let Some(id) = event_id {
12758            filter_parts.push(format!("Id = {id}"));
12759        }
12760        if let Some(src) = source {
12761            filter_parts.push(format!("ProviderName = '{src}'"));
12762        }
12763        if let Some(lvl) = level_filter {
12764            filter_parts.push(format!("Level = {lvl}"));
12765        }
12766
12767        let filter_ht = filter_parts.join("; ");
12768
12769        let ps = format!(
12770            r#"
12771$filter = @{{ {filter_ht} }}
12772try {{
12773    $events = Get-WinEvent -FilterHashtable $filter -MaxEvents {cap} -ErrorAction Stop |
12774        Select-Object TimeCreated, Id, LevelDisplayName, ProviderName,
12775            @{{N='Msg';E={{ ($_.Message -split "`n")[0] }}}}
12776    if ($events) {{
12777        $events | ForEach-Object {{
12778            "TIME:{{0}}|ID:{{1}}|LEVEL:{{2}}|SOURCE:{{3}}|MSG:{{4}}" -f `
12779                $_.TimeCreated.ToString("yyyy-MM-dd HH:mm:ss"),
12780                $_.Id, $_.LevelDisplayName, $_.ProviderName,
12781                ($_.Msg -replace '\|','/')
12782        }}
12783    }} else {{
12784        "NONE"
12785    }}
12786}} catch {{
12787    "ERROR:$($_.Exception.Message)"
12788}}
12789"#
12790        );
12791
12792        let raw = ps_exec(&ps);
12793        let lines: Vec<&str> = raw.lines().collect();
12794
12795        // Build query description for header
12796        let mut query_desc = format!("last {hours}h");
12797        if let Some(id) = event_id {
12798            let _ = write!(query_desc, ", Event ID {id}");
12799        }
12800        if let Some(src) = source {
12801            let _ = write!(query_desc, ", source '{src}'");
12802        }
12803        if log != "*" {
12804            let _ = write!(query_desc, ", log '{log}'");
12805        }
12806        if let Some(l) = level {
12807            let _ = write!(query_desc, ", level '{l}'");
12808        }
12809
12810        let mut out = format!("=== Event query: {query_desc} ===\n");
12811
12812        if lines
12813            .iter()
12814            .any(|l| l.trim() == "NONE" || l.trim().is_empty())
12815        {
12816            out.push_str("- No matching events found.\n");
12817        } else if let Some(err_line) = lines.iter().find(|l| l.starts_with("ERROR:")) {
12818            let msg = err_line.trim_start_matches("ERROR:").trim();
12819            if is_event_query_no_results_message(msg) {
12820                out.push_str("- No matching events found.\n");
12821            } else {
12822                let _ = writeln!(out, "- Query error: {msg}");
12823                findings.push(format!("Event query failed: {msg}"));
12824            }
12825        } else {
12826            let event_lines: Vec<&str> = lines
12827                .iter()
12828                .filter(|l| l.starts_with("TIME:"))
12829                .copied()
12830                .collect();
12831            if event_lines.is_empty() {
12832                out.push_str("- No matching events found.\n");
12833            } else {
12834                // Tally by level for findings
12835                let mut error_count = 0usize;
12836                let mut warning_count = 0usize;
12837
12838                for line in &event_lines {
12839                    let kv: std::collections::HashMap<&str, &str> = line
12840                        .split('|')
12841                        .filter_map(|p| {
12842                            let mut it = p.splitn(2, ':');
12843                            Some((it.next()?, it.next()?))
12844                        })
12845                        .collect();
12846                    let time = kv.get("TIME").copied().unwrap_or("?");
12847                    let id = kv.get("ID").copied().unwrap_or("?");
12848                    let lvl = kv.get("LEVEL").copied().unwrap_or("?");
12849                    let src = kv.get("SOURCE").copied().unwrap_or("?");
12850                    let msg = kv.get("MSG").copied().unwrap_or("").trim();
12851
12852                    // Truncate long messages
12853                    let msg_display = if msg.len() > 120 {
12854                        format!("{}…", safe_head(msg, 120))
12855                    } else {
12856                        msg.to_string()
12857                    };
12858
12859                    let _ = write!(out, "- [{time}] ID {id} | {lvl} | {src}\n  {msg_display}\n");
12860
12861                    if lvl.eq_ignore_ascii_case("error") || lvl.eq_ignore_ascii_case("critical") {
12862                        error_count += 1;
12863                    } else if lvl.eq_ignore_ascii_case("warning") {
12864                        warning_count += 1;
12865                    }
12866                }
12867
12868                let _ = write!(out, "\n- Total shown: {} event(s)\n", event_lines.len());
12869
12870                if error_count > 0 {
12871                    findings.push(format!(
12872                        "{error_count} Error/Critical event(s) found in the {query_desc} window — review the entries above for root cause."
12873                    ));
12874                }
12875                if warning_count > 5 {
12876                    findings.push(format!(
12877                        "{warning_count} Warning events found — elevated warning volume may indicate a recurring issue."
12878                    ));
12879                }
12880            }
12881        }
12882
12883        let mut result = String::from("Host inspection: event_query\n\n=== Findings ===\n");
12884        if findings.is_empty() {
12885            result.push_str("- No actionable findings from this event query.\n");
12886        } else {
12887            for f in &findings {
12888                let _ = writeln!(result, "- Finding: {f}");
12889            }
12890        }
12891        result.push('\n');
12892        result.push_str(&out);
12893        Ok(result.trim_end().to_string())
12894    }
12895
12896    #[cfg(not(target_os = "windows"))]
12897    {
12898        let _ = (event_id, log_name, source, hours, level, max_entries);
12899        Ok("Host inspection: event_query\n\n=== Findings ===\n- Event log query is Windows-only.\n".into())
12900    }
12901}
12902
12903// ── app_crashes ───────────────────────────────────────────────────────────────
12904
12905fn inspect_app_crashes(process_filter: Option<&str>, max_entries: usize) -> Result<String, String> {
12906    let n = max_entries.clamp(5, 50);
12907    #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
12908    let mut findings: Vec<String> = Vec::with_capacity(4);
12909    #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
12910    let mut sections = String::with_capacity(2048);
12911
12912    #[cfg(target_os = "windows")]
12913    {
12914        let proc_filter_ps = match process_filter {
12915            Some(proc) => format!(
12916                "| Where-Object {{ $_.Message -match [regex]::Escape('{}') }}",
12917                proc.replace('\'', "''")
12918            ),
12919            None => String::new(),
12920        };
12921
12922        let ps = format!(
12923            r#"
12924$results = @()
12925try {{
12926    $events = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue {proc_filter_ps}
12927    if ($events) {{
12928        foreach ($e in $events) {{
12929            $msg  = $e.Message
12930            $app  = if ($msg -match 'Faulting application name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ 'Unknown' }}
12931            $ver  = if ($msg -match 'Faulting application version: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12932            $mod  = if ($msg -match 'Faulting module name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12933            $exc  = if ($msg -match 'Exception code: (0x[0-9a-fA-F]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12934            $type = if ($e.Id -eq 1002) {{ 'HANG' }} else {{ 'CRASH' }}
12935            $results += "$($e.TimeCreated.ToString('yyyy-MM-dd HH:mm'))|$type|$app|$ver|$mod|$exc"
12936        }}
12937        $results
12938    }} else {{ 'NONE' }}
12939}} catch {{ 'ERROR:' + $_.Exception.Message }}
12940"#
12941        );
12942
12943        let raw = ps_exec(&ps);
12944        let text = raw.trim();
12945
12946        // WER archive count (non-blocking best-effort)
12947        let wer_ps = r#"
12948$wer = "$env:LOCALAPPDATA\Microsoft\Windows\WER"
12949$count = 0
12950if (Test-Path $wer) {
12951    $count = (Get-ChildItem -Path $wer -Recurse -Filter '*.wer' -ErrorAction SilentlyContinue).Count
12952}
12953$count
12954"#;
12955        let wer_count: usize = ps_exec(wer_ps).trim().parse().unwrap_or(0);
12956
12957        if text == "NONE" {
12958            sections.push_str("=== Application crashes ===\n- No application crashes or hangs in recent event log.\n");
12959        } else if text.starts_with("ERROR:") {
12960            let msg = text.trim_start_matches("ERROR:").trim();
12961            let _ = write!(
12962                sections,
12963                "=== Application crashes ===\n- Unable to query Application event log: {msg}\n"
12964            );
12965        } else {
12966            let events: Vec<&str> = text.lines().filter(|l| l.contains('|')).collect();
12967            let crash_count = events
12968                .iter()
12969                .filter(|l| l.split('|').nth(1) == Some("CRASH"))
12970                .count();
12971            let hang_count = events
12972                .iter()
12973                .filter(|l| l.split('|').nth(1) == Some("HANG"))
12974                .count();
12975
12976            // Tally crashes per app
12977            let mut app_counts: std::collections::HashMap<String, usize> =
12978                std::collections::HashMap::new();
12979            for line in &events {
12980                let mut it = line.splitn(6, '|');
12981                if let (Some(_), Some(_), Some(app)) = (it.next(), it.next(), it.next()) {
12982                    *app_counts.entry(app.to_string()).or_insert(0) += 1;
12983                }
12984            }
12985
12986            if crash_count > 0 {
12987                findings.push(format!(
12988                    "{crash_count} application crash event(s) — review below for faulting app and exception code."
12989                ));
12990            }
12991            if hang_count > 0 {
12992                findings.push(format!(
12993                    "{hang_count} application hang event(s) — process stopped responding."
12994                ));
12995            }
12996            if let Some((top_app, &count)) = app_counts.iter().max_by_key(|(_, c)| *c) {
12997                if count > 1 {
12998                    findings.push(format!(
12999                        "Most-crashed application: {top_app} ({count} events) — may indicate corrupted install or incompatible module."
13000                    ));
13001                }
13002            }
13003            if wer_count > 10 {
13004                findings.push(format!(
13005                    "{wer_count} WER reports archived — elevated crash history on this machine."
13006                ));
13007            }
13008
13009            let filter_note = match process_filter {
13010                Some(p) => format!(" (filtered: {p})"),
13011                None => String::new(),
13012            };
13013            let _ = writeln!(
13014                sections,
13015                "=== Application crashes and hangs{filter_note} ==="
13016            );
13017
13018            for line in &events {
13019                let mut it = line.splitn(6, '|');
13020                if let (Some(time), Some(kind), Some(app), Some(ver), Some(module), Some(exc)) = (
13021                    it.next(),
13022                    it.next(),
13023                    it.next(),
13024                    it.next(),
13025                    it.next(),
13026                    it.next(),
13027                ) {
13028                    let ver_note = if !ver.is_empty() {
13029                        format!(" v{ver}")
13030                    } else {
13031                        String::new()
13032                    };
13033                    let _ = writeln!(sections, "  [{time}] {kind}: {app}{ver_note}");
13034                    if !module.is_empty() && module != "?" {
13035                        let exc_note = if !exc.is_empty() {
13036                            format!(" (exc {exc})")
13037                        } else {
13038                            String::new()
13039                        };
13040                        let _ = writeln!(sections, "    faulting module: {module}{exc_note}");
13041                    } else if !exc.is_empty() {
13042                        let _ = writeln!(sections, "    exception: {exc}");
13043                    }
13044                }
13045            }
13046            let _ = write!(
13047                sections,
13048                "\n  Total: {crash_count} crash(es), {hang_count} hang(s)\n"
13049            );
13050
13051            if wer_count > 0 {
13052                let _ = write!(sections,
13053                    "\n=== Windows Error Reporting ===\n  WER archive: {wer_count} report(s) in %LOCALAPPDATA%\\Microsoft\\Windows\\WER\n"
13054                );
13055            }
13056        }
13057    }
13058
13059    #[cfg(not(target_os = "windows"))]
13060    {
13061        let _ = (process_filter, n);
13062        sections.push_str("=== Application crashes ===\n- Windows-only (uses Application Event Log, Event IDs 1000/1002).\n");
13063    }
13064
13065    let mut result = String::from("Host inspection: app_crashes\n\n=== Findings ===\n");
13066    if findings.is_empty() {
13067        result.push_str("- No actionable findings.\n");
13068    } else {
13069        for f in &findings {
13070            let _ = writeln!(result, "- Finding: {f}");
13071        }
13072    }
13073    result.push('\n');
13074    result.push_str(&sections);
13075    Ok(result.trim_end().to_string())
13076}
13077
13078#[cfg(target_os = "windows")]
13079fn gpu_voltage_telemetry_note() -> String {
13080    let output = Command::new("nvidia-smi")
13081        .args(["--help-query-gpu"])
13082        .output();
13083
13084    match output {
13085        Ok(o) => {
13086            let text = String::from_utf8_lossy(&o.stdout).to_ascii_lowercase();
13087            if text.contains("\"voltage\"") || text.contains("voltage.") {
13088                "Driver query surface advertises GPU voltage fields, but Hematite is not yet decoding them on this path.".to_string()
13089            } else {
13090                "Unavailable on this NVIDIA driver path: `nvidia-smi` exposes clocks, fans, power, and throttle reasons here, but not a GPU voltage rail query.".to_string()
13091            }
13092        }
13093        Err(_) => "Unavailable: `nvidia-smi` is not present, so Hematite cannot verify whether this driver path exposes GPU voltage rails.".to_string(),
13094    }
13095}
13096
13097#[cfg(target_os = "windows")]
13098fn decode_wmi_processor_voltage(raw: u64) -> Option<String> {
13099    if raw == 0 {
13100        return None;
13101    }
13102    if raw & 0x80 != 0 {
13103        let tenths = raw & 0x7f;
13104        return Some(format!(
13105            "{:.1} V (firmware-reported WMI current voltage)",
13106            tenths as f64 / 10.0
13107        ));
13108    }
13109
13110    let legacy = match raw {
13111        1 => Some("5.0 V"),
13112        2 => Some("3.3 V"),
13113        4 => Some("2.9 V"),
13114        _ => None,
13115    }?;
13116    Some(format!(
13117        "{} (legacy WMI voltage capability flag, not live telemetry)",
13118        legacy
13119    ))
13120}
13121
13122async fn inspect_overclocker() -> Result<String, String> {
13123    let mut out = String::from("Host inspection: overclocker\n\n");
13124
13125    #[cfg(target_os = "windows")]
13126    {
13127        out.push_str(
13128            "Gathering real-time silicon telemetry (2-second high-fidelity average)...\n\n",
13129        );
13130
13131        // 1. NVIDIA Census
13132        let nvidia = Command::new("nvidia-smi")
13133            .args([
13134                "--query-gpu=name,clocks.current.graphics,clocks.current.memory,fan.speed,power.draw,temperature.gpu,power.draw.average,power.draw.instant,power.limit,enforced.power.limit,clocks_throttle_reasons.active",
13135                "--format=csv,noheader,nounits",
13136            ])
13137            .output();
13138
13139        if let Ok(o) = nvidia {
13140            let stdout = String::from_utf8_lossy(&o.stdout);
13141            if !stdout.trim().is_empty() {
13142                out.push_str("=== GPU SENSE (NVIDIA) ===\n");
13143                let mut parts = Vec::with_capacity(16);
13144                parts.extend(stdout.trim().split(',').map(|s| s.trim()));
13145                if parts.len() >= 10 {
13146                    let _ = writeln!(out, "- Model:      {}", parts[0]);
13147                    let _ = writeln!(out, "- Graphics:   {} MHz", parts[1]);
13148                    let _ = writeln!(out, "- Memory:     {} MHz", parts[2]);
13149                    let _ = writeln!(out, "- Fan Speed:  {}%", parts[3]);
13150                    let _ = writeln!(out, "- Power Draw: {} W", parts[4]);
13151                    if !parts[6].eq_ignore_ascii_case("[N/A]") {
13152                        let _ = writeln!(out, "- Power Avg:  {} W", parts[6]);
13153                    }
13154                    if !parts[7].eq_ignore_ascii_case("[N/A]") {
13155                        let _ = writeln!(out, "- Power Inst: {} W", parts[7]);
13156                    }
13157                    if !parts[8].eq_ignore_ascii_case("[N/A]") {
13158                        let _ = writeln!(out, "- Power Cap:  {} W requested", parts[8]);
13159                    }
13160                    if !parts[9].eq_ignore_ascii_case("[N/A]") {
13161                        let _ = writeln!(out, "- Power Enf:  {} W enforced", parts[9]);
13162                    }
13163                    let _ = writeln!(out, "- Temperature: {}°C", parts[5]);
13164
13165                    if parts.len() > 10 {
13166                        let throttle_hex = parts[10];
13167                        let reasons = decode_nvidia_throttle_reasons(throttle_hex);
13168                        if !reasons.is_empty() {
13169                            let _ = writeln!(out, "- Throttling:  YES [Reason: {}]", reasons);
13170                        } else {
13171                            out.push_str("- Throttling:  None (Performance State: Max)\n");
13172                        }
13173                    }
13174                }
13175                out.push('\n');
13176            }
13177        }
13178
13179        out.push_str("=== VOLTAGE TELEMETRY ===\n");
13180        let _ = write!(out, "- GPU Voltage:  {}\n\n", gpu_voltage_telemetry_note());
13181
13182        // 1b. Session Trends (RAM-only historians)
13183        let gpu_state = &crate::ui::gpu_monitor::GLOBAL_GPU_STATE;
13184        let history = gpu_state.history.read().unwrap();
13185        if history.len() >= 2 {
13186            out.push_str("=== SILICON TRENDS (Session) ===\n");
13187            let first = history.front().unwrap();
13188            let last = history.back().unwrap();
13189
13190            let temp_diff = last.temperature as i32 - first.temperature as i32;
13191            let clock_diff = last.core_clock as i32 - first.core_clock as i32;
13192
13193            let temp_trend = if temp_diff > 1 {
13194                "Rising"
13195            } else if temp_diff < -1 {
13196                "Falling"
13197            } else {
13198                "Stable"
13199            };
13200            let clock_trend = if clock_diff > 10 {
13201                "Increasing"
13202            } else if clock_diff < -10 {
13203                "Decreasing"
13204            } else {
13205                "Stable"
13206            };
13207
13208            let _ = writeln!(
13209                out,
13210                "- Temperature: {} ({}°C anomaly)",
13211                temp_trend, temp_diff
13212            );
13213            let _ = writeln!(
13214                out,
13215                "- Core Clock:  {} ({} MHz delta)",
13216                clock_trend, clock_diff
13217            );
13218            out.push('\n');
13219        }
13220
13221        // 2. CPU Time-Series (2 samples)
13222        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))\" }";
13223        let cpu_stats = Command::new("powershell")
13224            .args(["-NoProfile", "-Command", ps_cmd])
13225            .output();
13226
13227        if let Ok(o) = cpu_stats {
13228            let stdout = String::from_utf8_lossy(&o.stdout);
13229            if !stdout.trim().is_empty() {
13230                out.push_str("=== SILICON CORE (CPU) ===\n");
13231                for line in stdout.lines() {
13232                    if let Some((path, val)) = line.split_once(':') {
13233                        let path_lower = path.to_lowercase();
13234                        if path_lower.contains("processor frequency") {
13235                            let _ = writeln!(out, "- Current Freq:  {} MHz (2s Avg)", val);
13236                        } else if path_lower.contains("% of maximum frequency") {
13237                            let _ = writeln!(out, "- Throttling:     {}% of Max Capacity", val);
13238                            let throttle_num = val.parse::<f64>().unwrap_or(100.0);
13239                            if throttle_num < 95.0 {
13240                                out.push_str(
13241                                    "  [WARNING] Active downclocking or power-saving detected.\n",
13242                                );
13243                            }
13244                        }
13245                    }
13246                }
13247            }
13248        }
13249
13250        // 2b. CPU Thermal Fallback
13251        let thermal = Command::new("powershell")
13252            .args([
13253                "-NoProfile",
13254                "-Command",
13255                "Get-CimInstance -Namespace root\\wmi -ClassName MSAcpi_ThermalZoneTemperature | Select-Object @{N='Temp';E={($_.CurrentTemperature - 2732) / 10}} | ConvertTo-Json",
13256            ])
13257            .output();
13258        if let Ok(o) = thermal {
13259            let stdout = String::from_utf8_lossy(&o.stdout);
13260            if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
13261                let temp = if v.is_array() {
13262                    v[0].get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
13263                } else {
13264                    v.get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
13265                };
13266                if temp > 1.0 {
13267                    let _ = writeln!(out, "- CPU Package:   {}°C (ACPI Zone)", temp);
13268                }
13269            }
13270        }
13271
13272        // 3. WMI Static Fallback/Context
13273        let wmi = Command::new("powershell")
13274            .args([
13275                "-NoProfile",
13276                "-Command",
13277                "Get-CimInstance Win32_Processor | Select-Object Name, MaxClockSpeed, CurrentVoltage | ConvertTo-Json",
13278            ])
13279            .output();
13280
13281        if let Ok(o) = wmi {
13282            let stdout = String::from_utf8_lossy(&o.stdout);
13283            if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
13284                out.push_str("\n=== HARDWARE DNA ===\n");
13285                let _ = writeln!(
13286                    out,
13287                    "- Rated Max:     {} MHz",
13288                    v.get("MaxClockSpeed").and_then(|x| x.as_u64()).unwrap_or(0)
13289                );
13290                match v.get("CurrentVoltage").and_then(|x| x.as_u64()) {
13291                    Some(raw) => {
13292                        if let Some(decoded) = decode_wmi_processor_voltage(raw) {
13293                            let _ = writeln!(out, "- CPU Voltage:   {}", decoded);
13294                        } else {
13295                            out.push_str(
13296                                "- CPU Voltage:   Unavailable or non-telemetry WMI value on this firmware path\n",
13297                            );
13298                        }
13299                    }
13300                    None => out.push_str("- CPU Voltage:   Unavailable on this WMI path\n"),
13301                }
13302            }
13303        }
13304    }
13305
13306    #[cfg(not(target_os = "windows"))]
13307    {
13308        out.push_str("Overclocker telemetry is currently optimized for Windows performance counters and NVIDIA drivers.\n");
13309    }
13310
13311    Ok(out.trim_end().to_string())
13312}
13313
13314/// Decodes the NVIDIA Clocks Throttle Reasons HEX bitmask.
13315#[cfg(target_os = "windows")]
13316fn decode_nvidia_throttle_reasons(hex: &str) -> String {
13317    let hex = hex.trim().trim_start_matches("0x");
13318    let val = match u64::from_str_radix(hex, 16) {
13319        Ok(v) => v,
13320        Err(_) => return String::new(),
13321    };
13322
13323    if val == 0 {
13324        return String::new();
13325    }
13326
13327    let mut reasons = Vec::with_capacity(9);
13328    if val & 0x01 != 0 {
13329        reasons.push("GPU Idle");
13330    }
13331    if val & 0x02 != 0 {
13332        reasons.push("Applications Clocks Setting");
13333    }
13334    if val & 0x04 != 0 {
13335        reasons.push("SW Power Cap (PL1/PL2)");
13336    }
13337    if val & 0x08 != 0 {
13338        reasons.push("HW Slowdown (Thermal/Power)");
13339    }
13340    if val & 0x10 != 0 {
13341        reasons.push("Sync Boost");
13342    }
13343    if val & 0x20 != 0 {
13344        reasons.push("SW Thermal Slowdown");
13345    }
13346    if val & 0x40 != 0 {
13347        reasons.push("HW Thermal Slowdown");
13348    }
13349    if val & 0x80 != 0 {
13350        reasons.push("HW Power Brake Slowdown");
13351    }
13352    if val & 0x100 != 0 {
13353        reasons.push("Display Clock Setting");
13354    }
13355
13356    reasons.join(", ")
13357}
13358
13359// ── PowerShell helper (used by camera / sign_in / search_index) ───────────────
13360
13361#[cfg(windows)]
13362fn run_powershell(script: &str) -> Result<String, String> {
13363    use std::process::Command;
13364    let out = Command::new("powershell")
13365        .args(["-NoProfile", "-NonInteractive", "-Command", script])
13366        .output()
13367        .map_err(|e| format!("powershell launch failed: {e}"))?;
13368    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
13369}
13370
13371// ── inspect_camera ────────────────────────────────────────────────────────────
13372
13373#[cfg(windows)]
13374fn inspect_camera(max_entries: usize) -> Result<String, String> {
13375    let mut out = String::from("=== Camera devices ===\n");
13376
13377    // PnP camera devices
13378    let ps_devices = r#"
13379Get-PnpDevice -Class Camera -ErrorAction SilentlyContinue | Select-Object -First 20 |
13380ForEach-Object {
13381    $status = if ($_.Status -eq 'OK') { 'OK' } else { $_.Status }
13382    "$($_.FriendlyName) | Status: $status | InstanceId: $($_.InstanceId)"
13383}
13384"#;
13385    match run_powershell(ps_devices) {
13386        Ok(o) if !o.trim().is_empty() => {
13387            for line in o.lines().take(max_entries) {
13388                let l = line.trim();
13389                if !l.is_empty() {
13390                    let _ = writeln!(out, "- {l}");
13391                }
13392            }
13393        }
13394        _ => out.push_str("- No camera devices found via PnP\n"),
13395    }
13396
13397    // Windows privacy / capability gate
13398    out.push_str("\n=== Windows camera privacy ===\n");
13399    let ps_privacy = r#"
13400$camKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\webcam'
13401$global = (Get-ItemProperty -Path $camKey -Name Value -ErrorAction SilentlyContinue).Value
13402"Global: $global"
13403$apps = Get-ChildItem $camKey -ErrorAction SilentlyContinue |
13404    Where-Object { $_.PSChildName -ne 'NonPackaged' } |
13405    ForEach-Object {
13406        $v = (Get-ItemProperty $_.PSPath -Name Value -ErrorAction SilentlyContinue).Value
13407        if ($v) { "  $($_.PSChildName): $v" }
13408    }
13409$apps
13410"#;
13411    match run_powershell(ps_privacy) {
13412        Ok(o) if !o.trim().is_empty() => {
13413            for line in o.lines().take(max_entries) {
13414                let l = line.trim_end();
13415                if !l.is_empty() {
13416                    let _ = writeln!(out, "{l}");
13417                }
13418            }
13419        }
13420        _ => out.push_str("- Could not read camera privacy registry\n"),
13421    }
13422
13423    // Windows Hello camera (IR / face auth)
13424    out.push_str("\n=== Biometric / Hello camera ===\n");
13425    let ps_bio = r#"
13426Get-PnpDevice -Class Biometric -ErrorAction SilentlyContinue | Select-Object -First 10 |
13427ForEach-Object { "$($_.FriendlyName) | Status: $($_.Status)" }
13428"#;
13429    match run_powershell(ps_bio) {
13430        Ok(o) if !o.trim().is_empty() => {
13431            for line in o.lines().take(max_entries) {
13432                let l = line.trim();
13433                if !l.is_empty() {
13434                    let _ = writeln!(out, "- {l}");
13435                }
13436            }
13437        }
13438        _ => out.push_str("- No biometric devices found\n"),
13439    }
13440
13441    // Findings
13442    let mut findings: Vec<String> = Vec::with_capacity(4);
13443    if out.contains("Status: Error") || out.contains("Status: Unknown") {
13444        findings.push("One or more camera devices report a non-OK status — check Device Manager for driver errors.".into());
13445    }
13446    if out.contains("Global: Deny") {
13447        findings.push("Camera access is globally DENIED in Windows privacy settings — apps cannot use the camera until this is re-enabled (Settings > Privacy > Camera).".into());
13448    }
13449
13450    let mut result = String::from("Host inspection: camera\n\n=== Findings ===\n");
13451    if findings.is_empty() {
13452        result.push_str("- No obvious camera or privacy gate issue detected.\n");
13453        result.push_str("  If an app still can't see the camera, check its individual permission in Settings > Privacy > Camera.\n");
13454    } else {
13455        for f in &findings {
13456            let _ = writeln!(result, "- Finding: {f}");
13457        }
13458    }
13459    result.push('\n');
13460    result.push_str(&out);
13461    Ok(result)
13462}
13463
13464#[cfg(not(windows))]
13465fn inspect_camera(_max_entries: usize) -> Result<String, String> {
13466    Ok("Host inspection: camera\nCamera inspection is Windows-only.".into())
13467}
13468
13469// ── inspect_sign_in ───────────────────────────────────────────────────────────
13470
13471#[cfg(windows)]
13472fn inspect_sign_in(max_entries: usize) -> Result<String, String> {
13473    let mut out = String::from("=== Windows Hello and sign-in state ===\n");
13474
13475    // Windows Hello PIN and face/fingerprint readiness
13476    let ps_hello = r#"
13477$helloKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI'
13478$pinConfigured = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Provisioning\FingerPrint' -ErrorAction SilentlyContinue
13479$faceConfigured = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\WbioSrvc' -Name Start -ErrorAction SilentlyContinue).Start
13480"PIN-style logon path: $helloKey"
13481"WbioSrvc start type: $faceConfigured"
13482"FingerPrint key present: $pinConfigured"
13483"#;
13484    match run_powershell(ps_hello) {
13485        Ok(o) => {
13486            for line in o.lines().take(max_entries) {
13487                let l = line.trim();
13488                if !l.is_empty() {
13489                    let _ = writeln!(out, "- {l}");
13490                }
13491            }
13492        }
13493        Err(e) => {
13494            let _ = writeln!(out, "- Hello query error: {e}");
13495        }
13496    }
13497
13498    // Biometric service state
13499    out.push_str("\n=== Biometric service ===\n");
13500    let ps_bio_svc = r#"
13501$svc = Get-Service WbioSrvc -ErrorAction SilentlyContinue
13502if ($svc) { "WbioSrvc | Status: $($svc.Status) | StartType: $($svc.StartType)" }
13503else { "WbioSrvc not found" }
13504"#;
13505    match run_powershell(ps_bio_svc) {
13506        Ok(o) => {
13507            let _ = writeln!(out, "- {}", o.trim());
13508        }
13509        Err(_) => out.push_str("- Could not query biometric service\n"),
13510    }
13511
13512    // Recent logon failure events
13513    out.push_str("\n=== Recent sign-in failures (last 24h) ===\n");
13514    let ps_events = r#"
13515$cutoff = (Get-Date).AddHours(-24)
13516Get-WinEvent -LogName Security -FilterXPath "*[System[EventID=4625 and TimeCreated[timediff(@SystemTime) <= 86400000]]]" -MaxEvents 10 -ErrorAction SilentlyContinue |
13517ForEach-Object {
13518    $xml = [xml]$_.ToXml()
13519    $reason = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'FailureReason' }).'#text'
13520    $account = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text'
13521    "$($_.TimeCreated.ToString('HH:mm')) | Account: $account | Reason: $reason"
13522} | Select-Object -First 10
13523"#;
13524    match run_powershell(ps_events) {
13525        Ok(o) if !o.trim().is_empty() => {
13526            let count = o.lines().filter(|l| !l.trim().is_empty()).count();
13527            let _ = writeln!(out, "- {count} recent logon failure(s) detected:");
13528            for line in o.lines().take(max_entries) {
13529                let l = line.trim();
13530                if !l.is_empty() {
13531                    let _ = writeln!(out, "  {l}");
13532                }
13533            }
13534        }
13535        _ => out.push_str("- No sign-in failures in the last 24h (or insufficient privileges to read Security log)\n"),
13536    }
13537
13538    // Credential providers
13539    out.push_str("\n=== Active credential providers ===\n");
13540    let ps_cp = r#"
13541Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers' -ErrorAction SilentlyContinue |
13542ForEach-Object {
13543    $name = (Get-ItemProperty $_.PSPath -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
13544    if ($name) { $name }
13545} | Select-Object -First 15
13546"#;
13547    match run_powershell(ps_cp) {
13548        Ok(o) if !o.trim().is_empty() => {
13549            for line in o.lines().take(max_entries) {
13550                let l = line.trim();
13551                if !l.is_empty() {
13552                    let _ = writeln!(out, "- {l}");
13553                }
13554            }
13555        }
13556        _ => out.push_str("- Could not enumerate credential providers\n"),
13557    }
13558
13559    let mut findings: Vec<String> = Vec::with_capacity(4);
13560    if out.contains("WbioSrvc | Status: Stopped") {
13561        findings.push("Windows Biometric Service is stopped — Windows Hello face/fingerprint will not work until it is running.".into());
13562    }
13563    if out.contains("recent logon failure") && !out.contains("0 recent") {
13564        findings.push("Recent sign-in failures detected — check the Security event log for account lockout or credential issues.".into());
13565    }
13566
13567    let mut result = String::from("Host inspection: sign_in\n\n=== Findings ===\n");
13568    if findings.is_empty() {
13569        result.push_str("- No obvious sign-in or Windows Hello service failure detected.\n");
13570        result.push_str("  If Hello is prompting for PIN or won't recognize you, try Settings > Accounts > Sign-in options > Repair.\n");
13571    } else {
13572        for f in &findings {
13573            let _ = writeln!(result, "- Finding: {f}");
13574        }
13575    }
13576    result.push('\n');
13577    result.push_str(&out);
13578    Ok(result)
13579}
13580
13581#[cfg(not(windows))]
13582fn inspect_sign_in(_max_entries: usize) -> Result<String, String> {
13583    Ok("Host inspection: sign_in\nSign-in / Windows Hello inspection is Windows-only.".into())
13584}
13585
13586// ── inspect_installer_health ──────────────────────────────────────────────────
13587
13588#[cfg(windows)]
13589fn inspect_installer_health(max_entries: usize) -> Result<String, String> {
13590    let mut out = String::from("=== Installer engines ===\n");
13591
13592    let ps_engines = r#"
13593$services = 'msiserver','AppXSvc','ClipSVC','InstallService'
13594foreach ($name in $services) {
13595    $svc = Get-Service -Name $name -ErrorAction SilentlyContinue
13596    if ($svc) {
13597        $cim = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
13598        $startType = if ($cim) { $cim.StartMode } else { 'Unknown' }
13599        "$name | Status: $($svc.Status) | StartType: $startType"
13600    } else {
13601        "$name | Not present"
13602    }
13603}
13604if (Test-Path "$env:WINDIR\System32\msiexec.exe") {
13605    "msiexec.exe | Present: Yes"
13606} else {
13607    "msiexec.exe | Present: No"
13608}
13609"#;
13610    match run_powershell(ps_engines) {
13611        Ok(o) if !o.trim().is_empty() => {
13612            for line in o.lines().take(max_entries + 6) {
13613                let l = line.trim();
13614                if !l.is_empty() {
13615                    let _ = writeln!(out, "- {l}");
13616                }
13617            }
13618        }
13619        _ => out.push_str("- Could not inspect installer engine services\n"),
13620    }
13621
13622    out.push_str("\n=== winget and App Installer ===\n");
13623    let ps_winget = r#"
13624$cmd = Get-Command winget -ErrorAction SilentlyContinue
13625if ($cmd) {
13626    try {
13627        $v = & winget --version 2>$null
13628        if ($LASTEXITCODE -eq 0 -and $v) { "winget | Version: $v" } else { "winget | Present but version query failed" }
13629    } catch { "winget | Present but invocation failed" }
13630} else {
13631    "winget | Missing"
13632}
13633$appInstaller = Get-AppxPackage Microsoft.DesktopAppInstaller -ErrorAction SilentlyContinue
13634if ($appInstaller) {
13635    "DesktopAppInstaller | Version: $($appInstaller.Version) | Status: Present"
13636} else {
13637    "DesktopAppInstaller | Status: Missing"
13638}
13639"#;
13640    match run_powershell(ps_winget) {
13641        Ok(o) if !o.trim().is_empty() => {
13642            for line in o.lines().take(max_entries) {
13643                let l = line.trim();
13644                if !l.is_empty() {
13645                    let _ = writeln!(out, "- {l}");
13646                }
13647            }
13648        }
13649        _ => out.push_str("- Could not inspect winget/App Installer state\n"),
13650    }
13651
13652    out.push_str("\n=== Microsoft Store packages ===\n");
13653    let ps_store = r#"
13654$store = Get-AppxPackage Microsoft.WindowsStore -ErrorAction SilentlyContinue
13655if ($store) {
13656    "Microsoft.WindowsStore | Version: $($store.Version) | Status: Present"
13657} else {
13658    "Microsoft.WindowsStore | Status: Missing"
13659}
13660"#;
13661    match run_powershell(ps_store) {
13662        Ok(o) if !o.trim().is_empty() => {
13663            for line in o.lines().take(max_entries) {
13664                let l = line.trim();
13665                if !l.is_empty() {
13666                    let _ = writeln!(out, "- {l}");
13667                }
13668            }
13669        }
13670        _ => out.push_str("- Could not inspect Microsoft Store package state\n"),
13671    }
13672
13673    out.push_str("\n=== Reboot and transaction blockers ===\n");
13674    let ps_blockers = r#"
13675$pending = $false
13676if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
13677    "RebootPending: CBS"
13678    $pending = $true
13679}
13680if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
13681    "RebootPending: WindowsUpdate"
13682    $pending = $true
13683}
13684$rename = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations
13685if ($rename) {
13686    "PendingFileRenameOperations: Yes"
13687    $pending = $true
13688}
13689if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\InProgress') {
13690    "InstallerInProgress: Yes"
13691    $pending = $true
13692}
13693if (-not $pending) { "No pending reboot or installer-in-progress flag detected" }
13694"#;
13695    match run_powershell(ps_blockers) {
13696        Ok(o) if !o.trim().is_empty() => {
13697            for line in o.lines().take(max_entries) {
13698                let l = line.trim();
13699                if !l.is_empty() {
13700                    let _ = writeln!(out, "- {l}");
13701                }
13702            }
13703        }
13704        _ => out.push_str("- Could not inspect reboot or transaction blockers\n"),
13705    }
13706
13707    out.push_str("\n=== Recent installer failures (7d) ===\n");
13708    let ps_failures = r#"
13709$cutoff = (Get-Date).AddDays(-7)
13710$msi = Get-WinEvent -FilterHashtable @{ LogName='Application'; ProviderName='MsiInstaller'; StartTime=$cutoff; Level=2 } -MaxEvents 6 -ErrorAction SilentlyContinue |
13711    ForEach-Object { "MSI | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
13712$appx = Get-WinEvent -LogName 'Microsoft-Windows-AppXDeploymentServer/Operational' -ErrorAction SilentlyContinue -MaxEvents 30 |
13713    Where-Object { $_.LevelDisplayName -eq 'Error' -and $_.TimeCreated -ge $cutoff } |
13714    Select-Object -First 6 |
13715    ForEach-Object { "AppX | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
13716$all = @($msi) + @($appx)
13717if ($all.Count -eq 0) {
13718    "No recent MSI/AppX installer errors detected"
13719} else {
13720    $all | Select-Object -First 8
13721}
13722"#;
13723    match run_powershell(ps_failures) {
13724        Ok(o) if !o.trim().is_empty() => {
13725            for line in o.lines().take(max_entries + 2) {
13726                let l = line.trim();
13727                if !l.is_empty() {
13728                    let _ = writeln!(out, "- {l}");
13729                }
13730            }
13731        }
13732        _ => out.push_str("- Could not inspect recent installer failure events\n"),
13733    }
13734
13735    let mut findings: Vec<String> = Vec::with_capacity(4);
13736    if out.contains("msiserver | Status: Stopped | StartType: Disabled") {
13737        findings.push("Windows Installer service (msiserver) is disabled - MSI installs cannot start until it is re-enabled.".into());
13738    }
13739    if out.contains("msiexec.exe | Present: No") {
13740        findings.push("msiexec.exe is missing from System32 - MSI installs will fail until Windows Installer is repaired.".into());
13741    }
13742    if out.contains("winget | Missing") {
13743        findings.push(
13744            "winget is missing - App Installer may not be installed or registered for this user."
13745                .into(),
13746        );
13747    }
13748    if out.contains("DesktopAppInstaller | Status: Missing") {
13749        findings.push("Microsoft Desktop App Installer is missing - winget and some app-installer flows will be unavailable.".into());
13750    }
13751    if out.contains("Microsoft.WindowsStore | Status: Missing") {
13752        findings.push(
13753            "Microsoft Store package is missing - Store-sourced installs and repairs may not work."
13754                .into(),
13755        );
13756    }
13757    if out.contains("RebootPending:") || out.contains("PendingFileRenameOperations: Yes") {
13758        findings.push("A pending reboot is present - installer transactions may stay blocked until the machine restarts.".into());
13759    }
13760    if out.contains("InstallerInProgress: Yes") {
13761        findings.push("Windows reports an installer transaction already in progress - concurrent installs may fail until it clears.".into());
13762    }
13763    if out.contains("MSI | ") || out.contains("AppX | ") {
13764        findings.push("Recent installer failures were recorded in the event logs - check the MSI/AppX error lines below for the failing package or deployment path.".into());
13765    }
13766
13767    let mut result = String::from("Host inspection: installer_health\n\n=== Findings ===\n");
13768    if findings.is_empty() {
13769        result.push_str("- No obvious installer-platform blocker detected.\n");
13770    } else {
13771        for finding in &findings {
13772            let _ = writeln!(result, "- Finding: {finding}");
13773        }
13774    }
13775    result.push('\n');
13776    result.push_str(&out);
13777    Ok(result)
13778}
13779
13780#[cfg(not(windows))]
13781fn inspect_installer_health(_max_entries: usize) -> Result<String, String> {
13782    Ok("Host inspection: installer_health\n\n=== Findings ===\n- Installer health is currently Windows-first. Linux/macOS package-manager triage can be added later.\n".into())
13783}
13784
13785// ── inspect_search_index ──────────────────────────────────────────────────────
13786
13787#[cfg(windows)]
13788fn inspect_onedrive(max_entries: usize) -> Result<String, String> {
13789    let mut out = String::from("=== OneDrive client ===\n");
13790
13791    let ps_client = r#"
13792$candidatePaths = @(
13793    (Join-Path $env:LOCALAPPDATA 'Microsoft\OneDrive\OneDrive.exe'),
13794    (Join-Path $env:ProgramFiles 'Microsoft OneDrive\OneDrive.exe'),
13795    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft OneDrive\OneDrive.exe')
13796) | Where-Object { $_ -and (Test-Path $_) }
13797$proc = Get-Process OneDrive -ErrorAction SilentlyContinue | Select-Object -First 1
13798$exe = $candidatePaths | Select-Object -First 1
13799if (-not $exe -and $proc) {
13800    try { $exe = $proc.Path } catch {}
13801}
13802if ($exe) {
13803    "Installed: Yes"
13804    "Executable: $exe"
13805    try { "Version: $((Get-Item $exe).VersionInfo.FileVersion)" } catch {}
13806} else {
13807    "Installed: Unknown"
13808}
13809if ($proc) {
13810    "Process: Running | PID: $($proc.Id)"
13811} else {
13812    "Process: Not running"
13813}
13814"#;
13815    match run_powershell(ps_client) {
13816        Ok(o) if !o.trim().is_empty() => {
13817            for line in o.lines().take(max_entries) {
13818                let l = line.trim();
13819                if !l.is_empty() {
13820                    let _ = writeln!(out, "- {l}");
13821                }
13822            }
13823        }
13824        _ => out.push_str("- Could not inspect OneDrive client state\n"),
13825    }
13826
13827    out.push_str("\n=== OneDrive accounts ===\n");
13828    let ps_accounts = r#"
13829function MaskEmail([string]$Email) {
13830    if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
13831    $parts = $Email.Split('@', 2)
13832    $local = $parts[0]
13833    $domain = $parts[1]
13834    if ($local.Length -le 1) { return "*@$domain" }
13835    return ($local.Substring(0,1) + "***@" + $domain)
13836}
13837$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
13838if (Test-Path $base) {
13839    Get-ChildItem $base -ErrorAction SilentlyContinue |
13840        Sort-Object PSChildName |
13841        Select-Object -First 12 |
13842        ForEach-Object {
13843            $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13844            $kind = if ($_.PSChildName -eq 'Personal') { 'Personal' } else { 'Business' }
13845            $mail = MaskEmail ([string]$p.UserEmail)
13846            $root = if ([string]::IsNullOrWhiteSpace([string]$p.UserFolder)) { 'Unknown' } else { [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder) }
13847            $exists = if ($root -eq 'Unknown') { 'Unknown' } elseif (Test-Path $root) { 'Yes' } else { 'No' }
13848            "$kind | Email: $mail | SyncRoot: $root | Exists: $exists"
13849        }
13850} else {
13851    "No OneDrive accounts configured"
13852}
13853"#;
13854    match run_powershell(ps_accounts) {
13855        Ok(o) if !o.trim().is_empty() => {
13856            for line in o.lines().take(max_entries) {
13857                let l = line.trim();
13858                if !l.is_empty() {
13859                    let _ = writeln!(out, "- {l}");
13860                }
13861            }
13862        }
13863        _ => out.push_str("- Could not read OneDrive account registry state\n"),
13864    }
13865
13866    out.push_str("\n=== OneDrive policy overrides ===\n");
13867    let ps_policy = r#"
13868$paths = @(
13869    'HKLM:\SOFTWARE\Policies\Microsoft\OneDrive',
13870    'HKCU:\SOFTWARE\Policies\Microsoft\OneDrive'
13871)
13872$names = @(
13873    'DisableFileSyncNGSC',
13874    'DisableLibrariesDefaultSaveToOneDrive',
13875    'KFMSilentOptIn',
13876    'KFMBlockOptIn',
13877    'SilentAccountConfig'
13878)
13879$found = $false
13880foreach ($path in $paths) {
13881    if (Test-Path $path) {
13882        $p = Get-ItemProperty $path -ErrorAction SilentlyContinue
13883        foreach ($name in $names) {
13884            $value = $p.$name
13885            if ($null -ne $value -and [string]$value -ne '') {
13886                "$path | $name=$value"
13887                $found = $true
13888            }
13889        }
13890    }
13891}
13892if (-not $found) { "No OneDrive policy overrides detected" }
13893"#;
13894    match run_powershell(ps_policy) {
13895        Ok(o) if !o.trim().is_empty() => {
13896            for line in o.lines().take(max_entries) {
13897                let l = line.trim();
13898                if !l.is_empty() {
13899                    let _ = writeln!(out, "- {l}");
13900                }
13901            }
13902        }
13903        _ => out.push_str("- Could not read OneDrive policy state\n"),
13904    }
13905
13906    out.push_str("\n=== Known Folder Backup ===\n");
13907    let ps_kfm = r#"
13908$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
13909$roots = @()
13910if (Test-Path $base) {
13911    Get-ChildItem $base -ErrorAction SilentlyContinue | ForEach-Object {
13912        $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13913        if ($p.UserFolder) {
13914            $roots += [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder)
13915        }
13916    }
13917}
13918$roots = $roots | Select-Object -Unique
13919$shell = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders'
13920if (Test-Path $shell) {
13921    $props = Get-ItemProperty $shell -ErrorAction SilentlyContinue
13922    $folders = @(
13923        @{ Name='Desktop'; Value=$props.Desktop },
13924        @{ Name='Documents'; Value=$props.Personal },
13925        @{ Name='Pictures'; Value=$props.'My Pictures' }
13926    )
13927    foreach ($folder in $folders) {
13928        $path = [Environment]::ExpandEnvironmentVariables([string]$folder.Value)
13929        if ([string]::IsNullOrWhiteSpace($path)) { $path = 'Unknown' }
13930        $protected = $false
13931        foreach ($root in $roots) {
13932            if (-not [string]::IsNullOrWhiteSpace([string]$root) -and $path.ToLower().StartsWith($root.ToLower())) {
13933                $protected = $true
13934                break
13935            }
13936        }
13937        "$($folder.Name) | Path: $path | In OneDrive: $(if ($protected) { 'Yes' } else { 'No' })"
13938    }
13939} else {
13940    "Explorer shell folders unavailable"
13941}
13942"#;
13943    match run_powershell(ps_kfm) {
13944        Ok(o) if !o.trim().is_empty() => {
13945            for line in o.lines().take(max_entries) {
13946                let l = line.trim();
13947                if !l.is_empty() {
13948                    let _ = writeln!(out, "- {l}");
13949                }
13950            }
13951        }
13952        _ => out.push_str("- Could not inspect Known Folder Backup state\n"),
13953    }
13954
13955    let mut findings: Vec<String> = Vec::with_capacity(4);
13956    if out.contains("Installed: Unknown") && !out.contains("Process: Running") {
13957        findings.push("OneDrive client installation could not be confirmed from standard paths in this session.".into());
13958    }
13959    if out.contains("No OneDrive accounts configured") {
13960        findings.push(
13961            "No OneDrive accounts are configured - sync cannot start until the user signs in."
13962                .into(),
13963        );
13964    }
13965    if out.contains("Process: Not running") && !out.contains("No OneDrive accounts configured") {
13966        findings.push("OneDrive accounts exist but the sync client is not running - sync may be paused until OneDrive starts.".into());
13967    }
13968    if out.contains("Exists: No") {
13969        findings.push("One or more configured OneDrive sync roots do not exist on disk - account linkage or folder redirection may be broken.".into());
13970    }
13971    if out.contains("DisableFileSyncNGSC=1") {
13972        findings
13973            .push("A OneDrive policy is disabling the sync client (DisableFileSyncNGSC=1).".into());
13974    }
13975    if out.contains("KFMBlockOptIn=1") {
13976        findings
13977            .push("A policy is blocking Known Folder Backup enrollment (KFMBlockOptIn=1).".into());
13978    }
13979    if out.contains("SyncRoot: C:\\") {
13980        let mut missing_kfm: Vec<&str> = Vec::new();
13981        for folder in ["Desktop", "Documents", "Pictures"] {
13982            if out.lines().any(|line| {
13983                line.contains(&format!("{folder} | Path:")) && line.contains("| In OneDrive: No")
13984            }) {
13985                missing_kfm.push(folder);
13986            }
13987        }
13988        if !missing_kfm.is_empty() {
13989            findings.push(format!(
13990                "Known Folder Backup is not protecting {} - those folders are outside the OneDrive sync root.",
13991                missing_kfm.join(", ")
13992            ));
13993        }
13994    }
13995
13996    let mut result = String::from("Host inspection: onedrive\n\n=== Findings ===\n");
13997    if findings.is_empty() {
13998        result.push_str("- No obvious OneDrive client, account, or policy blocker detected.\n");
13999    } else {
14000        for finding in &findings {
14001            let _ = writeln!(result, "- Finding: {finding}");
14002        }
14003    }
14004    result.push('\n');
14005    result.push_str(&out);
14006    Ok(result)
14007}
14008
14009#[cfg(not(windows))]
14010fn inspect_onedrive(_max_entries: usize) -> Result<String, String> {
14011    Ok("Host inspection: onedrive\n\n=== Findings ===\n- OneDrive inspection is currently Windows-first. macOS/Linux support can be added later.\n".into())
14012}
14013
14014#[cfg(windows)]
14015fn inspect_browser_health(max_entries: usize) -> Result<String, String> {
14016    let mut out = String::from("=== Browser inventory ===\n");
14017
14018    let ps_inventory = r#"
14019$browsers = @(
14020    @{ Name='Edge'; Paths=@(
14021        (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'),
14022        (Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe')
14023    ); Profile=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data') },
14024    @{ Name='Chrome'; Paths=@(
14025        (Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe'),
14026        (Join-Path ${env:ProgramFiles(x86)} 'Google\Chrome\Application\chrome.exe'),
14027        (Join-Path $env:LOCALAPPDATA 'Google\Chrome\Application\chrome.exe')
14028    ); Profile=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data') },
14029    @{ Name='Firefox'; Paths=@(
14030        (Join-Path $env:ProgramFiles 'Mozilla Firefox\firefox.exe'),
14031        (Join-Path ${env:ProgramFiles(x86)} 'Mozilla Firefox\firefox.exe')
14032    ); Profile=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles') }
14033)
14034foreach ($browser in $browsers) {
14035    $exe = $browser.Paths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14036    if ($exe) {
14037        $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
14038        $profileExists = if (Test-Path $browser.Profile) { 'Yes' } else { 'No' }
14039        "$($browser.Name) | Installed: Yes | Version: $version | Executable: $exe | ProfileRoot: $($browser.Profile) | ProfileExists: $profileExists"
14040    } else {
14041        "$($browser.Name) | Installed: No"
14042    }
14043}
14044$httpProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
14045$httpsProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
14046$startMenuInternet = (Get-ItemProperty 'HKLM:\SOFTWARE\Clients\StartMenuInternet' -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
14047"DefaultHTTP: $(if ($httpProgId) { $httpProgId } else { 'Unknown' })"
14048"DefaultHTTPS: $(if ($httpsProgId) { $httpsProgId } else { 'Unknown' })"
14049"StartMenuInternet: $(if ($startMenuInternet) { $startMenuInternet } else { 'Unknown' })"
14050"#;
14051    match run_powershell(ps_inventory) {
14052        Ok(o) if !o.trim().is_empty() => {
14053            for line in o.lines().take(max_entries + 6) {
14054                let l = line.trim();
14055                if !l.is_empty() {
14056                    let _ = writeln!(out, "- {l}");
14057                }
14058            }
14059        }
14060        _ => out.push_str("- Could not inspect installed browser inventory\n"),
14061    }
14062
14063    out.push_str("\n=== Runtime state ===\n");
14064    let ps_runtime = r#"
14065$targets = 'msedge','chrome','firefox','msedgewebview2'
14066foreach ($name in $targets) {
14067    $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
14068    if ($procs) {
14069        $count = @($procs).Count
14070        $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14071        "$name | Processes: $count | WorkingSetMB: $wsMb"
14072    } else {
14073        "$name | Processes: 0 | WorkingSetMB: 0"
14074    }
14075}
14076"#;
14077    match run_powershell(ps_runtime) {
14078        Ok(o) if !o.trim().is_empty() => {
14079            for line in o.lines().take(max_entries + 4) {
14080                let l = line.trim();
14081                if !l.is_empty() {
14082                    let _ = writeln!(out, "- {l}");
14083                }
14084            }
14085        }
14086        _ => out.push_str("- Could not inspect browser runtime state\n"),
14087    }
14088
14089    out.push_str("\n=== WebView2 runtime ===\n");
14090    let ps_webview = r#"
14091$paths = @(
14092    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
14093    (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
14094) | Where-Object { $_ -and (Test-Path $_) }
14095$runtimeDir = $paths | ForEach-Object {
14096    Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
14097        Where-Object { $_.Name -match '^\d+\.' } |
14098        Sort-Object Name -Descending |
14099        Select-Object -First 1
14100} | Select-Object -First 1
14101if ($runtimeDir) {
14102    $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14103    $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14104    "Installed: Yes"
14105    "Version: $version"
14106    "Executable: $exe"
14107} else {
14108    "Installed: No"
14109}
14110$proc = Get-Process msedgewebview2 -ErrorAction SilentlyContinue
14111"ProcessCount: $(if ($proc) { @($proc).Count } else { 0 })"
14112"#;
14113    match run_powershell(ps_webview) {
14114        Ok(o) if !o.trim().is_empty() => {
14115            for line in o.lines().take(max_entries) {
14116                let l = line.trim();
14117                if !l.is_empty() {
14118                    let _ = writeln!(out, "- {l}");
14119                }
14120            }
14121        }
14122        _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14123    }
14124
14125    out.push_str("\n=== Policy and proxy surface ===\n");
14126    let ps_policy = r#"
14127$proxy = Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
14128$proxyEnabled = if ($null -ne $proxy.ProxyEnable) { $proxy.ProxyEnable } else { 'Unknown' }
14129$proxyServer = if ($proxy.ProxyServer) { $proxy.ProxyServer } else { 'Direct' }
14130$autoConfig = if ($proxy.AutoConfigURL) { $proxy.AutoConfigURL } else { 'None' }
14131$autoDetect = if ($null -ne $proxy.AutoDetect) { $proxy.AutoDetect } else { 'Unknown' }
14132"UserProxyEnabled: $proxyEnabled"
14133"UserProxyServer: $proxyServer"
14134"UserAutoConfigURL: $autoConfig"
14135"UserAutoDetect: $autoDetect"
14136$winhttp = (netsh winhttp show proxy 2>$null) -join ' '
14137if ($winhttp) {
14138    $normalized = ($winhttp -replace '\s+', ' ').Trim()
14139    $isDirect = $normalized -match 'Direct access \(no proxy server\)\.?$'
14140    "WinHTTPMode: $(if ($isDirect) { 'Direct' } else { 'Proxy' })"
14141    "WinHTTP: $normalized"
14142}
14143$policyTargets = @(
14144    @{ Name='Edge'; Path='HKLM:\SOFTWARE\Policies\Microsoft\Edge'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') },
14145    @{ Name='Chrome'; Path='HKLM:\SOFTWARE\Policies\Google\Chrome'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') }
14146)
14147foreach ($policy in $policyTargets) {
14148    if (Test-Path $policy.Path) {
14149        $item = Get-ItemProperty $policy.Path -ErrorAction SilentlyContinue
14150        foreach ($key in $policy.Keys) {
14151            $value = $item.$key
14152            if ($null -ne $value -and [string]$value -ne '') {
14153                if ($value -is [array]) {
14154                    "$($policy.Name)Policy | $key=$([string]::Join('; ', $value))"
14155                } else {
14156                    "$($policy.Name)Policy | $key=$value"
14157                }
14158            }
14159        }
14160    }
14161}
14162"#;
14163    match run_powershell(ps_policy) {
14164        Ok(o) if !o.trim().is_empty() => {
14165            for line in o.lines().take(max_entries + 8) {
14166                let l = line.trim();
14167                if !l.is_empty() {
14168                    let _ = writeln!(out, "- {l}");
14169                }
14170            }
14171        }
14172        _ => out.push_str("- Could not inspect browser policy or proxy state\n"),
14173    }
14174
14175    out.push_str("\n=== Profile and cache pressure ===\n");
14176    let ps_profiles = r#"
14177$profiles = @(
14178    @{ Name='Edge'; Root=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data\Default\Extensions') },
14179    @{ Name='Chrome'; Root=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data\Default\Extensions') },
14180    @{ Name='Firefox'; Root=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles'); ExtensionRoot=$null }
14181)
14182foreach ($profile in $profiles) {
14183    if (Test-Path $profile.Root) {
14184        if ($profile.Name -eq 'Firefox') {
14185            $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue
14186        } else {
14187            $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue |
14188                Where-Object {
14189                    $_.Name -eq 'Default' -or
14190                    $_.Name -eq 'Guest Profile' -or
14191                    $_.Name -eq 'System Profile' -or
14192                    $_.Name -like 'Profile *'
14193                }
14194        }
14195        $profileCount = @($dirs).Count
14196        $sizeBytes = (Get-ChildItem $profile.Root -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
14197        if (-not $sizeBytes) { $sizeBytes = 0 }
14198        $sizeGb = [Math]::Round(($sizeBytes / 1GB), 2)
14199        $extCount = 'Unknown'
14200        if ($profile.ExtensionRoot -and (Test-Path $profile.ExtensionRoot)) {
14201            $extCount = @((Get-ChildItem $profile.ExtensionRoot -Directory -ErrorAction SilentlyContinue)).Count
14202        }
14203        "$($profile.Name) | ProfileRoot: $($profile.Root) | Profiles: $profileCount | SizeGB: $sizeGb | Extensions: $extCount"
14204    } else {
14205        "$($profile.Name) | ProfileRoot: Missing"
14206    }
14207}
14208"#;
14209    match run_powershell(ps_profiles) {
14210        Ok(o) if !o.trim().is_empty() => {
14211            for line in o.lines().take(max_entries + 4) {
14212                let l = line.trim();
14213                if !l.is_empty() {
14214                    let _ = writeln!(out, "- {l}");
14215                }
14216            }
14217        }
14218        _ => out.push_str("- Could not inspect browser profile pressure\n"),
14219    }
14220
14221    out.push_str("\n=== Recent browser failures (7d) ===\n");
14222    let ps_failures = r#"
14223$cutoff = (Get-Date).AddDays(-7)
14224$targets = 'chrome.exe','msedge.exe','firefox.exe','msedgewebview2.exe'
14225$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 250 -ErrorAction SilentlyContinue |
14226    Where-Object {
14227        $msg = [string]$_.Message
14228        ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
14229        ($targets | Where-Object { $msg.ToLower().Contains($_.ToLower()) })
14230    } |
14231    Select-Object -First 6
14232if ($events) {
14233    foreach ($event in $events) {
14234        $msg = ($event.Message -replace '\s+', ' ')
14235        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14236        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14237    }
14238} else {
14239    "No recent browser crash or WER events detected"
14240}
14241"#;
14242    match run_powershell(ps_failures) {
14243        Ok(o) if !o.trim().is_empty() => {
14244            for line in o.lines().take(max_entries + 2) {
14245                let l = line.trim();
14246                if !l.is_empty() {
14247                    let _ = writeln!(out, "- {l}");
14248                }
14249            }
14250        }
14251        _ => out.push_str("- Could not inspect recent browser failure events\n"),
14252    }
14253
14254    let mut findings: Vec<String> = Vec::with_capacity(4);
14255    if out.contains("Edge | Installed: No")
14256        && out.contains("Chrome | Installed: No")
14257        && out.contains("Firefox | Installed: No")
14258    {
14259        findings.push(
14260            "No supported browser install was detected from the standard Edge/Chrome/Firefox paths."
14261                .into(),
14262        );
14263    }
14264    if out.contains("DefaultHTTP: Unknown") || out.contains("DefaultHTTPS: Unknown") {
14265        findings.push(
14266            "Default browser or protocol associations could not be read cleanly - links may open inconsistently."
14267                .into(),
14268        );
14269    }
14270    if out.contains("UserProxyEnabled: 1") || out.contains("WinHTTPMode: Proxy") {
14271        findings.push(
14272            "Proxy settings are active for this user or machine - browser sign-in and web-app failures may be proxy or PAC related."
14273                .into(),
14274        );
14275    }
14276    if out.contains("EdgePolicy | Proxy")
14277        || out.contains("ChromePolicy | Proxy")
14278        || out.contains("ExtensionInstallForcelist=")
14279    {
14280        findings.push(
14281            "Browser policy overrides are present - forced proxy or extension policy may be influencing web-app behavior."
14282                .into(),
14283        );
14284    }
14285    for browser in ["msedge", "chrome", "firefox"] {
14286        let process_marker = format!("{browser} | Processes: ");
14287        if let Some(line) = out.lines().find(|line| line.contains(&process_marker)) {
14288            let count = line
14289                .split("| Processes: ")
14290                .nth(1)
14291                .and_then(|rest| rest.split(" |").next())
14292                .and_then(|value| value.trim().parse::<usize>().ok())
14293                .unwrap_or(0);
14294            let ws_mb = line
14295                .split("| WorkingSetMB: ")
14296                .nth(1)
14297                .and_then(|value| value.trim().parse::<f64>().ok())
14298                .unwrap_or(0.0);
14299            if count >= 25 {
14300                findings.push(format!(
14301                    "{browser} is running {count} processes - extension or tab pressure may be dragging browser responsiveness."
14302                ));
14303            } else if ws_mb >= 2500.0 {
14304                findings.push(format!(
14305                    "{browser} is consuming {ws_mb:.1} MB of working set - browser memory pressure may be driving slowness or tab crashes."
14306                ));
14307            }
14308        }
14309    }
14310    if out.contains("=== WebView2 runtime ===\n- Installed: No")
14311        || (out.contains("=== WebView2 runtime ===")
14312            && out.contains("- Installed: No")
14313            && out.contains("- ProcessCount: 0"))
14314    {
14315        findings.push(
14316            "WebView2 runtime is missing - modern Windows apps that embed Edge web content may fail or render badly."
14317                .into(),
14318        );
14319    }
14320    for browser in ["Edge", "Chrome", "Firefox"] {
14321        let prefix = format!("{browser} | ProfileRoot:");
14322        if let Some(line) = out.lines().find(|line| line.contains(&prefix)) {
14323            let size_gb = line
14324                .split("| SizeGB: ")
14325                .nth(1)
14326                .and_then(|rest| rest.split(" |").next())
14327                .and_then(|value| value.trim().parse::<f64>().ok())
14328                .unwrap_or(0.0);
14329            let ext_count = line
14330                .split("| Extensions: ")
14331                .nth(1)
14332                .and_then(|value| value.trim().parse::<usize>().ok())
14333                .unwrap_or(0);
14334            if size_gb >= 2.5 {
14335                findings.push(format!(
14336                    "{browser} profile data is {size_gb:.2} GB - cache or profile bloat may be hurting startup and web-app responsiveness."
14337                ));
14338            }
14339            if ext_count >= 20 {
14340                findings.push(format!(
14341                    "{browser} has {ext_count} extensions in the default profile - extension overload can slow page loads and trigger conflicts."
14342                ));
14343            }
14344        }
14345    }
14346    if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14347        findings.push(
14348            "Recent browser crash evidence was found in the Application log - review the failure lines below for the browser or helper process that is faulting."
14349                .into(),
14350        );
14351    }
14352
14353    let mut result = String::from("Host inspection: browser_health\n\n=== Findings ===\n");
14354    if findings.is_empty() {
14355        result.push_str("- No obvious browser, proxy, or WebView2 health blocker detected.\n");
14356    } else {
14357        for finding in &findings {
14358            let _ = writeln!(result, "- Finding: {finding}");
14359        }
14360    }
14361    result.push('\n');
14362    result.push_str(&out);
14363    Ok(result)
14364}
14365
14366#[cfg(not(windows))]
14367fn inspect_browser_health(_max_entries: usize) -> Result<String, String> {
14368    Ok("Host inspection: browser_health\n\n=== Findings ===\n- Browser health is currently Windows-first. Linux/macOS browser triage can be added later.\n".into())
14369}
14370
14371#[cfg(windows)]
14372fn inspect_outlook(max_entries: usize) -> Result<String, String> {
14373    let mut out = String::from("=== Outlook install inventory ===\n");
14374
14375    let ps_install = r#"
14376$installPaths = @(
14377    (Join-Path $env:ProgramFiles 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
14378    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
14379    (Join-Path $env:ProgramFiles 'Microsoft Office\Office16\OUTLOOK.EXE'),
14380    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office16\OUTLOOK.EXE'),
14381    (Join-Path $env:ProgramFiles 'Microsoft Office\Office15\OUTLOOK.EXE'),
14382    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office15\OUTLOOK.EXE')
14383)
14384$exe = $installPaths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14385if ($exe) {
14386    $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
14387    $productName = try { (Get-Item $exe).VersionInfo.ProductName } catch { 'Unknown' }
14388    "Installed: Yes"
14389    "Executable: $exe"
14390    "Version: $version"
14391    "Product: $productName"
14392} else {
14393    "Installed: No"
14394}
14395$newOutlook = Get-AppxPackage -Name 'Microsoft.OutlookForWindows' -ErrorAction SilentlyContinue
14396if ($newOutlook) {
14397    "NewOutlook: Installed | Version: $($newOutlook.Version)"
14398} else {
14399    "NewOutlook: Not installed"
14400}
14401"#;
14402    match run_powershell(ps_install) {
14403        Ok(o) if !o.trim().is_empty() => {
14404            for line in o.lines().take(max_entries + 4) {
14405                let l = line.trim();
14406                if !l.is_empty() {
14407                    let _ = writeln!(out, "- {l}");
14408                }
14409            }
14410        }
14411        _ => out.push_str("- Could not inspect Outlook install paths\n"),
14412    }
14413
14414    out.push_str("\n=== Runtime state ===\n");
14415    let ps_runtime = r#"
14416$proc = Get-Process OUTLOOK -ErrorAction SilentlyContinue
14417if ($proc) {
14418    $count = @($proc).Count
14419    $wsMb = [Math]::Round((($proc | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14420    $cpuPct = try { [Math]::Round(($proc | Measure-Object CPU -Sum).Sum, 1) } catch { 0 }
14421    "Running: Yes | ProcessCount: $count | WorkingSetMB: $wsMb | CPUSeconds: $cpuPct"
14422} else {
14423    "Running: No"
14424}
14425"#;
14426    match run_powershell(ps_runtime) {
14427        Ok(o) if !o.trim().is_empty() => {
14428            for line in o.lines().take(4) {
14429                let l = line.trim();
14430                if !l.is_empty() {
14431                    let _ = writeln!(out, "- {l}");
14432                }
14433            }
14434        }
14435        _ => out.push_str("- Could not inspect Outlook runtime state\n"),
14436    }
14437
14438    out.push_str("\n=== Mail profiles ===\n");
14439    let ps_profiles = r#"
14440$profileKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Profiles'
14441if (-not (Test-Path $profileKey)) {
14442    $profileKey = 'HKCU:\Software\Microsoft\Office\15.0\Outlook\Profiles'
14443}
14444if (Test-Path $profileKey) {
14445    $profiles = Get-ChildItem $profileKey -ErrorAction SilentlyContinue
14446    $count = @($profiles).Count
14447    "ProfileCount: $count"
14448    foreach ($p in $profiles | Select-Object -First 10) {
14449        "Profile: $($p.PSChildName)"
14450    }
14451} else {
14452    "ProfileCount: 0"
14453    "No Outlook profiles found in registry"
14454}
14455"#;
14456    match run_powershell(ps_profiles) {
14457        Ok(o) if !o.trim().is_empty() => {
14458            for line in o.lines().take(max_entries + 2) {
14459                let l = line.trim();
14460                if !l.is_empty() {
14461                    let _ = writeln!(out, "- {l}");
14462                }
14463            }
14464        }
14465        _ => out.push_str("- Could not inspect Outlook mail profiles\n"),
14466    }
14467
14468    out.push_str("\n=== OST and PST data files ===\n");
14469    let ps_datafiles = r#"
14470$searchRoots = @(
14471    (Join-Path $env:LOCALAPPDATA 'Microsoft\Outlook'),
14472    (Join-Path $env:USERPROFILE 'Documents'),
14473    (Join-Path $env:USERPROFILE 'OneDrive\Documents')
14474) | Where-Object { $_ -and (Test-Path $_) }
14475$files = foreach ($root in $searchRoots) {
14476    Get-ChildItem $root -Include '*.ost','*.pst' -Recurse -ErrorAction SilentlyContinue -Force |
14477        Select-Object FullName,
14478            @{N='SizeMB';E={[Math]::Round($_.Length/1MB,1)}},
14479            @{N='Type';E={$_.Extension.TrimStart('.').ToUpper()}},
14480            LastWriteTime
14481}
14482if ($files) {
14483    foreach ($f in ($files | Sort-Object SizeMB -Descending | Select-Object -First 12)) {
14484        "$($f.Type) | $($f.FullName) | SizeMB: $($f.SizeMB) | LastWrite: $($f.LastWriteTime.ToString('yyyy-MM-dd'))"
14485    }
14486} else {
14487    "No OST or PST files found in standard locations"
14488}
14489"#;
14490    match run_powershell(ps_datafiles) {
14491        Ok(o) if !o.trim().is_empty() => {
14492            for line in o.lines().take(max_entries + 4) {
14493                let l = line.trim();
14494                if !l.is_empty() {
14495                    let _ = writeln!(out, "- {l}");
14496                }
14497            }
14498        }
14499        _ => out.push_str("- Could not inspect OST/PST data files\n"),
14500    }
14501
14502    out.push_str("\n=== Add-in pressure ===\n");
14503    let ps_addins = r#"
14504$addinPaths = @(
14505    'HKLM:\SOFTWARE\Microsoft\Office\Outlook\Addins',
14506    'HKCU:\SOFTWARE\Microsoft\Office\Outlook\Addins',
14507    'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Office\Outlook\Addins'
14508)
14509$addins = foreach ($path in $addinPaths) {
14510    if (Test-Path $path) {
14511        Get-ChildItem $path -ErrorAction SilentlyContinue | ForEach-Object {
14512            $item = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
14513            $loadBehavior = $item.LoadBehavior
14514            $desc = if ($item.Description) { $item.Description } else { $_.PSChildName }
14515            [PSCustomObject]@{ Name=$desc; LoadBehavior=$loadBehavior; Key=$_.PSChildName }
14516        }
14517    }
14518}
14519$enabledCount = ($addins | Where-Object { $_.LoadBehavior -band 1 }).Count
14520$disabledCount = ($addins | Where-Object { $_.LoadBehavior -eq 0 }).Count
14521"TotalAddins: $(@($addins).Count) | Active: $enabledCount | Disabled: $disabledCount"
14522foreach ($a in ($addins | Sort-Object LoadBehavior -Descending | Select-Object -First 15)) {
14523    $state = switch ($a.LoadBehavior) {
14524        0 { 'Disabled' }
14525        2 { 'LoadOnStart(inactive)' }
14526        3 { 'ActiveOnStart' }
14527        8 { 'DemandLoad' }
14528        9 { 'ActiveDemand' }
14529        16 { 'ConnectedFirst' }
14530        default { "LoadBehavior=$($a.LoadBehavior)" }
14531    }
14532    "$($a.Name) | $state"
14533}
14534$crashedKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DoNotDisableAddinList'
14535$disabledByResiliency = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DisabledItems'
14536if (Test-Path $disabledByResiliency) {
14537    $dis = Get-ItemProperty $disabledByResiliency -ErrorAction SilentlyContinue
14538    $count = ($dis.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' }).Count
14539    if ($count -gt 0) { "ResiliencyDisabledItems: $count (add-ins crashed and were auto-disabled)" }
14540}
14541"#;
14542    match run_powershell(ps_addins) {
14543        Ok(o) if !o.trim().is_empty() => {
14544            for line in o.lines().take(max_entries + 8) {
14545                let l = line.trim();
14546                if !l.is_empty() {
14547                    let _ = writeln!(out, "- {l}");
14548                }
14549            }
14550        }
14551        _ => out.push_str("- Could not inspect Outlook add-ins\n"),
14552    }
14553
14554    out.push_str("\n=== Authentication and cache friction ===\n");
14555    let ps_auth = r#"
14556$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14557$tokenCount = if (Test-Path $tokenCache) {
14558    @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
14559} else { 0 }
14560"TokenBrokerCacheFiles: $tokenCount"
14561$credentialManager = cmdkey /list 2>&1 | Select-String 'MicrosoftOffice|ADALCache|microsoftoffice|MsoOpenIdConnect'
14562$credsCount = @($credentialManager).Count
14563"OfficeCredentialsInVault: $credsCount"
14564$samlKey = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14565if (Test-Path $samlKey) {
14566    $id = Get-ItemProperty $samlKey -ErrorAction SilentlyContinue
14567    $connected = if ($id.ConnectedAccountWamOverride) { $id.ConnectedAccountWamOverride } else { 'Unknown' }
14568    $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
14569    "WAMOverride: $connected"
14570    "SignedInUserId: $signedIn"
14571}
14572$outlookReg = 'HKCU:\Software\Microsoft\Office\16.0\Outlook'
14573if (Test-Path $outlookReg) {
14574    $olk = Get-ItemProperty $outlookReg -ErrorAction SilentlyContinue
14575    if ($olk.DisableMAPI) { "DisableMAPI: $($olk.DisableMAPI)" }
14576}
14577"#;
14578    match run_powershell(ps_auth) {
14579        Ok(o) if !o.trim().is_empty() => {
14580            for line in o.lines().take(max_entries + 4) {
14581                let l = line.trim();
14582                if !l.is_empty() {
14583                    let _ = writeln!(out, "- {l}");
14584                }
14585            }
14586        }
14587        _ => out.push_str("- Could not inspect Outlook auth state\n"),
14588    }
14589
14590    out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
14591    let ps_events = r#"
14592$cutoff = (Get-Date).AddDays(-7)
14593$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14594    Where-Object {
14595        $msg = [string]$_.Message
14596        ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting' -or $_.ProviderName -eq 'Outlook') -and
14597        ($msg.ToLower().Contains('outlook') -or $msg.ToLower().Contains('mso.dll') -or $msg.ToLower().Contains('outllib.dll') -or $msg.ToLower().Contains('olmapi32.dll'))
14598    } |
14599    Select-Object -First 8
14600if ($events) {
14601    foreach ($event in $events) {
14602        $msg = ($event.Message -replace '\s+', ' ')
14603        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14604        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14605    }
14606} else {
14607    "No recent Outlook crash or error events detected in Application log"
14608}
14609"#;
14610    match run_powershell(ps_events) {
14611        Ok(o) if !o.trim().is_empty() => {
14612            for line in o.lines().take(max_entries + 4) {
14613                let l = line.trim();
14614                if !l.is_empty() {
14615                    let _ = writeln!(out, "- {l}");
14616                }
14617            }
14618        }
14619        _ => out.push_str("- Could not inspect Outlook event log evidence\n"),
14620    }
14621
14622    let mut findings: Vec<String> = Vec::with_capacity(4);
14623
14624    if out.contains("- Installed: No") && out.contains("- NewOutlook: Not installed") {
14625        findings.push(
14626            "Outlook is not installed — neither classic Office nor the new Outlook for Windows was found."
14627                .into(),
14628        );
14629    }
14630
14631    if let Some(line) = out.lines().find(|l| l.contains("WorkingSetMB:")) {
14632        let ws_mb = line
14633            .split("WorkingSetMB: ")
14634            .nth(1)
14635            .and_then(|r| r.split(" |").next())
14636            .and_then(|v| v.trim().parse::<f64>().ok())
14637            .unwrap_or(0.0);
14638        if ws_mb >= 1500.0 {
14639            findings.push(format!(
14640                "Outlook is consuming {ws_mb:.0} MB of RAM — add-in pressure, large OST files, or a corrupt profile may be driving memory growth."
14641            ));
14642        }
14643    }
14644
14645    let large_ost: Vec<String> = out
14646        .lines()
14647        .filter(|l| l.contains("SizeMB:") && l.contains("OST"))
14648        .filter_map(|l| {
14649            let mb = l
14650                .split("SizeMB: ")
14651                .nth(1)
14652                .and_then(|r| r.split(" |").next())
14653                .and_then(|v| v.trim().parse::<f64>().ok())
14654                .unwrap_or(0.0);
14655            if mb >= 10_000.0 {
14656                Some(format!("{mb:.0} MB OST file detected"))
14657            } else {
14658                None
14659            }
14660        })
14661        .collect();
14662    for msg in large_ost {
14663        findings.push(format!(
14664            "{msg} — large OST files can cause Outlook slowness, send/receive delays, and search index rebuild time."
14665        ));
14666    }
14667
14668    if let Some(line) = out.lines().find(|l| l.contains("TotalAddins:")) {
14669        let active_count = line
14670            .split("Active: ")
14671            .nth(1)
14672            .and_then(|r| r.split(" |").next())
14673            .and_then(|v| v.trim().parse::<usize>().ok())
14674            .unwrap_or(0);
14675        if active_count >= 8 {
14676            findings.push(format!(
14677                "{active_count} active Outlook add-ins detected — add-in overload is a common cause of slow Outlook startup, freezes, and crashes."
14678            ));
14679        }
14680    }
14681
14682    if out.contains("ResiliencyDisabledItems:") {
14683        findings.push(
14684            "Outlook's crash resiliency has auto-disabled one or more add-ins — look at the ResiliencyDisabledItems count and remove the offending add-in."
14685                .into(),
14686        );
14687    }
14688
14689    if out.contains("- ProfileCount: 0") || out.contains("- No Outlook profiles found") {
14690        findings.push(
14691            "No Outlook mail profiles were found in the registry — Outlook may not have been set up, or the profile may be corrupt."
14692                .into(),
14693        );
14694    }
14695
14696    if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14697        findings.push(
14698            "Recent Outlook crash evidence found in the Application event log — check the event lines below for the faulting module (mso.dll, outllib.dll, or an add-in DLL)."
14699                .into(),
14700        );
14701    }
14702
14703    let mut result = String::from("Host inspection: outlook\n\n=== Findings ===\n");
14704    if findings.is_empty() {
14705        result.push_str("- No obvious Outlook health blocker detected.\n");
14706    } else {
14707        for finding in &findings {
14708            let _ = writeln!(result, "- Finding: {finding}");
14709        }
14710    }
14711    result.push('\n');
14712    result.push_str(&out);
14713    Ok(result)
14714}
14715
14716#[cfg(not(windows))]
14717fn inspect_outlook(_max_entries: usize) -> Result<String, String> {
14718    Ok("Host inspection: outlook\n\n=== Findings ===\n- Outlook health inspection is Windows-only.\n".into())
14719}
14720
14721#[cfg(windows)]
14722fn inspect_teams(max_entries: usize) -> Result<String, String> {
14723    let mut out = String::from("=== Teams install inventory ===\n");
14724
14725    let ps_install = r#"
14726# Classic Teams (Teams 1.0)
14727$classicExe = @(
14728    (Join-Path $env:LOCALAPPDATA 'Microsoft\Teams\current\Teams.exe'),
14729    (Join-Path $env:ProgramFiles 'Microsoft\Teams\current\Teams.exe')
14730) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14731
14732if ($classicExe) {
14733    $ver = try { (Get-Item $classicExe).VersionInfo.FileVersion } catch { 'Unknown' }
14734    "ClassicTeams: Installed | Version: $ver | Path: $classicExe"
14735} else {
14736    "ClassicTeams: Not installed"
14737}
14738
14739# New Teams (Teams 2.0 / ms-teams.exe)
14740$newTeamsExe = @(
14741    (Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\ms-teams.exe'),
14742    (Join-Path $env:ProgramFiles 'WindowsApps\MSTeams_*\ms-teams.exe')
14743) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14744
14745$newTeamsPkg = Get-AppxPackage -Name 'MSTeams' -ErrorAction SilentlyContinue
14746if ($newTeamsPkg) {
14747    "NewTeams: Installed | Version: $($newTeamsPkg.Version) | PackageName: $($newTeamsPkg.PackageFullName)"
14748} elseif ($newTeamsExe) {
14749    $ver = try { (Get-Item $newTeamsExe).VersionInfo.FileVersion } catch { 'Unknown' }
14750    "NewTeams: Installed | Version: $ver | Path: $newTeamsExe"
14751} else {
14752    "NewTeams: Not installed"
14753}
14754
14755# Teams Machine-Wide Installer (MSI/per-machine)
14756$mwi = Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue |
14757    Where-Object { $_.DisplayName -like 'Teams Machine-Wide Installer*' } |
14758    Select-Object -First 1
14759if ($mwi) {
14760    "MachineWideInstaller: Installed | Version: $($mwi.DisplayVersion)"
14761} else {
14762    "MachineWideInstaller: Not found"
14763}
14764"#;
14765    match run_powershell(ps_install) {
14766        Ok(o) if !o.trim().is_empty() => {
14767            for line in o.lines().take(max_entries + 4) {
14768                let l = line.trim();
14769                if !l.is_empty() {
14770                    let _ = writeln!(out, "- {l}");
14771                }
14772            }
14773        }
14774        _ => out.push_str("- Could not inspect Teams install paths\n"),
14775    }
14776
14777    out.push_str("\n=== Runtime state ===\n");
14778    let ps_runtime = r#"
14779$targets = @('Teams','ms-teams')
14780foreach ($name in $targets) {
14781    $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
14782    if ($procs) {
14783        $count = @($procs).Count
14784        $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14785        "$name | Running: Yes | Processes: $count | WorkingSetMB: $wsMb"
14786    } else {
14787        "$name | Running: No"
14788    }
14789}
14790"#;
14791    match run_powershell(ps_runtime) {
14792        Ok(o) if !o.trim().is_empty() => {
14793            for line in o.lines().take(6) {
14794                let l = line.trim();
14795                if !l.is_empty() {
14796                    let _ = writeln!(out, "- {l}");
14797                }
14798            }
14799        }
14800        _ => out.push_str("- Could not inspect Teams runtime state\n"),
14801    }
14802
14803    out.push_str("\n=== Cache directory sizing ===\n");
14804    let ps_cache = r#"
14805$cachePaths = @(
14806    @{ Name='ClassicTeamsCache'; Path=(Join-Path $env:APPDATA 'Microsoft\Teams') },
14807    @{ Name='ClassicTeamsSquirrel'; Path=(Join-Path $env:LOCALAPPDATA 'Microsoft\Teams') },
14808    @{ Name='NewTeamsCache'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams') },
14809    @{ Name='NewTeamsAppData'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe') }
14810)
14811foreach ($entry in $cachePaths) {
14812    if (Test-Path $entry.Path) {
14813        $sizeBytes = (Get-ChildItem $entry.Path -Recurse -File -ErrorAction SilentlyContinue -Force | Measure-Object Length -Sum).Sum
14814        if (-not $sizeBytes) { $sizeBytes = 0 }
14815        $sizeMb = [Math]::Round($sizeBytes / 1MB, 1)
14816        "$($entry.Name) | Path: $($entry.Path) | SizeMB: $sizeMb"
14817    } else {
14818        "$($entry.Name) | Path: $($entry.Path) | Not found"
14819    }
14820}
14821"#;
14822    match run_powershell(ps_cache) {
14823        Ok(o) if !o.trim().is_empty() => {
14824            for line in o.lines().take(max_entries + 4) {
14825                let l = line.trim();
14826                if !l.is_empty() {
14827                    let _ = writeln!(out, "- {l}");
14828                }
14829            }
14830        }
14831        _ => out.push_str("- Could not inspect Teams cache directories\n"),
14832    }
14833
14834    out.push_str("\n=== WebView2 runtime ===\n");
14835    let ps_webview = r#"
14836$paths = @(
14837    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
14838    (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
14839) | Where-Object { $_ -and (Test-Path $_) }
14840$runtimeDir = $paths | ForEach-Object {
14841    Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
14842        Where-Object { $_.Name -match '^\d+\.' } |
14843        Sort-Object Name -Descending |
14844        Select-Object -First 1
14845} | Select-Object -First 1
14846if ($runtimeDir) {
14847    $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14848    $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14849    "Installed: Yes | Version: $version"
14850} else {
14851    "Installed: No -- New Teams and some Office features require WebView2"
14852}
14853"#;
14854    match run_powershell(ps_webview) {
14855        Ok(o) if !o.trim().is_empty() => {
14856            for line in o.lines().take(4) {
14857                let l = line.trim();
14858                if !l.is_empty() {
14859                    let _ = writeln!(out, "- {l}");
14860                }
14861            }
14862        }
14863        _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14864    }
14865
14866    out.push_str("\n=== Account and sign-in state ===\n");
14867    let ps_auth = r#"
14868# Classic Teams account registry
14869$classicAcct = 'HKCU:\Software\Microsoft\Office\Teams'
14870if (Test-Path $classicAcct) {
14871    $item = Get-ItemProperty $classicAcct -ErrorAction SilentlyContinue
14872    $email = if ($item.HomeUserUpn) { $item.HomeUserUpn } elseif ($item.LoggedInEmail) { $item.LoggedInEmail } else { 'Unknown' }
14873    "ClassicTeamsAccount: $email"
14874} else {
14875    "ClassicTeamsAccount: Not configured"
14876}
14877# WAM / token broker state for Teams
14878$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14879$tokenCount = if (Test-Path $tokenCache) {
14880    @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
14881} else { 0 }
14882"TokenBrokerCacheFiles: $tokenCount"
14883# Office identity
14884$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14885if (Test-Path $officeId) {
14886    $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
14887    $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
14888    "OfficeSignedInUserId: $signedIn"
14889}
14890# Check if Teams is in startup
14891$startupKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
14892$teamsRun = (Get-ItemProperty $startupKey -ErrorAction SilentlyContinue) | Select-Object -ExpandProperty 'com.squirrel.Teams.Teams' -ErrorAction SilentlyContinue
14893"TeamsInStartup: $(if ($teamsRun) { 'Yes (Classic)' } else { 'Not in user run key' })"
14894"#;
14895    match run_powershell(ps_auth) {
14896        Ok(o) if !o.trim().is_empty() => {
14897            for line in o.lines().take(max_entries + 4) {
14898                let l = line.trim();
14899                if !l.is_empty() {
14900                    let _ = writeln!(out, "- {l}");
14901                }
14902            }
14903        }
14904        _ => out.push_str("- Could not inspect Teams account state\n"),
14905    }
14906
14907    out.push_str("\n=== Audio and video device binding ===\n");
14908    let ps_devices = r#"
14909# Teams stores device prefs in the settings file
14910$settingsPaths = @(
14911    (Join-Path $env:APPDATA 'Microsoft\Teams\desktop-config.json'),
14912    (Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams\app_settings.json')
14913)
14914$found = $false
14915foreach ($sp in $settingsPaths) {
14916    if (Test-Path $sp) {
14917        $found = $true
14918        $raw = try { Get-Content $sp -Raw -ErrorAction SilentlyContinue } catch { $null }
14919        if ($raw) {
14920            $json = try { $raw | ConvertFrom-Json -ErrorAction SilentlyContinue } catch { $null }
14921            if ($json) {
14922                $mic = if ($json.currentAudioDevice) { $json.currentAudioDevice } elseif ($json.audioDevice) { $json.audioDevice } else { 'Default' }
14923                $spk = if ($json.currentSpeakerDevice) { $json.currentSpeakerDevice } elseif ($json.speakerDevice) { $json.speakerDevice } else { 'Default' }
14924                $cam = if ($json.currentVideoDevice) { $json.currentVideoDevice } elseif ($json.videoDevice) { $json.videoDevice } else { 'Default' }
14925                "ConfigFile: $sp"
14926                "Microphone: $mic"
14927                "Speaker: $spk"
14928                "Camera: $cam"
14929            } else {
14930                "ConfigFile: $sp (not parseable as JSON)"
14931            }
14932        } else {
14933            "ConfigFile: $sp (empty)"
14934        }
14935        break
14936    }
14937}
14938if (-not $found) {
14939    "NoTeamsConfigFile: Teams device prefs not found -- Teams may not have been launched yet or uses system defaults"
14940}
14941"#;
14942    match run_powershell(ps_devices) {
14943        Ok(o) if !o.trim().is_empty() => {
14944            for line in o.lines().take(max_entries + 4) {
14945                let l = line.trim();
14946                if !l.is_empty() {
14947                    let _ = writeln!(out, "- {l}");
14948                }
14949            }
14950        }
14951        _ => out.push_str("- Could not inspect Teams device binding\n"),
14952    }
14953
14954    out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
14955    let ps_events = r#"
14956$cutoff = (Get-Date).AddDays(-7)
14957$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14958    Where-Object {
14959        $msg = [string]$_.Message
14960        ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
14961        ($msg.ToLower().Contains('teams') -or $msg.ToLower().Contains('ms-teams') -or $msg.ToLower().Contains('msteams'))
14962    } |
14963    Select-Object -First 8
14964if ($events) {
14965    foreach ($event in $events) {
14966        $msg = ($event.Message -replace '\s+', ' ')
14967        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14968        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14969    }
14970} else {
14971    "No recent Teams crash or error events detected in Application log"
14972}
14973"#;
14974    match run_powershell(ps_events) {
14975        Ok(o) if !o.trim().is_empty() => {
14976            for line in o.lines().take(max_entries + 4) {
14977                let l = line.trim();
14978                if !l.is_empty() {
14979                    let _ = writeln!(out, "- {l}");
14980                }
14981            }
14982        }
14983        _ => out.push_str("- Could not inspect Teams event log evidence\n"),
14984    }
14985
14986    let mut findings: Vec<String> = Vec::with_capacity(4);
14987
14988    let classic_installed = out.contains("- ClassicTeams: Installed");
14989    let new_installed = out.contains("- NewTeams: Installed");
14990    if !classic_installed && !new_installed {
14991        findings.push("Neither classic Teams nor new Teams is installed on this machine.".into());
14992    }
14993
14994    for name in ["Teams", "ms-teams"] {
14995        let marker = format!("{name} | Running: Yes | Processes:");
14996        if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
14997            let ws_mb = line
14998                .split("WorkingSetMB: ")
14999                .nth(1)
15000                .and_then(|v| v.trim().parse::<f64>().ok())
15001                .unwrap_or(0.0);
15002            if ws_mb >= 1000.0 {
15003                findings.push(format!(
15004                    "{name} is consuming {ws_mb:.0} MB of RAM — cache bloat or a large number of channels/meetings loaded may be driving memory growth."
15005                ));
15006            }
15007        }
15008    }
15009
15010    for (label, threshold_mb) in [
15011        ("ClassicTeamsCache", 500.0_f64),
15012        ("ClassicTeamsSquirrel", 2000.0),
15013        ("NewTeamsCache", 500.0),
15014        ("NewTeamsAppData", 3000.0),
15015    ] {
15016        let marker = format!("{label} |");
15017        if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
15018            let mb = line
15019                .split("SizeMB: ")
15020                .nth(1)
15021                .and_then(|v| v.trim().parse::<f64>().ok())
15022                .unwrap_or(0.0);
15023            if mb >= threshold_mb {
15024                findings.push(format!(
15025                    "{label} is {mb:.0} MB — cache bloat at this size can cause Teams slowness, failed sign-in, and rendering glitches. Fix: quit Teams and delete the cache folder."
15026                ));
15027            }
15028        }
15029    }
15030
15031    if out.contains("- Installed: No -- New Teams") {
15032        findings.push(
15033            "WebView2 runtime is missing — new Teams requires WebView2 for rendering. Install it from Microsoft's WebView2 page or via winget install Microsoft.EdgeWebView2Runtime."
15034                .into(),
15035        );
15036    }
15037
15038    if out.contains("- ClassicTeamsAccount: Not configured")
15039        && out.contains("- OfficeSignedInUserId: None")
15040    {
15041        findings.push(
15042            "No Teams account is configured and Office sign-in is absent — Teams will fail to load meetings or channels until the user signs in."
15043                .into(),
15044        );
15045    }
15046
15047    if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
15048        findings.push(
15049            "Recent Teams crash evidence found in the Application event log — check the event lines below for the faulting module."
15050                .into(),
15051        );
15052    }
15053
15054    let mut result = String::from("Host inspection: teams\n\n=== Findings ===\n");
15055    if findings.is_empty() {
15056        result.push_str("- No obvious Teams health blocker detected.\n");
15057    } else {
15058        for finding in &findings {
15059            let _ = writeln!(result, "- Finding: {finding}");
15060        }
15061    }
15062    result.push('\n');
15063    result.push_str(&out);
15064    Ok(result)
15065}
15066
15067#[cfg(not(windows))]
15068fn inspect_teams(_max_entries: usize) -> Result<String, String> {
15069    Ok(
15070        "Host inspection: teams\n\n=== Findings ===\n- Teams health inspection is Windows-only.\n"
15071            .into(),
15072    )
15073}
15074
15075#[cfg(windows)]
15076fn inspect_identity_auth(max_entries: usize) -> Result<String, String> {
15077    let mut out = String::from("=== Identity broker services ===\n");
15078
15079    let ps_services = r#"
15080$serviceNames = 'TokenBroker','wlidsvc','OneAuth'
15081foreach ($name in $serviceNames) {
15082    $svc = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
15083    if ($svc) {
15084        "$($svc.Name) | Status: $($svc.State) | StartMode: $($svc.StartMode)"
15085    } else {
15086        "$name | Not found"
15087    }
15088}
15089"#;
15090    match run_powershell(ps_services) {
15091        Ok(o) if !o.trim().is_empty() => {
15092            for line in o.lines().take(max_entries) {
15093                let l = line.trim();
15094                if !l.is_empty() {
15095                    let _ = writeln!(out, "- {l}");
15096                }
15097            }
15098        }
15099        _ => out.push_str("- Could not inspect identity broker services\n"),
15100    }
15101
15102    out.push_str("\n=== Device registration ===\n");
15103    let ps_device = r#"
15104$dsreg = Get-Command dsregcmd.exe -ErrorAction SilentlyContinue
15105if ($dsreg) {
15106    try {
15107        $raw = & $dsreg.Source /status 2>$null
15108        $text = ($raw -join "`n")
15109        $keys = 'AzureAdJoined','WorkplaceJoined','DomainJoined','DeviceAuthStatus','TenantName','AzureAdPrt','WamDefaultSet'
15110        $seen = $false
15111        foreach ($key in $keys) {
15112            $match = [regex]::Match($text, '(?im)^\s*' + [regex]::Escape($key) + '\s*:\s*(.+)$')
15113            if ($match.Success) {
15114                "${key}: $($match.Groups[1].Value.Trim())"
15115                $seen = $true
15116            }
15117        }
15118        if (-not $seen) {
15119            "DeviceRegistration: dsregcmd returned no recognizable registration fields (common on personal or unmanaged devices)"
15120        }
15121    } catch {
15122        "DeviceRegistration: dsregcmd failed - $($_.Exception.Message)"
15123    }
15124} else {
15125    "DeviceRegistration: dsregcmd unavailable"
15126}
15127"#;
15128    match run_powershell(ps_device) {
15129        Ok(o) if !o.trim().is_empty() => {
15130            for line in o.lines().take(max_entries + 4) {
15131                let l = line.trim();
15132                if !l.is_empty() {
15133                    let _ = writeln!(out, "- {l}");
15134                }
15135            }
15136        }
15137        _ => out.push_str(
15138            "- DeviceRegistration: Could not inspect device registration state in this session\n",
15139        ),
15140    }
15141
15142    out.push_str("\n=== Broker packages and caches ===\n");
15143    let ps_broker = r#"
15144$pkg = Get-AppxPackage -Name 'Microsoft.AAD.BrokerPlugin' -ErrorAction SilentlyContinue | Select-Object -First 1
15145if ($pkg) {
15146    "AADBrokerPlugin: Installed | Version: $($pkg.Version)"
15147} else {
15148    "AADBrokerPlugin: Not installed"
15149}
15150$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
15151$tokenCount = if (Test-Path $tokenCache) { @(Get-ChildItem $tokenCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15152"TokenBrokerCacheFiles: $tokenCount"
15153$identityCache = Join-Path $env:LOCALAPPDATA 'Microsoft\IdentityCache'
15154$identityCount = if (Test-Path $identityCache) { @(Get-ChildItem $identityCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15155"IdentityCacheFiles: $identityCount"
15156$oneAuth = Join-Path $env:LOCALAPPDATA 'Microsoft\OneAuth'
15157$oneAuthCount = if (Test-Path $oneAuth) { @(Get-ChildItem $oneAuth -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15158"OneAuthFiles: $oneAuthCount"
15159"#;
15160    match run_powershell(ps_broker) {
15161        Ok(o) if !o.trim().is_empty() => {
15162            for line in o.lines().take(max_entries + 4) {
15163                let l = line.trim();
15164                if !l.is_empty() {
15165                    let _ = writeln!(out, "- {l}");
15166                }
15167            }
15168        }
15169        _ => out.push_str("- Could not inspect identity broker packages or caches\n"),
15170    }
15171
15172    out.push_str("\n=== Microsoft app account signals ===\n");
15173    let ps_accounts = r#"
15174function MaskEmail([string]$Email) {
15175    if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
15176    $parts = $Email.Split('@', 2)
15177    $local = $parts[0]
15178    $domain = $parts[1]
15179    if ($local.Length -le 1) { return "*@$domain" }
15180    return ($local.Substring(0,1) + "***@" + $domain)
15181}
15182$allAccounts = @()
15183$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
15184if (Test-Path $officeId) {
15185    $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
15186    if ($id.SignedInUserId) {
15187        $allAccounts += [string]$id.SignedInUserId
15188        "OfficeSignedInUserId: $(MaskEmail ([string]$id.SignedInUserId))"
15189    } else {
15190        "OfficeSignedInUserId: None"
15191    }
15192} else {
15193    "OfficeSignedInUserId: Not configured"
15194}
15195$teamsAcct = 'HKCU:\Software\Microsoft\Office\Teams'
15196if (Test-Path $teamsAcct) {
15197    $item = Get-ItemProperty $teamsAcct -ErrorAction SilentlyContinue
15198    $email = if ($item.HomeUserUpn) { [string]$item.HomeUserUpn } elseif ($item.LoggedInEmail) { [string]$item.LoggedInEmail } else { '' }
15199    if (-not [string]::IsNullOrWhiteSpace($email)) {
15200        $allAccounts += $email
15201        "TeamsAccount: $(MaskEmail $email)"
15202    } else {
15203        "TeamsAccount: Unknown"
15204    }
15205} else {
15206    "TeamsAccount: Not configured"
15207}
15208$oneDriveBase = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
15209$oneDriveEmails = @()
15210if (Test-Path $oneDriveBase) {
15211    $oneDriveEmails = Get-ChildItem $oneDriveBase -ErrorAction SilentlyContinue |
15212        ForEach-Object {
15213            $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
15214            if ($p.UserEmail) { [string]$p.UserEmail }
15215        } |
15216        Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
15217        Sort-Object -Unique
15218}
15219$allAccounts += $oneDriveEmails
15220"OneDriveAccountCount: $(@($oneDriveEmails).Count)"
15221if (@($oneDriveEmails).Count -gt 0) {
15222    "OneDriveAccounts: $((@($oneDriveEmails) | ForEach-Object { MaskEmail $_ }) -join ', ')"
15223}
15224$distinct = @($allAccounts | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
15225"DistinctIdentityCount: $($distinct.Count)"
15226if ($distinct.Count -gt 0) {
15227    "IdentitySet: $((@($distinct) | ForEach-Object { MaskEmail $_ }) -join ', ')"
15228}
15229"#;
15230    match run_powershell(ps_accounts) {
15231        Ok(o) if !o.trim().is_empty() => {
15232            for line in o.lines().take(max_entries + 6) {
15233                let l = line.trim();
15234                if !l.is_empty() {
15235                    let _ = writeln!(out, "- {l}");
15236                }
15237            }
15238        }
15239        _ => out.push_str("- Could not inspect Microsoft app identity state\n"),
15240    }
15241
15242    out.push_str("\n=== WebView2 auth dependency ===\n");
15243    let ps_webview = r#"
15244$paths = @(
15245    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
15246    (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
15247) | Where-Object { $_ -and (Test-Path $_) }
15248$runtimeDir = $paths | ForEach-Object {
15249    Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
15250        Where-Object { $_.Name -match '^\d+\.' } |
15251        Sort-Object Name -Descending |
15252        Select-Object -First 1
15253} | Select-Object -First 1
15254if ($runtimeDir) {
15255    $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
15256    $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
15257    "WebView2: Installed | Version: $version"
15258} else {
15259    "WebView2: Not installed"
15260}
15261"#;
15262    match run_powershell(ps_webview) {
15263        Ok(o) if !o.trim().is_empty() => {
15264            for line in o.lines().take(4) {
15265                let l = line.trim();
15266                if !l.is_empty() {
15267                    let _ = writeln!(out, "- {l}");
15268                }
15269            }
15270        }
15271        _ => out.push_str("- Could not inspect WebView2 runtime\n"),
15272    }
15273
15274    out.push_str("\n=== Recent auth-related events (24h) ===\n");
15275    let ps_events = r#"
15276try {
15277    $cutoff = (Get-Date).AddHours(-24)
15278    $events = @()
15279    if (Get-WinEvent -ListLog 'Microsoft-Windows-AAD/Operational' -ErrorAction SilentlyContinue) {
15280        $events += Get-WinEvent -FilterHashtable @{ LogName='Microsoft-Windows-AAD/Operational'; StartTime=$cutoff } -MaxEvents 30 -ErrorAction SilentlyContinue |
15281            Where-Object { $_.LevelDisplayName -in @('Error','Warning') } |
15282            Select-Object -First 4
15283    }
15284    $events += Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 80 -ErrorAction SilentlyContinue |
15285        Where-Object {
15286            ($_.LevelDisplayName -in @('Error','Warning')) -and (
15287                $_.ProviderName -match 'Outlook|Teams|OneDrive|Office|AAD|TokenBroker|Broker'
15288                -or $_.Message -match 'Outlook|Teams|OneDrive|sign-?in|authentication|TokenBroker|BrokerPlugin|AAD'
15289            )
15290        } |
15291        Select-Object -First 6
15292    $events = $events | Sort-Object TimeCreated -Descending | Select-Object -First 8
15293    "AuthEventCount: $(@($events).Count)"
15294    if ($events) {
15295        foreach ($e in $events) {
15296            $msg = if ([string]::IsNullOrWhiteSpace([string]$e.Message)) {
15297                'No message'
15298            } else {
15299                ($e.Message -replace '\r','' -split '\n')[0] -replace '\|','/'
15300            }
15301            "$($e.TimeCreated.ToString('MM-dd HH:mm')) | Provider: $($e.ProviderName) | Level: $($e.LevelDisplayName) | Id: $($e.Id) | $msg"
15302        }
15303    } else {
15304        "No auth-related warning/error events detected"
15305    }
15306} catch {
15307    "AuthEventStatus: Could not inspect auth-related events - $($_.Exception.Message)"
15308}
15309"#;
15310    match run_powershell(ps_events) {
15311        Ok(o) if !o.trim().is_empty() => {
15312            for line in o.lines().take(max_entries + 8) {
15313                let l = line.trim();
15314                if !l.is_empty() {
15315                    let _ = writeln!(out, "- {l}");
15316                }
15317            }
15318        }
15319        _ => out
15320            .push_str("- AuthEventStatus: Could not inspect auth-related events in this session\n"),
15321    }
15322
15323    let parse_count = |prefix: &str| -> Option<u64> {
15324        out.lines().find_map(|line| {
15325            line.trim()
15326                .strip_prefix(prefix)
15327                .and_then(|value| value.trim().parse::<u64>().ok())
15328        })
15329    };
15330
15331    let distinct_identity_count = parse_count("- DistinctIdentityCount: ").unwrap_or(0);
15332    let auth_event_count = parse_count("- AuthEventCount: ").unwrap_or(0);
15333
15334    let mut findings: Vec<String> = Vec::with_capacity(4);
15335    if out.contains("TokenBroker | Status: Stopped")
15336        || out.contains("wlidsvc | Status: Stopped")
15337        || out.contains("OneAuth | Status: Stopped")
15338    {
15339        findings.push(
15340            "One or more Microsoft identity broker services are stopped - Outlook, Teams, OneDrive, or Microsoft 365 sign-in can loop or fail until WAM services are running."
15341                .into(),
15342        );
15343    }
15344    if out.contains("AADBrokerPlugin: Not installed") {
15345        findings.push(
15346            "Microsoft AAD Broker Plugin is missing - work/school account sign-in and token refresh can fail without the broker package."
15347                .into(),
15348        );
15349    }
15350    if out.contains("WebView2: Not installed") {
15351        findings.push(
15352            "WebView2 runtime is missing - modern Microsoft 365 sign-in surfaces may fail or render badly without it."
15353                .into(),
15354        );
15355    }
15356    if distinct_identity_count > 1 {
15357        findings.push(format!(
15358            "{distinct_identity_count} distinct Microsoft identity signals were detected across Office, Teams, and OneDrive - account mismatch can cause repeated sign-in prompts or the wrong tenant opening."
15359        ));
15360    }
15361    if (out.contains("AzureAdJoined: NO") || out.contains("WorkplaceJoined: NO"))
15362        && distinct_identity_count > 0
15363    {
15364        findings.push(
15365            "This machine shows Microsoft app identities but weak device-registration signals - organizational SSO, Conditional Access, or silent token refresh may be limited."
15366                .into(),
15367        );
15368    }
15369    if out.contains("DeviceRegistration: dsregcmd")
15370        || out.contains("DeviceRegistration: Could not inspect device registration state")
15371    {
15372        findings.push(
15373            "Device-registration visibility is partial in this session - personal devices are often fine here, but managed Microsoft 365 SSO posture may need dsregcmd details to confirm."
15374                .into(),
15375        );
15376    }
15377    if auth_event_count > 0 {
15378        findings.push(format!(
15379            "{auth_event_count} recent auth-related warning/error event(s) were found - the event section may explain repeated prompts, broker failures, or account-sync issues."
15380        ));
15381    } else if out.contains("AuthEventStatus: Could not inspect auth-related events") {
15382        findings.push(
15383            "Auth-related event visibility is partial in this session - the machine may still be healthy, but Hematite could not confirm recent broker or sign-in events."
15384                .into(),
15385        );
15386    }
15387
15388    let mut result = String::from("Host inspection: identity_auth\n\n=== Findings ===\n");
15389    if findings.is_empty() {
15390        result.push_str("- No obvious Microsoft 365 identity broker, token cache, or device-registration blocker detected.\n");
15391    } else {
15392        for finding in &findings {
15393            let _ = writeln!(result, "- Finding: {finding}");
15394        }
15395    }
15396    result.push('\n');
15397    result.push_str(&out);
15398    Ok(result)
15399}
15400
15401#[cfg(not(windows))]
15402fn inspect_identity_auth(_max_entries: usize) -> Result<String, String> {
15403    Ok("Host inspection: identity_auth\n\n=== Findings ===\n- Microsoft 365 identity-broker inspection is currently Windows-first. macOS/Linux support can be added later.\n".into())
15404}
15405
15406#[cfg(windows)]
15407fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
15408    let mut out = String::from("=== File History ===\n");
15409
15410    let ps_fh = r#"
15411$svc = Get-Service fhsvc -ErrorAction SilentlyContinue
15412if ($svc) {
15413    "FileHistoryService: $($svc.Status) | StartType: $($svc.StartType)"
15414} else {
15415    "FileHistoryService: Not found"
15416}
15417# File History config in registry
15418$fhKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SPP\UserPolicy\S-1-5-21*\d5c93fba*'
15419$fhUser = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\FileHistory'
15420if (Test-Path $fhUser) {
15421    $fh = Get-ItemProperty $fhUser -ErrorAction SilentlyContinue
15422    $enabled = if ($fh.Enabled -eq 0) { 'Disabled' } elseif ($fh.Enabled -eq 1) { 'Enabled' } else { 'Unknown' }
15423    $target = if ($fh.TargetUrl) { $fh.TargetUrl } else { 'Not configured' }
15424    $lastBackup = if ($fh.ProtectedUpToTime) {
15425        try { [DateTime]::FromFileTime($fh.ProtectedUpToTime).ToString('yyyy-MM-dd HH:mm') } catch { 'Unknown' }
15426    } else { 'Never' }
15427    "Enabled: $enabled"
15428    "BackupDrive: $target"
15429    "LastBackup: $lastBackup"
15430} else {
15431    "Enabled: Not configured"
15432    "BackupDrive: Not configured"
15433    "LastBackup: Never"
15434}
15435"#;
15436    match run_powershell(ps_fh) {
15437        Ok(o) if !o.trim().is_empty() => {
15438            for line in o.lines().take(6) {
15439                let l = line.trim();
15440                if !l.is_empty() {
15441                    let _ = writeln!(out, "- {l}");
15442                }
15443            }
15444        }
15445        _ => out.push_str("- Could not inspect File History state\n"),
15446    }
15447
15448    out.push_str("\n=== Windows Backup (wbadmin) ===\n");
15449    let ps_wbadmin = r#"
15450$svc = Get-Service wbengine -ErrorAction SilentlyContinue
15451"WindowsBackupEngine: $(if ($svc) { "$($svc.Status) | StartType: $($svc.StartType)" } else { 'Not found' })"
15452# Last backup from wbadmin
15453$raw = try { wbadmin get versions 2>&1 | Select-Object -First 30 } catch { $null }
15454if ($raw -and ($raw -join ' ') -notmatch 'no backup') {
15455    $lastDate = ($raw | Select-String 'Backup time:' | Select-Object -First 1).Line
15456    $lastTarget = ($raw | Select-String 'Backup target:' | Select-Object -First 1).Line
15457    if ($lastDate) { $lastDate.Trim() }
15458    if ($lastTarget) { $lastTarget.Trim() }
15459} else {
15460    "LastWbadminBackup: No backup versions found"
15461}
15462# Task-based backup
15463$task = Get-ScheduledTask -TaskPath '\Microsoft\Windows\WindowsBackup\' -ErrorAction SilentlyContinue
15464foreach ($t in $task) {
15465    "BackupTask: $($t.TaskName) | State: $($t.State)"
15466}
15467"#;
15468    match run_powershell(ps_wbadmin) {
15469        Ok(o) if !o.trim().is_empty() => {
15470            for line in o.lines().take(8) {
15471                let l = line.trim();
15472                if !l.is_empty() {
15473                    let _ = writeln!(out, "- {l}");
15474                }
15475            }
15476        }
15477        _ => out.push_str("- Could not inspect Windows Backup state\n"),
15478    }
15479
15480    out.push_str("\n=== System Restore ===\n");
15481    let ps_sr = r#"
15482$drives = Get-WmiObject -Class Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction SilentlyContinue |
15483    Select-Object -ExpandProperty DeviceID
15484foreach ($drive in $drives) {
15485    $protection = try {
15486        (Get-ComputerRestorePoint -Drive "$drive\" -ErrorAction SilentlyContinue)
15487    } catch { $null }
15488    $srReg = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore"
15489    $rpConf = try {
15490        Get-ItemProperty "$srReg" -ErrorAction SilentlyContinue
15491    } catch { $null }
15492    # Check if SR is disabled for this drive
15493    $disabled = $false
15494    $vssService = Get-Service VSS -ErrorAction SilentlyContinue
15495    "Drive: $drive | VSSService: $(if ($vssService) { $vssService.Status } else { 'Not found' })"
15496}
15497# Most recent restore point
15498$points = try { Get-ComputerRestorePoint -ErrorAction SilentlyContinue } catch { $null }
15499if ($points) {
15500    $latest = $points | Sort-Object SequenceNumber -Descending | Select-Object -First 1
15501    $date = try { [Management.ManagementDateTimeConverter]::ToDateTime($latest.CreationTime).ToString('yyyy-MM-dd HH:mm') } catch { $latest.CreationTime }
15502    "MostRecentRestorePoint: $($latest.Description) | Created: $date"
15503} else {
15504    "MostRecentRestorePoint: None found"
15505}
15506$srEnabled = try {
15507    $regVal = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' -ErrorAction SilentlyContinue).RPSessionInterval
15508    if ($null -eq $regVal) { 'Enabled (default)' } elseif ($regVal -eq 0) { 'Disabled' } else { "Interval: $regVal" }
15509} catch { 'Unknown' }
15510"SystemRestoreState: $srEnabled"
15511"#;
15512    match run_powershell(ps_sr) {
15513        Ok(o) if !o.trim().is_empty() => {
15514            for line in o.lines().take(8) {
15515                let l = line.trim();
15516                if !l.is_empty() {
15517                    let _ = writeln!(out, "- {l}");
15518                }
15519            }
15520        }
15521        _ => out.push_str("- Could not inspect System Restore state\n"),
15522    }
15523
15524    out.push_str("\n=== OneDrive backup (Known Folder Move) ===\n");
15525    let ps_kfm = r#"
15526$kfmKey = 'HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts'
15527if (Test-Path $kfmKey) {
15528    $accounts = Get-ChildItem $kfmKey -ErrorAction SilentlyContinue
15529    foreach ($acct in $accounts | Select-Object -First 3) {
15530        $props = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
15531        $email = $props.UserEmail
15532        $kfmDesktop = $props.'KFMSilentOptInDesktop'
15533        $kfmDocs = $props.'KFMSilentOptInDocuments'
15534        $kfmPics = $props.'KFMSilentOptInPictures'
15535        "Account: $email | KFM-Desktop: $(if ($kfmDesktop) { 'Protected' } else { 'Not enrolled' }) | KFM-Docs: $(if ($kfmDocs) { 'Protected' } else { 'Not enrolled' }) | KFM-Pics: $(if ($kfmPics) { 'Protected' } else { 'Not enrolled' })"
15536    }
15537} else {
15538    "OneDriveKFM: No OneDrive accounts found"
15539}
15540"#;
15541    match run_powershell(ps_kfm) {
15542        Ok(o) if !o.trim().is_empty() => {
15543            for line in o.lines().take(6) {
15544                let l = line.trim();
15545                if !l.is_empty() {
15546                    let _ = writeln!(out, "- {l}");
15547                }
15548            }
15549        }
15550        _ => out.push_str("- Could not inspect OneDrive Known Folder Move state\n"),
15551    }
15552
15553    out.push_str("\n=== Recent backup failure events (7d) ===\n");
15554    let ps_events = r#"
15555$cutoff = (Get-Date).AddDays(-7)
15556$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
15557    Where-Object {
15558        $_.ProviderName -match 'backup|FileHistory|wbengine|Microsoft-Windows-Backup' -or
15559        ($_.Id -in @(49,50,517,521) -and $_.LogName -eq 'Application')
15560    } |
15561    Where-Object { $_.Level -le 3 } |
15562    Select-Object -First 6
15563if ($events) {
15564    foreach ($event in $events) {
15565        $msg = ($event.Message -replace '\s+', ' ')
15566        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
15567        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
15568    }
15569} else {
15570    "No recent backup failure events detected"
15571}
15572"#;
15573    match run_powershell(ps_events) {
15574        Ok(o) if !o.trim().is_empty() => {
15575            for line in o.lines().take(8) {
15576                let l = line.trim();
15577                if !l.is_empty() {
15578                    let _ = writeln!(out, "- {l}");
15579                }
15580            }
15581        }
15582        _ => out.push_str("- Could not inspect backup failure events\n"),
15583    }
15584
15585    let mut findings: Vec<String> = Vec::with_capacity(4);
15586
15587    let fh_enabled = out.contains("- Enabled: Enabled");
15588    let fh_never =
15589        out.contains("- LastBackup: Never") || out.contains("- LastBackup: Not configured");
15590    let no_wbadmin = out.contains("No backup versions found");
15591    let no_restore_point = out.contains("MostRecentRestorePoint: None found");
15592
15593    if !fh_enabled && no_wbadmin {
15594        findings.push(
15595            "No backup solution detected — File History is not enabled and no Windows Backup versions were found. This machine has no local recovery path if data is lost or corrupted.".into(),
15596        );
15597    } else if fh_enabled && fh_never {
15598        findings.push(
15599            "File History is enabled but has never completed a backup — check that the backup drive is connected and accessible.".into(),
15600        );
15601    }
15602
15603    if no_restore_point {
15604        findings.push(
15605            "No System Restore points exist — if a driver or update goes wrong there is no local rollback point available.".into(),
15606        );
15607    }
15608
15609    if out.contains("- FileHistoryService: Stopped")
15610        || out.contains("- FileHistoryService: Not found")
15611    {
15612        findings.push(
15613            "File History service (fhsvc) is stopped or missing — File History backups cannot run until the service is started.".into(),
15614        );
15615    }
15616
15617    if out.contains("Application Error |")
15618        || out.contains("Microsoft-Windows-Backup |")
15619        || out.contains("wbengine |")
15620    {
15621        findings.push(
15622            "Recent backup failure events found in the Application log — check the event lines below for the specific error.".into(),
15623        );
15624    }
15625
15626    let mut result = String::from("Host inspection: windows_backup\n\n=== Findings ===\n");
15627    if findings.is_empty() {
15628        result.push_str("- No obvious backup health blocker detected.\n");
15629    } else {
15630        for finding in &findings {
15631            let _ = writeln!(result, "- Finding: {finding}");
15632        }
15633    }
15634    result.push('\n');
15635    result.push_str(&out);
15636    Ok(result)
15637}
15638
15639#[cfg(not(windows))]
15640fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
15641    Ok("Host inspection: windows_backup\n\n=== Findings ===\n- Windows Backup inspection is Windows-only.\n".into())
15642}
15643
15644#[cfg(windows)]
15645fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
15646    let mut out = String::from("=== Windows Search service ===\n");
15647
15648    // Service state
15649    let ps_svc = r#"
15650$svc = Get-Service WSearch -ErrorAction SilentlyContinue
15651if ($svc) { "WSearch | Status: $($svc.Status) | StartType: $($svc.StartType)" }
15652else { "WSearch service not found" }
15653"#;
15654    match run_powershell(ps_svc) {
15655        Ok(o) => {
15656            let _ = writeln!(out, "- {}", o.trim());
15657        }
15658        Err(_) => out.push_str("- Could not query WSearch service\n"),
15659    }
15660
15661    // Indexer state via registry
15662    out.push_str("\n=== Indexer state ===\n");
15663    let ps_idx = r#"
15664$key = 'HKLM:\SOFTWARE\Microsoft\Windows Search'
15665$props = Get-ItemProperty $key -ErrorAction SilentlyContinue
15666if ($props) {
15667    "SetupCompletedSuccessfully: $($props.SetupCompletedSuccessfully)"
15668    "IsContentIndexingEnabled: $($props.IsContentIndexingEnabled)"
15669    "DataDirectory: $($props.DataDirectory)"
15670} else { "Registry key not found" }
15671"#;
15672    match run_powershell(ps_idx) {
15673        Ok(o) => {
15674            for line in o.lines() {
15675                let l = line.trim();
15676                if !l.is_empty() {
15677                    let _ = writeln!(out, "- {l}");
15678                }
15679            }
15680        }
15681        Err(_) => out.push_str("- Could not read indexer registry\n"),
15682    }
15683
15684    // Indexed locations
15685    out.push_str("\n=== Indexed locations ===\n");
15686    let ps_locs = r#"
15687$comObj = New-Object -ComObject Microsoft.Search.Administration.CSearchManager -ErrorAction SilentlyContinue
15688if ($comObj) {
15689    $catalog = $comObj.GetCatalog('SystemIndex')
15690    $manager = $catalog.GetCrawlScopeManager()
15691    $rules = $manager.EnumerateRoots()
15692    while ($true) {
15693        try {
15694            $root = $rules.Next(1)
15695            if ($root.Count -eq 0) { break }
15696            $r = $root[0]
15697            "  $($r.RootURL) | Default: $($r.IsDefault) | Included: $($r.IsIncluded)"
15698        } catch { break }
15699    }
15700} else { "  COM admin interface not available (normal on non-admin sessions)" }
15701"#;
15702    match run_powershell(ps_locs) {
15703        Ok(o) if !o.trim().is_empty() => {
15704            for line in o.lines() {
15705                let l = line.trim_end();
15706                if !l.is_empty() {
15707                    let _ = writeln!(out, "{l}");
15708                }
15709            }
15710        }
15711        _ => {
15712            // Fallback: read from registry
15713            let ps_reg = r#"
15714Get-ChildItem 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Search\Indexer\Sources' -ErrorAction SilentlyContinue |
15715ForEach-Object { "  $($_.PSChildName)" } | Select-Object -First 20
15716"#;
15717            match run_powershell(ps_reg) {
15718                Ok(o) if !o.trim().is_empty() => {
15719                    for line in o.lines() {
15720                        let l = line.trim_end();
15721                        if !l.is_empty() {
15722                            let _ = writeln!(out, "{l}");
15723                        }
15724                    }
15725                }
15726                _ => out.push_str("  - Could not enumerate indexed locations\n"),
15727            }
15728        }
15729    }
15730
15731    // Recent indexing errors from event log
15732    out.push_str("\n=== Recent indexer errors (last 24h) ===\n");
15733    let ps_evts = r#"
15734Get-WinEvent -LogName 'Microsoft-Windows-Search/Operational' -MaxEvents 5 -ErrorAction SilentlyContinue |
15735Where-Object { $_.LevelDisplayName -eq 'Error' -or $_.LevelDisplayName -eq 'Warning' } |
15736ForEach-Object { "$($_.TimeCreated.ToString('HH:mm')) [$($_.LevelDisplayName)] $($_.Message.Substring(0, [Math]::Min(120, $_.Message.Length)))" }
15737"#;
15738    match run_powershell(ps_evts) {
15739        Ok(o) if !o.trim().is_empty() => {
15740            for line in o.lines() {
15741                let l = line.trim();
15742                if !l.is_empty() {
15743                    let _ = writeln!(out, "- {l}");
15744                }
15745            }
15746        }
15747        _ => out.push_str("- No recent indexer errors found\n"),
15748    }
15749
15750    let mut findings: Vec<String> = Vec::with_capacity(4);
15751    if out.contains("Status: Stopped") {
15752        findings.push("Windows Search (WSearch) is stopped — search results will be slow or empty. Start the service: `Start-Service WSearch`.".into());
15753    }
15754    if out.contains("IsContentIndexingEnabled: 0")
15755        || out.contains("IsContentIndexingEnabled: False")
15756    {
15757        findings.push(
15758            "Content indexing is disabled — file content won't be searchable, only filenames."
15759                .into(),
15760        );
15761    }
15762    if out.contains("SetupCompletedSuccessfully: 0")
15763        || out.contains("SetupCompletedSuccessfully: False")
15764    {
15765        findings.push("Search indexer setup did not complete successfully — index may be corrupt. Rebuild: Settings > Search > Searching Windows > Advanced > Rebuild.".into());
15766    }
15767
15768    let mut result = String::from("Host inspection: search_index\n\n=== Findings ===\n");
15769    if findings.is_empty() {
15770        result.push_str("- Windows Search service and indexer appear healthy.\n");
15771        result.push_str("  If search still feels slow, the index may just be catching up — check indexing status in Settings > Search > Searching Windows.\n");
15772    } else {
15773        for f in &findings {
15774            let _ = writeln!(result, "- Finding: {f}");
15775        }
15776    }
15777    result.push('\n');
15778    result.push_str(&out);
15779    Ok(result)
15780}
15781
15782#[cfg(not(windows))]
15783fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
15784    Ok("Host inspection: search_index\nSearch index inspection is Windows-only.".into())
15785}
15786
15787// ── inspect_display_config ────────────────────────────────────────────────────
15788
15789#[cfg(windows)]
15790fn inspect_display_config(max_entries: usize) -> Result<String, String> {
15791    let mut out = String::with_capacity(1024);
15792
15793    // Active displays via CIM
15794    out.push_str("=== Active displays ===\n");
15795    let ps_displays = r#"
15796Get-CimInstance -ClassName CIM_VideoControllerResolution -ErrorAction SilentlyContinue |
15797Select-Object -First 20 |
15798ForEach-Object {
15799    "$($_.HorizontalResolution)x$($_.VerticalResolution) @ $($_.RefreshRate)Hz | Colors: $($_.NumberOfColors)"
15800}
15801"#;
15802    match run_powershell(ps_displays) {
15803        Ok(o) if !o.trim().is_empty() => {
15804            for line in o.lines().take(max_entries) {
15805                let l = line.trim();
15806                if !l.is_empty() {
15807                    let _ = writeln!(out, "- {l}");
15808                }
15809            }
15810        }
15811        _ => out.push_str("- Could not enumerate display resolutions via CIM\n"),
15812    }
15813
15814    // GPU / video adapter
15815    out.push_str("\n=== Video adapters ===\n");
15816    let ps_gpu = r#"
15817Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | Select-Object -First 4 |
15818ForEach-Object {
15819    $res = "$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
15820    $hz  = "$($_.CurrentRefreshRate) Hz"
15821    $bits = "$($_.CurrentBitsPerPixel) bpp"
15822    "$($_.Name) | $res @ $hz | $bits | Driver: $($_.DriverVersion)"
15823}
15824"#;
15825    match run_powershell(ps_gpu) {
15826        Ok(o) if !o.trim().is_empty() => {
15827            for line in o.lines().take(max_entries) {
15828                let l = line.trim();
15829                if !l.is_empty() {
15830                    let _ = writeln!(out, "- {l}");
15831                }
15832            }
15833        }
15834        _ => out.push_str("- Could not query video adapter info\n"),
15835    }
15836
15837    // Monitor names via Win32_DesktopMonitor
15838    out.push_str("\n=== Connected monitors ===\n");
15839    let ps_monitors = r#"
15840Get-CimInstance Win32_DesktopMonitor -ErrorAction SilentlyContinue | Select-Object -First 8 |
15841ForEach-Object { "$($_.Name) | Status: $($_.Status) | PnP: $($_.PNPDeviceID)" }
15842"#;
15843    match run_powershell(ps_monitors) {
15844        Ok(o) if !o.trim().is_empty() => {
15845            for line in o.lines().take(max_entries) {
15846                let l = line.trim();
15847                if !l.is_empty() {
15848                    let _ = writeln!(out, "- {l}");
15849                }
15850            }
15851        }
15852        _ => out.push_str("- No monitor info available via WMI\n"),
15853    }
15854
15855    // DPI scaling
15856    out.push_str("\n=== DPI / scaling ===\n");
15857    let ps_dpi = r#"
15858Add-Type -TypeDefinition @'
15859using System; using System.Runtime.InteropServices;
15860public class DPI {
15861    [DllImport("user32")] public static extern IntPtr GetDC(IntPtr hwnd);
15862    [DllImport("gdi32")]  public static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
15863    [DllImport("user32")] public static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc);
15864}
15865'@ -ErrorAction SilentlyContinue
15866try {
15867    $hdc  = [DPI]::GetDC([IntPtr]::Zero)
15868    $dpiX = [DPI]::GetDeviceCaps($hdc, 88)
15869    $dpiY = [DPI]::GetDeviceCaps($hdc, 90)
15870    [DPI]::ReleaseDC([IntPtr]::Zero, $hdc) | Out-Null
15871    $scale = [Math]::Round($dpiX / 96.0 * 100)
15872    "DPI: ${dpiX}x${dpiY} | Scale: ${scale}%"
15873} catch { "DPI query unavailable" }
15874"#;
15875    match run_powershell(ps_dpi) {
15876        Ok(o) if !o.trim().is_empty() => {
15877            let _ = writeln!(out, "- {}", o.trim());
15878        }
15879        _ => out.push_str("- DPI info unavailable\n"),
15880    }
15881
15882    let mut findings: Vec<String> = Vec::with_capacity(4);
15883    if out.contains("0x0") || out.contains("@ 0 Hz") {
15884        findings.push("One or more adapters report zero resolution or refresh rate — display may be asleep or misconfigured.".into());
15885    }
15886
15887    let mut result = String::from("Host inspection: display_config\n\n=== Findings ===\n");
15888    if findings.is_empty() {
15889        result.push_str("- Display configuration appears normal.\n");
15890    } else {
15891        for f in &findings {
15892            let _ = writeln!(result, "- Finding: {f}");
15893        }
15894    }
15895    result.push('\n');
15896    result.push_str(&out);
15897    Ok(result)
15898}
15899
15900#[cfg(not(windows))]
15901fn inspect_display_config(_max_entries: usize) -> Result<String, String> {
15902    Ok("Host inspection: display_config\nDisplay config inspection is Windows-only.".into())
15903}
15904
15905// ── inspect_ntp ───────────────────────────────────────────────────────────────
15906
15907#[cfg(windows)]
15908fn inspect_ntp() -> Result<String, String> {
15909    let mut out = String::with_capacity(1024);
15910
15911    // w32tm status
15912    out.push_str("=== Windows Time service ===\n");
15913    let ps_svc = r#"
15914$svc = Get-Service W32Time -ErrorAction SilentlyContinue
15915if ($svc) { "W32Time | Status: $($svc.Status) | StartType: $($svc.StartType)" }
15916else { "W32Time service not found" }
15917"#;
15918    match run_powershell(ps_svc) {
15919        Ok(o) => {
15920            let _ = writeln!(out, "- {}", o.trim());
15921        }
15922        Err(_) => out.push_str("- Could not query W32Time service\n"),
15923    }
15924
15925    // NTP source and last sync
15926    out.push_str("\n=== NTP source and sync status ===\n");
15927    let ps_sync = r#"
15928$q = w32tm /query /status 2>$null
15929if ($q) { $q } else { "w32tm query unavailable" }
15930"#;
15931    match run_powershell(ps_sync) {
15932        Ok(o) if !o.trim().is_empty() => {
15933            for line in o.lines() {
15934                let l = line.trim();
15935                if !l.is_empty() {
15936                    let _ = writeln!(out, "  {l}");
15937                }
15938            }
15939        }
15940        _ => out.push_str("  - Could not query w32tm status\n"),
15941    }
15942
15943    // Configured NTP server
15944    out.push_str("\n=== Configured NTP servers ===\n");
15945    let ps_peers = r#"
15946w32tm /query /peers 2>$null | Select-Object -First 10
15947"#;
15948    match run_powershell(ps_peers) {
15949        Ok(o) if !o.trim().is_empty() => {
15950            for line in o.lines() {
15951                let l = line.trim();
15952                if !l.is_empty() {
15953                    let _ = writeln!(out, "  {l}");
15954                }
15955            }
15956        }
15957        _ => {
15958            // Fallback: registry
15959            let ps_reg = r#"
15960(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Parameters' -Name NtpServer -ErrorAction SilentlyContinue).NtpServer
15961"#;
15962            match run_powershell(ps_reg) {
15963                Ok(o) if !o.trim().is_empty() => {
15964                    let _ = writeln!(out, "  NtpServer (registry): {}", o.trim());
15965                }
15966                _ => out.push_str("  - Could not enumerate NTP peers\n"),
15967            }
15968        }
15969    }
15970
15971    let mut findings: Vec<String> = Vec::with_capacity(4);
15972    if out.contains("W32Time | Status: Stopped") {
15973        findings.push("Windows Time service is stopped — system clock will drift and may cause authentication or certificate failures. Start with: `Start-Service W32Time`.".into());
15974    }
15975    if out.contains("The computer did not resync") || out.contains("Error") {
15976        findings.push("w32tm reports a sync error — check NTP server reachability or run `w32tm /resync /force`.".into());
15977    }
15978
15979    let mut result = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15980    if findings.is_empty() {
15981        result.push_str("- Windows Time service and NTP sync appear healthy.\n");
15982    } else {
15983        for f in &findings {
15984            let _ = writeln!(result, "- Finding: {f}");
15985        }
15986    }
15987    result.push('\n');
15988    result.push_str(&out);
15989    Ok(result)
15990}
15991
15992#[cfg(not(windows))]
15993fn inspect_ntp() -> Result<String, String> {
15994    // Linux/macOS: check timedatectl / chrony / ntpq
15995    let mut out = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15996
15997    let timedatectl = std::process::Command::new("timedatectl")
15998        .arg("status")
15999        .output();
16000
16001    if let Ok(o) = timedatectl {
16002        let text = String::from_utf8_lossy(&o.stdout);
16003        if text.contains("synchronized: yes") || text.contains("NTP synchronized: yes") {
16004            out.push_str("- NTP synchronized: yes\n\n=== timedatectl status ===\n");
16005        } else {
16006            out.push_str("- Finding: NTP not synchronized — run `timedatectl set-ntp true`\n\n=== timedatectl status ===\n");
16007        }
16008        for line in text.lines() {
16009            let l = line.trim();
16010            if !l.is_empty() {
16011                let _ = write!(out, "  {l}\n");
16012            }
16013        }
16014        return Ok(out);
16015    }
16016
16017    // macOS fallback
16018    let sntp = std::process::Command::new("sntp")
16019        .args(["-d", "time.apple.com"])
16020        .output();
16021    if let Ok(o) = sntp {
16022        out.push_str("- NTP check via sntp:\n");
16023        out.push_str(&String::from_utf8_lossy(&o.stdout));
16024        return Ok(out);
16025    }
16026
16027    out.push_str("- NTP status unavailable (no timedatectl or sntp found)\n");
16028    Ok(out)
16029}
16030
16031// ── inspect_cpu_power ─────────────────────────────────────────────────────────
16032
16033#[cfg(windows)]
16034fn inspect_cpu_power() -> Result<String, String> {
16035    let mut out = String::with_capacity(1024);
16036
16037    // Active power plan
16038    out.push_str("=== Active power plan ===\n");
16039    let ps_plan = r#"
16040$plan = powercfg /getactivescheme 2>$null
16041if ($plan) { $plan } else { "Could not query power scheme" }
16042"#;
16043    match run_powershell(ps_plan) {
16044        Ok(o) if !o.trim().is_empty() => {
16045            let _ = writeln!(out, "- {}", o.trim());
16046        }
16047        _ => out.push_str("- Could not read active power plan\n"),
16048    }
16049
16050    // Processor min/max state and boost policy
16051    out.push_str("\n=== Processor performance policy ===\n");
16052    let ps_proc = r#"
16053$active = (powercfg /getactivescheme) -replace '.*GUID: ([a-f0-9-]+).*','$1'
16054$min = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMIN 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
16055$max = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMAX 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
16056$boost = powercfg /query $active SUB_PROCESSOR PERFBOOSTMODE 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
16057if ($min)   { "Min processor state:  $(([Convert]::ToInt32(($min -split '0x')[1],16)))%" }
16058if ($max)   { "Max processor state:  $(([Convert]::ToInt32(($max -split '0x')[1],16)))%" }
16059if ($boost) {
16060    $bval = [Convert]::ToInt32(($boost -split '0x')[1],16)
16061    $bname = switch ($bval) { 0{'Disabled'} 1{'Enabled'} 2{'Aggressive'} 3{'Efficient Enabled'} 4{'Efficient Aggressive'} default{"Unknown ($bval)"} }
16062    "Turbo boost mode:     $bname"
16063}
16064"#;
16065    match run_powershell(ps_proc) {
16066        Ok(o) if !o.trim().is_empty() => {
16067            for line in o.lines() {
16068                let l = line.trim();
16069                if !l.is_empty() {
16070                    let _ = writeln!(out, "- {l}");
16071                }
16072            }
16073        }
16074        _ => out.push_str("- Could not query processor performance settings\n"),
16075    }
16076
16077    // Current CPU frequency via WMI
16078    out.push_str("\n=== CPU frequency ===\n");
16079    let ps_freq = r#"
16080Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 4 |
16081ForEach-Object {
16082    $cur = $_.CurrentClockSpeed
16083    $max = $_.MaxClockSpeed
16084    $load = $_.LoadPercentage
16085    "$($_.Name.Trim()) | Current: ${cur} MHz | Max: ${max} MHz | Load: ${load}%"
16086}
16087"#;
16088    match run_powershell(ps_freq) {
16089        Ok(o) if !o.trim().is_empty() => {
16090            for line in o.lines() {
16091                let l = line.trim();
16092                if !l.is_empty() {
16093                    let _ = writeln!(out, "- {l}");
16094                }
16095            }
16096        }
16097        _ => out.push_str("- Could not query CPU frequency via WMI\n"),
16098    }
16099
16100    // Throttle reason from ETW (quick check)
16101    out.push_str("\n=== Throttling indicators ===\n");
16102    let ps_throttle = r#"
16103$pwr = Get-CimInstance -Namespace root\wmi -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue
16104if ($pwr) {
16105    $pwr | Select-Object -First 4 | ForEach-Object {
16106        $c = [Math]::Round(($_.CurrentTemperature / 10.0) - 273.15, 1)
16107        "Thermal zone $($_.InstanceName): ${c}°C"
16108    }
16109} else { "Thermal zone WMI not available (normal on consumer hardware)" }
16110"#;
16111    match run_powershell(ps_throttle) {
16112        Ok(o) if !o.trim().is_empty() => {
16113            for line in o.lines() {
16114                let l = line.trim();
16115                if !l.is_empty() {
16116                    let _ = writeln!(out, "- {l}");
16117                }
16118            }
16119        }
16120        _ => out.push_str("- Thermal zone info unavailable\n"),
16121    }
16122
16123    let mut findings: Vec<String> = Vec::with_capacity(4);
16124    if out.contains("Max processor state:  0%") || out.contains("Max processor state:  1%") {
16125        findings.push("Max processor state is near 0% — CPU is being hard-capped by the power plan. Check power plan settings.".into());
16126    }
16127    if out.contains("Turbo boost mode:     Disabled") {
16128        findings.push("Turbo Boost is disabled in the active power plan — CPU cannot exceed base clock speed.".into());
16129    }
16130    if out.contains("Min processor state:  100%") {
16131        findings.push("Min processor state is 100% — CPU is pinned at max clock. Good for performance, increases power/heat.".into());
16132    }
16133
16134    let mut result = String::from("Host inspection: cpu_power\n\n=== Findings ===\n");
16135    if findings.is_empty() {
16136        result.push_str("- CPU power and frequency settings appear normal.\n");
16137    } else {
16138        for f in &findings {
16139            let _ = writeln!(result, "- Finding: {f}");
16140        }
16141    }
16142    result.push('\n');
16143    result.push_str(&out);
16144    Ok(result)
16145}
16146
16147#[cfg(windows)]
16148fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
16149    let mut out = String::with_capacity(1024);
16150
16151    out.push_str("=== Credential vault summary ===\n");
16152    let ps_summary = r#"
16153$raw = cmdkey /list 2>&1
16154$lines = $raw -split "`n"
16155$total = ($lines | Where-Object { $_ -match "Target:" }).Count
16156"Total stored credentials: $total"
16157$windows = ($lines | Where-Object { $_ -match "Type: Windows" }).Count
16158$generic = ($lines | Where-Object { $_ -match "Type: Generic" }).Count
16159$cert    = ($lines | Where-Object { $_ -match "Type: Certificate" }).Count
16160"  Windows credentials: $windows"
16161"  Generic credentials: $generic"
16162"  Certificate-based:   $cert"
16163"#;
16164    match run_powershell(ps_summary) {
16165        Ok(o) => {
16166            for line in o.lines() {
16167                let l = line.trim();
16168                if !l.is_empty() {
16169                    let _ = writeln!(out, "- {l}");
16170                }
16171            }
16172        }
16173        Err(e) => {
16174            let _ = writeln!(out, "- Credential summary error: {e}");
16175        }
16176    }
16177
16178    out.push_str("\n=== Credential targets (up to 20) ===\n");
16179    let ps_list = r#"
16180$raw = cmdkey /list 2>&1
16181$entries = @(); $cur = @{}
16182foreach ($line in ($raw -split "`n")) {
16183    $l = $line.Trim()
16184    if     ($l -match "^Target:\s*(.+)")  { $cur = @{ Target=$Matches[1] } }
16185    elseif ($l -match "^Type:\s*(.+)"   -and $cur.Target) { $cur.Type=$Matches[1] }
16186    elseif ($l -match "^User:\s*(.+)"   -and $cur.Target) { $cur.User=$Matches[1]; $entries+=$cur; $cur=@{} }
16187}
16188$entries | Select-Object -Last 20 | ForEach-Object {
16189    "[$($_.Type)] $($_.Target)  (user: $($_.User))"
16190}
16191"#;
16192    match run_powershell(ps_list) {
16193        Ok(o) => {
16194            let lines: Vec<&str> = o
16195                .lines()
16196                .map(|l| l.trim())
16197                .filter(|l| !l.is_empty())
16198                .collect();
16199            if lines.is_empty() {
16200                out.push_str("- No credential entries found\n");
16201            } else {
16202                for l in &lines {
16203                    let _ = writeln!(out, "- {l}");
16204                }
16205            }
16206        }
16207        Err(e) => {
16208            let _ = writeln!(out, "- Credential list error: {e}");
16209        }
16210    }
16211
16212    let total_creds: usize = {
16213        let ps_count = r#"(cmdkey /list 2>&1 | Select-String "Target:").Count"#;
16214        run_powershell(ps_count)
16215            .ok()
16216            .and_then(|s| s.trim().parse().ok())
16217            .unwrap_or(0)
16218    };
16219
16220    let mut findings: Vec<String> = Vec::with_capacity(4);
16221    if total_creds > 30 {
16222        findings.push(format!(
16223            "{total_creds} stored credentials found — consider auditing for stale entries."
16224        ));
16225    }
16226
16227    let mut result = String::from("Host inspection: credentials\n\n=== Findings ===\n");
16228    if findings.is_empty() {
16229        result.push_str("- Credential store looks normal.\n");
16230    } else {
16231        for f in &findings {
16232            let _ = writeln!(result, "- Finding: {f}");
16233        }
16234    }
16235    result.push('\n');
16236    result.push_str(&out);
16237    Ok(result)
16238}
16239
16240#[cfg(not(windows))]
16241fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
16242    Ok("Host inspection: credentials\n\n=== Findings ===\n- Credential Manager is Windows-only. Use `secret-tool` or `pass` on Linux.\n".into())
16243}
16244
16245#[cfg(windows)]
16246fn inspect_tpm() -> Result<String, String> {
16247    let mut out = String::with_capacity(1024);
16248
16249    out.push_str("=== TPM state ===\n");
16250    let ps_tpm = r#"
16251function Emit-Field([string]$Name, $Value, [string]$Fallback = "Unknown") {
16252    $text = if ($null -eq $Value) { "" } else { [string]$Value }
16253    if ([string]::IsNullOrWhiteSpace($text)) { $text = $Fallback }
16254    "$Name$text"
16255}
16256$t = Get-Tpm -ErrorAction SilentlyContinue
16257if ($t) {
16258    Emit-Field "TpmPresent:          " $t.TpmPresent
16259    Emit-Field "TpmReady:            " $t.TpmReady
16260    Emit-Field "TpmEnabled:          " $t.TpmEnabled
16261    Emit-Field "TpmOwned:            " $t.TpmOwned
16262    Emit-Field "RestartPending:      " $t.RestartPending
16263    Emit-Field "ManufacturerIdTxt:   " $t.ManufacturerIdTxt
16264    Emit-Field "ManufacturerVersion: " $t.ManufacturerVersion
16265} else { "TPM module unavailable" }
16266"#;
16267    match run_powershell(ps_tpm) {
16268        Ok(o) => {
16269            for line in o.lines() {
16270                let l = line.trim();
16271                if !l.is_empty() {
16272                    let _ = writeln!(out, "- {l}");
16273                }
16274            }
16275        }
16276        Err(e) => {
16277            let _ = writeln!(out, "- Get-Tpm error: {e}");
16278        }
16279    }
16280
16281    out.push_str("\n=== TPM spec version (WMI) ===\n");
16282    let ps_spec = r#"
16283$wmi = Get-CimInstance -Namespace root\cimv2\security\microsofttpm -ClassName Win32_Tpm -ErrorAction SilentlyContinue
16284if ($wmi) {
16285    $spec = if ([string]::IsNullOrWhiteSpace([string]$wmi.SpecVersion)) { "Unknown" } else { [string]$wmi.SpecVersion }
16286    "SpecVersion:  $spec"
16287    "IsActivated:  $(if ($null -eq $wmi.IsActivated_InitialValue) { 'Unknown' } else { $wmi.IsActivated_InitialValue })"
16288    "IsEnabled:    $(if ($null -eq $wmi.IsEnabled_InitialValue) { 'Unknown' } else { $wmi.IsEnabled_InitialValue })"
16289    "IsOwned:      $(if ($null -eq $wmi.IsOwned_InitialValue) { 'Unknown' } else { $wmi.IsOwned_InitialValue })"
16290} else { "Win32_Tpm WMI class unavailable (may need elevation or no TPM)" }
16291"#;
16292    match run_powershell(ps_spec) {
16293        Ok(o) => {
16294            for line in o.lines() {
16295                let l = line.trim();
16296                if !l.is_empty() {
16297                    let _ = writeln!(out, "- {l}");
16298                }
16299            }
16300        }
16301        Err(e) => {
16302            let _ = writeln!(out, "- Win32_Tpm WMI error: {e}");
16303        }
16304    }
16305
16306    out.push_str("\n=== Secure Boot state ===\n");
16307    let ps_sb = r#"
16308try {
16309    $sb = Confirm-SecureBootUEFI -ErrorAction Stop
16310    if ($sb) { "Secure Boot: ENABLED" } else { "Secure Boot: DISABLED" }
16311} catch {
16312    $msg = $_.Exception.Message
16313    if ($msg -match "Access was denied" -or $msg -match "proper privileges") {
16314        "Secure Boot: Unknown (administrator privileges required)"
16315    } elseif ($msg -match "Cmdlet not supported on this platform") {
16316        "Secure Boot: N/A (Legacy BIOS or unsupported firmware)"
16317    } else {
16318        "Secure Boot: N/A ($msg)"
16319    }
16320}
16321"#;
16322    match run_powershell(ps_sb) {
16323        Ok(o) => {
16324            for line in o.lines() {
16325                let l = line.trim();
16326                if !l.is_empty() {
16327                    let _ = writeln!(out, "- {l}");
16328                }
16329            }
16330        }
16331        Err(e) => {
16332            let _ = writeln!(out, "- Secure Boot check error: {e}");
16333        }
16334    }
16335
16336    out.push_str("\n=== Firmware type ===\n");
16337    let ps_fw = r#"
16338$fw = (Get-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Control -Name "PEFirmwareType" -ErrorAction SilentlyContinue).PEFirmwareType
16339switch ($fw) {
16340    1 { "Firmware type: BIOS (Legacy)" }
16341    2 { "Firmware type: UEFI" }
16342    default {
16343        $bcd = bcdedit /enum firmware 2>$null
16344        if ($LASTEXITCODE -eq 0 -and $bcd) { "Firmware type: UEFI (bcdedit fallback)" }
16345        else { "Firmware type: Unknown or not set" }
16346    }
16347}
16348"#;
16349    match run_powershell(ps_fw) {
16350        Ok(o) => {
16351            for line in o.lines() {
16352                let l = line.trim();
16353                if !l.is_empty() {
16354                    let _ = writeln!(out, "- {l}");
16355                }
16356            }
16357        }
16358        Err(e) => {
16359            let _ = writeln!(out, "- Firmware type error: {e}");
16360        }
16361    }
16362
16363    let mut findings: Vec<String> = Vec::with_capacity(4);
16364    let mut indeterminate = false;
16365    if out.contains("TpmPresent:          False") {
16366        findings.push("No TPM detected — BitLocker hardware encryption and Windows 11 security features unavailable.".into());
16367    }
16368    if out.contains("TpmReady:            False") {
16369        findings.push(
16370            "TPM present but not ready — may need initialization in BIOS/UEFI settings.".into(),
16371        );
16372    }
16373    if out.contains("SpecVersion:  1.2") {
16374        findings.push("TPM 1.2 detected — Windows 11 requires TPM 2.0.".into());
16375    }
16376    if out.contains("Secure Boot: DISABLED") {
16377        findings.push("Secure Boot is disabled — recommended to enable in UEFI firmware for Windows 11 compliance.".into());
16378    }
16379    if out.contains("Firmware type: BIOS (Legacy)") {
16380        findings.push(
16381            "Legacy BIOS detected — Secure Boot and modern TPM require UEFI firmware.".into(),
16382        );
16383    }
16384
16385    if out.contains("TPM module unavailable")
16386        || out.contains("Win32_Tpm WMI class unavailable")
16387        || out.contains("Secure Boot: N/A")
16388        || out.contains("Secure Boot: Unknown")
16389        || out.contains("Firmware type: Unknown or not set")
16390        || out.contains("TpmPresent:          Unknown")
16391        || out.contains("TpmReady:            Unknown")
16392        || out.contains("TpmEnabled:          Unknown")
16393    {
16394        indeterminate = true;
16395    }
16396    if indeterminate {
16397        findings.push(
16398            "TPM / Secure Boot state could not be fully determined from this session - firmware mode, privileges, or Windows TPM providers may be limiting visibility."
16399                .into(),
16400        );
16401    }
16402
16403    let mut result = String::from("Host inspection: tpm\n\n=== Findings ===\n");
16404    if findings.is_empty() {
16405        result.push_str("- TPM and Secure Boot appear healthy.\n");
16406    } else {
16407        for f in &findings {
16408            let _ = writeln!(result, "- Finding: {f}");
16409        }
16410    }
16411    result.push('\n');
16412    result.push_str(&out);
16413    Ok(result)
16414}
16415
16416#[cfg(not(windows))]
16417fn inspect_tpm() -> Result<String, String> {
16418    Ok(
16419        "Host inspection: tpm\n\n=== Findings ===\n- TPM/Secure Boot inspection is Windows-only.\n"
16420            .into(),
16421    )
16422}
16423
16424#[cfg(windows)]
16425fn inspect_latency() -> Result<String, String> {
16426    let mut out = String::with_capacity(1024);
16427
16428    // Resolve default gateway from the routing table
16429    let ps_gw = r#"
16430$gw = (Get-NetRoute -DestinationPrefix "0.0.0.0/0" -ErrorAction SilentlyContinue |
16431       Sort-Object RouteMetric | Select-Object -First 1).NextHop
16432if ($gw) { $gw } else { "" }
16433"#;
16434    let gateway = run_powershell(ps_gw)
16435        .ok()
16436        .map(|s| s.trim().to_string())
16437        .filter(|s| !s.is_empty());
16438
16439    let targets: Vec<(&str, String)> = {
16440        let mut t = Vec::with_capacity(3);
16441        if let Some(ref gw) = gateway {
16442            t.push(("Default gateway", gw.clone()));
16443        }
16444        t.push(("Cloudflare DNS", "1.1.1.1".into()));
16445        t.push(("Google DNS", "8.8.8.8".into()));
16446        t
16447    };
16448
16449    let mut findings: Vec<String> = Vec::with_capacity(4);
16450
16451    for (label, host) in &targets {
16452        let _ = write!(out, "\n=== Ping: {label} ({host}) ===\n");
16453        // Test-NetConnection gives RTT; -InformationLevel Quiet just returns bool, so use ping
16454        let ps_ping = format!(
16455            r#"
16456$r = Test-Connection -ComputerName "{host}" -Count 4 -ErrorAction SilentlyContinue
16457if ($r) {{
16458    $rtts = $r | ForEach-Object {{ $_.ResponseTime }}
16459    $min  = ($rtts | Measure-Object -Minimum).Minimum
16460    $max  = ($rtts | Measure-Object -Maximum).Maximum
16461    $avg  = [Math]::Round(($rtts | Measure-Object -Average).Average, 1)
16462    $loss = [Math]::Round((4 - $r.Count) / 4 * 100)
16463    "RTT min/avg/max: ${{min}}ms / ${{avg}}ms / ${{max}}ms"
16464    "Packet loss: ${{loss}}%"
16465    "Sent: 4  Received: $($r.Count)"
16466}} else {{
16467    "UNREACHABLE — 100% packet loss"
16468}}
16469"#
16470        );
16471        match run_powershell(&ps_ping) {
16472            Ok(o) => {
16473                let body = o.trim().to_string();
16474                for line in body.lines() {
16475                    let l = line.trim();
16476                    if !l.is_empty() {
16477                        let _ = writeln!(out, "- {l}");
16478                    }
16479                }
16480                if body.contains("UNREACHABLE") {
16481                    findings.push(format!(
16482                        "{label} ({host}) is unreachable — possible routing or firewall issue."
16483                    ));
16484                } else if let Some(loss_line) = body.lines().find(|l| l.contains("Packet loss")) {
16485                    let pct: u32 = loss_line
16486                        .chars()
16487                        .filter(|c| c.is_ascii_digit())
16488                        .collect::<String>()
16489                        .parse()
16490                        .unwrap_or(0);
16491                    if pct >= 25 {
16492                        findings.push(format!("{label} ({host}): {pct}% packet loss detected — possible network instability."));
16493                    }
16494                    // High latency check
16495                    if let Some(rtt_line) = body.lines().find(|l| l.contains("RTT min/avg/max")) {
16496                        // parse avg from "RTT min/avg/max: Xms / Yms / Zms"
16497                        if let Some(avg_field) = rtt_line.split('/').nth(1) {
16498                            let avg_str: String =
16499                                avg_field.chars().filter(|c| c.is_ascii_digit()).collect();
16500                            let avg: u32 = avg_str.parse().unwrap_or(0);
16501                            if avg > 150 {
16502                                findings.push(format!("{label} ({host}): high average RTT ({avg}ms) — check for congestion or routing issues."));
16503                            }
16504                        }
16505                    }
16506                }
16507            }
16508            Err(e) => {
16509                let _ = writeln!(out, "- Ping error: {e}");
16510            }
16511        }
16512    }
16513
16514    let mut result = String::from("Host inspection: latency\n\n=== Findings ===\n");
16515    if findings.is_empty() {
16516        result.push_str("- Latency and reachability look normal.\n");
16517    } else {
16518        for f in &findings {
16519            let _ = writeln!(result, "- Finding: {f}");
16520        }
16521    }
16522    result.push('\n');
16523    result.push_str(&out);
16524    Ok(result)
16525}
16526
16527#[cfg(not(windows))]
16528fn inspect_latency() -> Result<String, String> {
16529    let mut out = String::from("Host inspection: latency\n\n=== Findings ===\n");
16530    let targets = [("Cloudflare DNS", "1.1.1.1"), ("Google DNS", "8.8.8.8")];
16531    let mut findings: Vec<String> = Vec::with_capacity(4);
16532
16533    for (label, host) in &targets {
16534        let _ = write!(out, "\n=== Ping: {label} ({host}) ===\n");
16535        let ping = std::process::Command::new("ping")
16536            .args(["-c", "4", "-W", "2", host])
16537            .output();
16538        match ping {
16539            Ok(o) => {
16540                let body = String::from_utf8_lossy(&o.stdout).into_owned();
16541                for line in body.lines() {
16542                    let l = line.trim();
16543                    if l.contains("ms") || l.contains("loss") || l.contains("transmitted") {
16544                        let _ = write!(out, "- {l}\n");
16545                    }
16546                }
16547                if body.contains("100% packet loss") || body.contains("100.0% packet loss") {
16548                    findings.push(format!("{label} ({host}) is unreachable."));
16549                }
16550            }
16551            Err(e) => {
16552                let _ = write!(out, "- ping error: {e}\n");
16553            }
16554        }
16555    }
16556
16557    if findings.is_empty() {
16558        out.insert_str(
16559            "Host inspection: latency\n\n=== Findings ===\n".len(),
16560            "- Latency and reachability look normal.\n",
16561        );
16562    } else {
16563        let mut prefix = String::new();
16564        for f in &findings {
16565            let _ = write!(prefix, "- Finding: {f}\n");
16566        }
16567        out.insert_str(
16568            "Host inspection: latency\n\n=== Findings ===\n".len(),
16569            &prefix,
16570        );
16571    }
16572    Ok(out)
16573}
16574
16575#[cfg(windows)]
16576fn inspect_network_adapter() -> Result<String, String> {
16577    let mut out = String::with_capacity(1024);
16578
16579    out.push_str("=== Network adapters ===\n");
16580    let ps_adapters = r#"
16581Get-NetAdapter | Sort-Object Status,Name | ForEach-Object {
16582    $speed = if ($_.LinkSpeed) { $_.LinkSpeed } else { "Unknown" }
16583    "$($_.Name) | Status: $($_.Status) | Speed: $speed | MAC: $($_.MacAddress) | Driver: $($_.DriverVersion)"
16584}
16585"#;
16586    match run_powershell(ps_adapters) {
16587        Ok(o) => {
16588            for line in o.lines() {
16589                let l = line.trim();
16590                if !l.is_empty() {
16591                    let _ = writeln!(out, "- {l}");
16592                }
16593            }
16594        }
16595        Err(e) => {
16596            let _ = writeln!(out, "- Adapter query error: {e}");
16597        }
16598    }
16599
16600    out.push_str("\n=== Duplex and negotiated speed ===\n");
16601    let ps_duplex = r#"
16602Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16603    $name = $_.Name
16604    $duplex = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
16605        Where-Object { $_.DisplayName -match "Duplex|Speed" } |
16606        Select-Object DisplayName, DisplayValue
16607    if ($duplex) {
16608        "--- $name ---"
16609        $duplex | ForEach-Object { "  $($_.DisplayName): $($_.DisplayValue)" }
16610    } else {
16611        "--- $name --- (no duplex/speed property exposed by driver)"
16612    }
16613}
16614"#;
16615    match run_powershell(ps_duplex) {
16616        Ok(o) => {
16617            let lines: Vec<&str> = o
16618                .lines()
16619                .map(|l| l.trim())
16620                .filter(|l| !l.is_empty())
16621                .collect();
16622            for l in &lines {
16623                let _ = writeln!(out, "- {l}");
16624            }
16625        }
16626        Err(e) => {
16627            let _ = writeln!(out, "- Duplex query error: {e}");
16628        }
16629    }
16630
16631    out.push_str("\n=== Offload and performance settings (Up adapters) ===\n");
16632    let ps_offload = r#"
16633Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16634    $name = $_.Name
16635    $props = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
16636        Where-Object { $_.DisplayName -match "Offload|RSS|Jumbo|Buffer|Flow|Interrupt|Checksum|Large Send" } |
16637        Select-Object DisplayName, DisplayValue
16638    if ($props) {
16639        "--- $name ---"
16640        $props | ForEach-Object { "  $($_.DisplayName): $($_.DisplayValue)" }
16641    }
16642}
16643"#;
16644    match run_powershell(ps_offload) {
16645        Ok(o) => {
16646            let lines: Vec<&str> = o
16647                .lines()
16648                .map(|l| l.trim())
16649                .filter(|l| !l.is_empty())
16650                .collect();
16651            if lines.is_empty() {
16652                out.push_str(
16653                    "- No offload settings exposed by driver (common on virtual/Wi-Fi adapters)\n",
16654                );
16655            } else {
16656                for l in &lines {
16657                    let _ = writeln!(out, "- {l}");
16658                }
16659            }
16660        }
16661        Err(e) => {
16662            let _ = writeln!(out, "- Offload query error: {e}");
16663        }
16664    }
16665
16666    out.push_str("\n=== Adapter error counters ===\n");
16667    let ps_errors = r#"
16668Get-NetAdapterStatistics | ForEach-Object {
16669    $errs = $_.ReceivedPacketErrors + $_.OutboundPacketErrors + $_.ReceivedDiscardedPackets + $_.OutboundDiscardedPackets
16670    if ($errs -gt 0) {
16671        "$($_.Name) | RX errors: $($_.ReceivedPacketErrors) | TX errors: $($_.OutboundPacketErrors) | RX discards: $($_.ReceivedDiscardedPackets) | TX discards: $($_.OutboundDiscardedPackets)"
16672    }
16673}
16674"#;
16675    match run_powershell(ps_errors) {
16676        Ok(o) => {
16677            let lines: Vec<&str> = o
16678                .lines()
16679                .map(|l| l.trim())
16680                .filter(|l| !l.is_empty())
16681                .collect();
16682            if lines.is_empty() {
16683                out.push_str("- No adapter errors or discards detected.\n");
16684            } else {
16685                for l in &lines {
16686                    let _ = writeln!(out, "- {l}");
16687                }
16688            }
16689        }
16690        Err(e) => {
16691            let _ = writeln!(out, "- Error counter query: {e}");
16692        }
16693    }
16694
16695    out.push_str("\n=== Wake-on-LAN and power settings ===\n");
16696    let ps_wol = r#"
16697Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16698    $wol = Get-NetAdapterPowerManagement -Name $_.Name -ErrorAction SilentlyContinue
16699    if ($wol) {
16700        "$($_.Name) | WakeOnMagicPacket: $($wol.WakeOnMagicPacket) | AllowComputerToTurnOffDevice: $($wol.AllowComputerToTurnOffDevice)"
16701    }
16702}
16703"#;
16704    match run_powershell(ps_wol) {
16705        Ok(o) => {
16706            let lines: Vec<&str> = o
16707                .lines()
16708                .map(|l| l.trim())
16709                .filter(|l| !l.is_empty())
16710                .collect();
16711            if lines.is_empty() {
16712                out.push_str("- Power management data unavailable for active adapters.\n");
16713            } else {
16714                for l in &lines {
16715                    let _ = writeln!(out, "- {l}");
16716                }
16717            }
16718        }
16719        Err(e) => {
16720            let _ = writeln!(out, "- WoL query error: {e}");
16721        }
16722    }
16723
16724    let mut findings: Vec<String> = Vec::with_capacity(4);
16725    // Check for error-prone adapters
16726    if out.contains("RX errors:") || out.contains("TX errors:") {
16727        findings
16728            .push("Adapter errors detected — check cabling, driver, or duplex mismatch.".into());
16729    }
16730    // Check for half-duplex (rare but still seen on older switches)
16731    if out.contains("Half") {
16732        findings.push("Half-duplex adapter detected — likely a duplex mismatch with the switch; set both sides to full-duplex.".into());
16733    }
16734
16735    let mut result = String::from("Host inspection: network_adapter\n\n=== Findings ===\n");
16736    if findings.is_empty() {
16737        result.push_str("- Network adapter configuration looks normal.\n");
16738    } else {
16739        for f in &findings {
16740            let _ = writeln!(result, "- Finding: {f}");
16741        }
16742    }
16743    result.push('\n');
16744    result.push_str(&out);
16745    Ok(result)
16746}
16747
16748#[cfg(not(windows))]
16749fn inspect_network_adapter() -> Result<String, String> {
16750    let mut out = String::from("Host inspection: network_adapter\n\n=== Findings ===\n- Network adapter inspection running on Unix.\n\n");
16751
16752    out.push_str("=== Network adapters (ip link) ===\n");
16753    let ip_link = std::process::Command::new("ip")
16754        .args(["link", "show"])
16755        .output();
16756    if let Ok(o) = ip_link {
16757        for line in String::from_utf8_lossy(&o.stdout).lines() {
16758            let l = line.trim();
16759            if !l.is_empty() {
16760                let _ = write!(out, "- {l}\n");
16761            }
16762        }
16763    }
16764
16765    out.push_str("\n=== Adapter statistics (ip -s link) ===\n");
16766    let ip_stats = std::process::Command::new("ip")
16767        .args(["-s", "link", "show"])
16768        .output();
16769    if let Ok(o) = ip_stats {
16770        for line in String::from_utf8_lossy(&o.stdout).lines() {
16771            let l = line.trim();
16772            if l.contains("RX") || l.contains("TX") || l.contains("errors") || l.contains("dropped")
16773            {
16774                let _ = write!(out, "- {l}\n");
16775            }
16776        }
16777    }
16778    Ok(out)
16779}
16780
16781#[cfg(windows)]
16782fn inspect_dhcp() -> Result<String, String> {
16783    let mut out = String::with_capacity(1024);
16784
16785    out.push_str("=== DHCP lease details (per adapter) ===\n");
16786    let ps_dhcp = r#"
16787$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
16788    Where-Object { $_.IPEnabled -eq $true }
16789foreach ($a in $adapters) {
16790    "--- $($a.Description) ---"
16791    "  DHCP Enabled:      $($a.DHCPEnabled)"
16792    if ($a.DHCPEnabled) {
16793        "  DHCP Server:       $($a.DHCPServer)"
16794        $obtained = $a.ConvertToDateTime($a.DHCPLeaseObtained) 2>$null
16795        $expires  = $a.ConvertToDateTime($a.DHCPLeaseExpires)  2>$null
16796        "  Lease Obtained:    $obtained"
16797        "  Lease Expires:     $expires"
16798    }
16799    "  IP Address:        $($a.IPAddress -join ', ')"
16800    "  Subnet Mask:       $($a.IPSubnet -join ', ')"
16801    "  Default Gateway:   $($a.DefaultIPGateway -join ', ')"
16802    "  DNS Servers:       $($a.DNSServerSearchOrder -join ', ')"
16803    "  MAC Address:       $($a.MACAddress)"
16804    ""
16805}
16806"#;
16807    match run_powershell(ps_dhcp) {
16808        Ok(o) => {
16809            for line in o.lines() {
16810                let l = line.trim_end();
16811                if !l.is_empty() {
16812                    let _ = writeln!(out, "{l}");
16813                }
16814            }
16815        }
16816        Err(e) => {
16817            let _ = writeln!(out, "- DHCP query error: {e}");
16818        }
16819    }
16820
16821    // Findings: check for expired or very-soon-expiring leases
16822    let mut findings: Vec<String> = Vec::with_capacity(4);
16823    let ps_expiry = r#"
16824$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object { $_.DHCPEnabled -and $_.IPEnabled }
16825foreach ($a in $adapters) {
16826    try {
16827        $exp = $a.ConvertToDateTime($a.DHCPLeaseExpires)
16828        $now = Get-Date
16829        $hrs = ($exp - $now).TotalHours
16830        if ($hrs -lt 0) { "$($a.Description): EXPIRED" }
16831        elseif ($hrs -lt 2) { "$($a.Description): expires in $([Math]::Round($hrs,1)) hours" }
16832    } catch {}
16833}
16834"#;
16835    if let Ok(o) = run_powershell(ps_expiry) {
16836        for line in o.lines() {
16837            let l = line.trim();
16838            if !l.is_empty() {
16839                if l.contains("EXPIRED") {
16840                    findings.push(format!("DHCP lease EXPIRED on adapter: {l}"));
16841                } else if l.contains("expires in") {
16842                    findings.push(format!("DHCP lease expiring soon — {l}"));
16843                }
16844            }
16845        }
16846    }
16847
16848    let mut result = String::from("Host inspection: dhcp\n\n=== Findings ===\n");
16849    if findings.is_empty() {
16850        result.push_str("- DHCP leases look healthy.\n");
16851    } else {
16852        for f in &findings {
16853            let _ = writeln!(result, "- Finding: {f}");
16854        }
16855    }
16856    result.push('\n');
16857    result.push_str(&out);
16858    Ok(result)
16859}
16860
16861#[cfg(not(windows))]
16862fn inspect_dhcp() -> Result<String, String> {
16863    let mut out = String::from(
16864        "Host inspection: dhcp\n\n=== Findings ===\n- DHCP lease inspection running on Unix.\n\n",
16865    );
16866    out.push_str("=== DHCP leases (dhclient / NetworkManager) ===\n");
16867    for path in &["/var/lib/dhcp/dhclient.leases", "/var/lib/NetworkManager"] {
16868        if std::path::Path::new(path).exists() {
16869            let cat = std::process::Command::new("cat").arg(path).output();
16870            if let Ok(o) = cat {
16871                let text = String::from_utf8_lossy(&o.stdout);
16872                for line in text.lines().take(40) {
16873                    let l = line.trim();
16874                    if l.contains("lease")
16875                        || l.contains("expire")
16876                        || l.contains("server")
16877                        || l.contains("address")
16878                    {
16879                        let _ = write!(out, "- {l}\n");
16880                    }
16881                }
16882            }
16883        }
16884    }
16885    // Also try ip addr for current IPs
16886    let ip = std::process::Command::new("ip")
16887        .args(["addr", "show"])
16888        .output();
16889    if let Ok(o) = ip {
16890        out.push_str("\n=== Current IP addresses (ip addr) ===\n");
16891        for line in String::from_utf8_lossy(&o.stdout).lines() {
16892            let l = line.trim();
16893            if l.starts_with("inet") || l.contains("dynamic") {
16894                let _ = write!(out, "- {l}\n");
16895            }
16896        }
16897    }
16898    Ok(out)
16899}
16900
16901#[cfg(windows)]
16902fn inspect_mtu() -> Result<String, String> {
16903    let mut out = String::with_capacity(1024);
16904
16905    out.push_str("=== Per-adapter MTU (IPv4) ===\n");
16906    let ps_mtu = r#"
16907Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv4" } |
16908    Sort-Object ConnectionState, InterfaceAlias |
16909    ForEach-Object {
16910        "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
16911    }
16912"#;
16913    match run_powershell(ps_mtu) {
16914        Ok(o) => {
16915            for line in o.lines() {
16916                let l = line.trim();
16917                if !l.is_empty() {
16918                    let _ = writeln!(out, "- {l}");
16919                }
16920            }
16921        }
16922        Err(e) => {
16923            let _ = writeln!(out, "- MTU query error: {e}");
16924        }
16925    }
16926
16927    out.push_str("\n=== Per-adapter MTU (IPv6) ===\n");
16928    let ps_mtu6 = r#"
16929Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv6" } |
16930    Sort-Object ConnectionState, InterfaceAlias |
16931    ForEach-Object {
16932        "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
16933    }
16934"#;
16935    match run_powershell(ps_mtu6) {
16936        Ok(o) => {
16937            for line in o.lines() {
16938                let l = line.trim();
16939                if !l.is_empty() {
16940                    let _ = writeln!(out, "- {l}");
16941                }
16942            }
16943        }
16944        Err(e) => {
16945            let _ = writeln!(out, "- IPv6 MTU query error: {e}");
16946        }
16947    }
16948
16949    out.push_str("\n=== Path MTU discovery (ping DF-bit to 8.8.8.8) ===\n");
16950    // Send a 1472-byte payload (1500 - 28 IP+ICMP headers) to test standard Ethernet MTU
16951    let ps_pmtu = r#"
16952$sizes = @(1472, 1400, 1280, 576)
16953$result = $null
16954foreach ($s in $sizes) {
16955    $r = Test-Connection -ComputerName "8.8.8.8" -Count 1 -BufferSize $s -ErrorAction SilentlyContinue
16956    if ($r) { $result = $s; break }
16957}
16958if ($result) { "Largest successful payload: $result bytes (path MTU >= $($result + 28) bytes)" }
16959else { "All test sizes failed — path MTU may be very restricted or ICMP is blocked" }
16960"#;
16961    match run_powershell(ps_pmtu) {
16962        Ok(o) => {
16963            for line in o.lines() {
16964                let l = line.trim();
16965                if !l.is_empty() {
16966                    let _ = writeln!(out, "- {l}");
16967                }
16968            }
16969        }
16970        Err(e) => {
16971            let _ = writeln!(out, "- Path MTU test error: {e}");
16972        }
16973    }
16974
16975    let mut findings: Vec<String> = Vec::with_capacity(4);
16976    if out.contains("MTU: 576 bytes") {
16977        findings.push("576-byte MTU detected — severely restricted path, likely a misconfigured VPN or legacy link.".into());
16978    }
16979    if out.contains("MTU: 1280 bytes") && !out.contains("IPv6") {
16980        findings.push(
16981            "1280-byte MTU on an IPv4 interface is unusually low — check VPN or PPPoE config."
16982                .into(),
16983        );
16984    }
16985    if out.contains("All test sizes failed") {
16986        findings.push("Path MTU test failed — ICMP may be blocked by firewall or all tested sizes exceed the path limit.".into());
16987    }
16988
16989    let mut result = String::from("Host inspection: mtu\n\n=== Findings ===\n");
16990    if findings.is_empty() {
16991        result.push_str("- MTU configuration looks normal.\n");
16992    } else {
16993        for f in &findings {
16994            let _ = writeln!(result, "- Finding: {f}");
16995        }
16996    }
16997    result.push('\n');
16998    result.push_str(&out);
16999    Ok(result)
17000}
17001
17002#[cfg(not(windows))]
17003fn inspect_mtu() -> Result<String, String> {
17004    let mut out = String::from(
17005        "Host inspection: mtu\n\n=== Findings ===\n- MTU inspection running on Unix.\n\n",
17006    );
17007
17008    out.push_str("=== Per-interface MTU (ip link) ===\n");
17009    let ip = std::process::Command::new("ip")
17010        .args(["link", "show"])
17011        .output();
17012    if let Ok(o) = ip {
17013        for line in String::from_utf8_lossy(&o.stdout).lines() {
17014            let l = line.trim();
17015            if l.contains("mtu") || l.starts_with("\\d") {
17016                let _ = write!(out, "- {l}\n");
17017            }
17018        }
17019    }
17020
17021    out.push_str("\n=== Path MTU test (ping -M do to 8.8.8.8) ===\n");
17022    let ping = std::process::Command::new("ping")
17023        .args(["-c", "1", "-M", "do", "-s", "1472", "8.8.8.8"])
17024        .output();
17025    match ping {
17026        Ok(o) => {
17027            let body = String::from_utf8_lossy(&o.stdout);
17028            for line in body.lines() {
17029                let l = line.trim();
17030                if !l.is_empty() {
17031                    let _ = write!(out, "- {l}\n");
17032                }
17033            }
17034        }
17035        Err(e) => {
17036            let _ = write!(out, "- Ping error: {e}\n");
17037        }
17038    }
17039    Ok(out)
17040}
17041
17042#[cfg(not(windows))]
17043fn inspect_cpu_power() -> Result<String, String> {
17044    let mut out = String::from("Host inspection: cpu_power\n\n=== Findings ===\n- CPU power inspection running on Unix.\n\n");
17045
17046    // Linux: cpufreq-info or /sys/devices/system/cpu
17047    out.push_str("=== CPU frequency (Linux) ===\n");
17048    let cat_scaling = std::process::Command::new("cat")
17049        .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq")
17050        .output();
17051    if let Ok(o) = cat_scaling {
17052        let khz: u64 = String::from_utf8_lossy(&o.stdout)
17053            .trim()
17054            .parse()
17055            .unwrap_or(0);
17056        if khz > 0 {
17057            let _ = write!(out, "- Current: {} MHz\n", khz / 1000);
17058        }
17059    }
17060    let cat_max = std::process::Command::new("cat")
17061        .arg("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq")
17062        .output();
17063    if let Ok(o) = cat_max {
17064        let khz: u64 = String::from_utf8_lossy(&o.stdout)
17065            .trim()
17066            .parse()
17067            .unwrap_or(0);
17068        if khz > 0 {
17069            let _ = write!(out, "- Max: {} MHz\n", khz / 1000);
17070        }
17071    }
17072    let governor = std::process::Command::new("cat")
17073        .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
17074        .output();
17075    if let Ok(o) = governor {
17076        let g = String::from_utf8_lossy(&o.stdout);
17077        let g = g.trim();
17078        if !g.is_empty() {
17079            let _ = write!(out, "- Governor: {g}\n");
17080        }
17081    }
17082    Ok(out)
17083}
17084
17085// ── IPv6 ────────────────────────────────────────────────────────────────────
17086
17087#[cfg(windows)]
17088fn inspect_ipv6() -> Result<String, String> {
17089    let script = r#"
17090$result = [System.Text.StringBuilder]::new()
17091
17092# Per-adapter IPv6 addresses
17093$result.AppendLine("=== IPv6 addresses per adapter ===") | Out-Null
17094$adapters = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
17095    Where-Object { $_.IPAddress -notmatch '^::1$' } |
17096    Sort-Object InterfaceAlias
17097foreach ($a in $adapters) {
17098    $prefix = $a.PrefixOrigin
17099    $suffix = $a.SuffixOrigin
17100    $scope  = $a.AddressState
17101    $result.AppendLine("  [$($a.InterfaceAlias)] $($a.IPAddress)/$($a.PrefixLength)  origin=$prefix/$suffix  state=$scope") | Out-Null
17102}
17103if (-not $adapters) { $result.AppendLine("  No global/link-local IPv6 addresses found.") | Out-Null }
17104
17105# Default gateway IPv6
17106$result.AppendLine("") | Out-Null
17107$result.AppendLine("=== IPv6 default gateway ===") | Out-Null
17108$gw6 = Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue
17109if ($gw6) {
17110    foreach ($g in $gw6) {
17111        $result.AppendLine("  [$($g.InterfaceAlias)] via $($g.NextHop)  metric=$($g.RouteMetric)") | Out-Null
17112    }
17113} else {
17114    $result.AppendLine("  No IPv6 default gateway configured.") | Out-Null
17115}
17116
17117# DHCPv6 lease info
17118$result.AppendLine("") | Out-Null
17119$result.AppendLine("=== DHCPv6 / prefix delegation ===") | Out-Null
17120$dhcpv6 = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
17121    Where-Object { $_.PrefixOrigin -eq 'Dhcp' -or $_.SuffixOrigin -eq 'Dhcp' }
17122if ($dhcpv6) {
17123    foreach ($d in $dhcpv6) {
17124        $result.AppendLine("  [$($d.InterfaceAlias)] $($d.IPAddress) (DHCPv6-assigned)") | Out-Null
17125    }
17126} else {
17127    $result.AppendLine("  No DHCPv6-assigned addresses found (SLAAC or static in use).") | Out-Null
17128}
17129
17130# Privacy extensions
17131$result.AppendLine("") | Out-Null
17132$result.AppendLine("=== Privacy extensions (RFC 4941) ===") | Out-Null
17133try {
17134    $priv = netsh interface ipv6 show privacy
17135    $result.AppendLine(($priv -join "`n")) | Out-Null
17136} catch {
17137    $result.AppendLine("  Could not retrieve privacy extension state.") | Out-Null
17138}
17139
17140# Tunnel adapters
17141$result.AppendLine("") | Out-Null
17142$result.AppendLine("=== Tunnel adapters ===") | Out-Null
17143$tunnels = Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceDescription -match 'Teredo|6to4|ISATAP|Tunnel' }
17144if ($tunnels) {
17145    foreach ($t in $tunnels) {
17146        $result.AppendLine("  $($t.Name): $($t.InterfaceDescription)  Status=$($t.Status)") | Out-Null
17147    }
17148} else {
17149    $result.AppendLine("  No Teredo/6to4/ISATAP tunnel adapters found.") | Out-Null
17150}
17151
17152# Findings
17153$findings = [System.Collections.Generic.List[string]]::new()
17154$globalAddrs = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
17155    Where-Object { $_.IPAddress -match '^2[0-9a-f]{3}:' -or $_.IPAddress -match '^fc|^fd' }
17156if (-not $globalAddrs) { $findings.Add("No global unicast IPv6 address assigned — IPv6 internet access may be unavailable.") }
17157$noGw6 = -not (Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue)
17158if ($noGw6) { $findings.Add("No IPv6 default gateway — IPv6 routing not active.") }
17159
17160$result.AppendLine("") | Out-Null
17161$result.AppendLine("=== Findings ===") | Out-Null
17162if ($findings.Count -eq 0) {
17163    $result.AppendLine("- IPv6 configuration looks healthy.") | Out-Null
17164} else {
17165    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17166}
17167
17168Write-Output $result.ToString()
17169"#;
17170    let out = run_powershell(script)?;
17171    Ok(format!("Host inspection: ipv6\n\n{out}"))
17172}
17173
17174#[cfg(not(windows))]
17175fn inspect_ipv6() -> Result<String, String> {
17176    let mut out = String::from("Host inspection: ipv6\n\n=== IPv6 addresses (ip -6 addr) ===\n");
17177    if let Ok(o) = std::process::Command::new("ip")
17178        .args(["-6", "addr", "show"])
17179        .output()
17180    {
17181        out.push_str(&String::from_utf8_lossy(&o.stdout));
17182    }
17183    out.push_str("\n=== IPv6 routes (ip -6 route) ===\n");
17184    if let Ok(o) = std::process::Command::new("ip")
17185        .args(["-6", "route"])
17186        .output()
17187    {
17188        out.push_str(&String::from_utf8_lossy(&o.stdout));
17189    }
17190    Ok(out)
17191}
17192
17193// ── TCP Parameters ──────────────────────────────────────────────────────────
17194
17195#[cfg(windows)]
17196fn inspect_tcp_params() -> Result<String, String> {
17197    let script = r#"
17198$result = [System.Text.StringBuilder]::new()
17199
17200# Autotuning and global TCP settings
17201$result.AppendLine("=== TCP global settings (netsh) ===") | Out-Null
17202try {
17203    $global = netsh interface tcp show global
17204    foreach ($line in $global) {
17205        $l = $line.Trim()
17206        if ($l -and $l -notmatch '^---' -and $l -notmatch '^TCP Global') {
17207            $result.AppendLine("  $l") | Out-Null
17208        }
17209    }
17210} catch {
17211    $result.AppendLine("  Could not retrieve TCP global settings.") | Out-Null
17212}
17213
17214# Supplemental params via Get-NetTCPSetting
17215$result.AppendLine("") | Out-Null
17216$result.AppendLine("=== TCP settings profiles ===") | Out-Null
17217try {
17218    $tcpSettings = Get-NetTCPSetting -ErrorAction SilentlyContinue
17219    foreach ($s in $tcpSettings) {
17220        $result.AppendLine("  Profile: $($s.SettingName)") | Out-Null
17221        $result.AppendLine("    CongestionProvider:      $($s.CongestionProvider)") | Out-Null
17222        $result.AppendLine("    InitialCongestionWindow: $($s.InitialCongestionWindowMss) MSS") | Out-Null
17223        $result.AppendLine("    AutoTuningLevelLocal:    $($s.AutoTuningLevelLocal)") | Out-Null
17224        $result.AppendLine("    ScalingHeuristics:       $($s.ScalingHeuristics)") | Out-Null
17225        $result.AppendLine("    DynamicPortRangeStart:   $($s.DynamicPortRangeStartPort)") | Out-Null
17226        $result.AppendLine("    DynamicPortRangeEnd:     $($s.DynamicPortRangeStartPort + $s.DynamicPortRangeNumberOfPorts - 1)") | Out-Null
17227        $result.AppendLine("") | Out-Null
17228    }
17229} catch {
17230    $result.AppendLine("  Get-NetTCPSetting unavailable.") | Out-Null
17231}
17232
17233# Chimney offload state
17234$result.AppendLine("=== TCP Chimney offload ===") | Out-Null
17235try {
17236    $chimney = netsh interface tcp show chimney
17237    $result.AppendLine(($chimney -join "`n  ")) | Out-Null
17238} catch {
17239    $result.AppendLine("  Could not retrieve chimney state.") | Out-Null
17240}
17241
17242# ECN state
17243$result.AppendLine("") | Out-Null
17244$result.AppendLine("=== ECN capability ===") | Out-Null
17245try {
17246    $ecn = netsh interface tcp show ecncapability
17247    $result.AppendLine(($ecn -join "`n  ")) | Out-Null
17248} catch {
17249    $result.AppendLine("  Could not retrieve ECN state.") | Out-Null
17250}
17251
17252# Findings
17253$findings = [System.Collections.Generic.List[string]]::new()
17254try {
17255    $ts = Get-NetTCPSetting -SettingName 'Internet' -ErrorAction SilentlyContinue
17256    if ($ts -and $ts.AutoTuningLevelLocal -eq 'Disabled') {
17257        $findings.Add("TCP autotuning is DISABLED on the Internet profile — may limit throughput on high-latency links.")
17258    }
17259    if ($ts -and $ts.CongestionProvider -ne 'CUBIC' -and $ts.CongestionProvider -ne 'NewReno' -and $ts.CongestionProvider) {
17260        $findings.Add("Non-standard congestion provider: $($ts.CongestionProvider)")
17261    }
17262} catch {}
17263
17264$result.AppendLine("") | Out-Null
17265$result.AppendLine("=== Findings ===") | Out-Null
17266if ($findings.Count -eq 0) {
17267    $result.AppendLine("- TCP parameters look normal.") | Out-Null
17268} else {
17269    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17270}
17271
17272Write-Output $result.ToString()
17273"#;
17274    let out = run_powershell(script)?;
17275    Ok(format!("Host inspection: tcp_params\n\n{out}"))
17276}
17277
17278#[cfg(not(windows))]
17279fn inspect_tcp_params() -> Result<String, String> {
17280    let mut out = String::from("Host inspection: tcp_params\n\n=== TCP kernel parameters ===\n");
17281    for key in &[
17282        "net.ipv4.tcp_congestion_control",
17283        "net.ipv4.tcp_rmem",
17284        "net.ipv4.tcp_wmem",
17285        "net.ipv4.tcp_window_scaling",
17286        "net.ipv4.tcp_ecn",
17287        "net.ipv4.tcp_timestamps",
17288    ] {
17289        if let Ok(o) = std::process::Command::new("sysctl").arg(key).output() {
17290            let _ = write!(out, "  {}\n", String::from_utf8_lossy(&o.stdout).trim());
17291        }
17292    }
17293    Ok(out)
17294}
17295
17296// ── WLAN Profiles ───────────────────────────────────────────────────────────
17297
17298#[cfg(windows)]
17299fn inspect_wlan_profiles() -> Result<String, String> {
17300    let script = r#"
17301$result = [System.Text.StringBuilder]::new()
17302
17303# List all saved profiles
17304$result.AppendLine("=== Saved wireless profiles ===") | Out-Null
17305try {
17306    $profilesRaw = netsh wlan show profiles
17307    $profiles = $profilesRaw | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
17308        $_.Matches[0].Groups[1].Value.Trim()
17309    }
17310
17311    if (-not $profiles) {
17312        $result.AppendLine("  No saved wireless profiles found.") | Out-Null
17313    } else {
17314        foreach ($p in $profiles) {
17315            $result.AppendLine("") | Out-Null
17316            $result.AppendLine("  Profile: $p") | Out-Null
17317            # Get detail for each profile
17318            $detail = netsh wlan show profile name="$p" key=clear 2>$null
17319            $auth      = ($detail | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
17320            $cipher    = ($detail | Select-String 'Cipher\s*:\s*(.+)') | Select-Object -First 1
17321            $conn      = ($detail | Select-String 'Connection mode\s*:\s*(.+)') | Select-Object -First 1
17322            $autoConn  = ($detail | Select-String 'Connect automatically\s*:\s*(.+)') | Select-Object -First 1
17323            if ($auth)     { $result.AppendLine("    Authentication:    $($auth.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17324            if ($cipher)   { $result.AppendLine("    Cipher:            $($cipher.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17325            if ($conn)     { $result.AppendLine("    Connection mode:   $($conn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17326            if ($autoConn) { $result.AppendLine("    Auto-connect:      $($autoConn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17327        }
17328    }
17329} catch {
17330    $result.AppendLine("  netsh wlan unavailable (no wireless adapter or WLAN service not running).") | Out-Null
17331}
17332
17333# Currently connected SSID
17334$result.AppendLine("") | Out-Null
17335$result.AppendLine("=== Currently connected ===") | Out-Null
17336try {
17337    $conn = netsh wlan show interfaces
17338    $ssid   = ($conn | Select-String 'SSID\s*:\s*(?!BSSID)(.+)') | Select-Object -First 1
17339    $bssid  = ($conn | Select-String 'BSSID\s*:\s*(.+)') | Select-Object -First 1
17340    $signal = ($conn | Select-String 'Signal\s*:\s*(.+)') | Select-Object -First 1
17341    $radio  = ($conn | Select-String 'Radio type\s*:\s*(.+)') | Select-Object -First 1
17342    if ($ssid)   { $result.AppendLine("  SSID:       $($ssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17343    if ($bssid)  { $result.AppendLine("  BSSID:      $($bssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17344    if ($signal) { $result.AppendLine("  Signal:     $($signal.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17345    if ($radio)  { $result.AppendLine("  Radio type: $($radio.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17346    if (-not $ssid) { $result.AppendLine("  Not connected to any wireless network.") | Out-Null }
17347} catch {
17348    $result.AppendLine("  Could not query wireless interface state.") | Out-Null
17349}
17350
17351# Findings
17352$findings = [System.Collections.Generic.List[string]]::new()
17353try {
17354    $allDetail = netsh wlan show profiles 2>$null
17355    $profileNames = $allDetail | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
17356        $_.Matches[0].Groups[1].Value.Trim()
17357    }
17358    foreach ($pn in $profileNames) {
17359        $det = netsh wlan show profile name="$pn" key=clear 2>$null
17360        $authLine = ($det | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
17361        if ($authLine) {
17362            $authVal = $authLine.Matches[0].Groups[1].Value.Trim()
17363            if ($authVal -match 'Open|WEP|None') {
17364                $findings.Add("Profile '$pn' uses weak/open authentication: $authVal")
17365            }
17366        }
17367    }
17368} catch {}
17369
17370$result.AppendLine("") | Out-Null
17371$result.AppendLine("=== Findings ===") | Out-Null
17372if ($findings.Count -eq 0) {
17373    $result.AppendLine("- All saved wireless profiles use acceptable authentication.") | Out-Null
17374} else {
17375    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17376}
17377
17378Write-Output $result.ToString()
17379"#;
17380    let out = run_powershell(script)?;
17381    Ok(format!("Host inspection: wlan_profiles\n\n{out}"))
17382}
17383
17384#[cfg(not(windows))]
17385fn inspect_wlan_profiles() -> Result<String, String> {
17386    let mut out =
17387        String::from("Host inspection: wlan_profiles\n\n=== Saved wireless profiles ===\n");
17388    // Try nmcli (NetworkManager)
17389    if let Ok(o) = std::process::Command::new("nmcli")
17390        .args(["-t", "-f", "NAME,TYPE,DEVICE", "connection", "show"])
17391        .output()
17392    {
17393        for line in String::from_utf8_lossy(&o.stdout).lines() {
17394            if line.contains("wireless") || line.contains("wifi") {
17395                let _ = write!(out, "  {line}\n");
17396            }
17397        }
17398    } else {
17399        out.push_str("  nmcli not available.\n");
17400    }
17401    Ok(out)
17402}
17403
17404// ── IPSec ───────────────────────────────────────────────────────────────────
17405
17406#[cfg(windows)]
17407fn inspect_ipsec() -> Result<String, String> {
17408    let script = r#"
17409$result = [System.Text.StringBuilder]::new()
17410
17411# IPSec rules (firewall-integrated)
17412$result.AppendLine("=== IPSec connection security rules ===") | Out-Null
17413try {
17414    $rules = Get-NetIPsecRule -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq 'True' }
17415    if ($rules) {
17416        foreach ($r in $rules) {
17417            $result.AppendLine("  [$($r.DisplayName)]") | Out-Null
17418            $result.AppendLine("    Mode:       $($r.Mode)") | Out-Null
17419            $result.AppendLine("    Action:     $($r.Action)") | Out-Null
17420            $result.AppendLine("    InProfile:  $($r.Profile)") | Out-Null
17421        }
17422    } else {
17423        $result.AppendLine("  No enabled IPSec connection security rules found.") | Out-Null
17424    }
17425} catch {
17426    $result.AppendLine("  Get-NetIPsecRule unavailable.") | Out-Null
17427}
17428
17429# Active main-mode SAs
17430$result.AppendLine("") | Out-Null
17431$result.AppendLine("=== Active IPSec main-mode SAs ===") | Out-Null
17432try {
17433    $mmSAs = Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue
17434    if ($mmSAs) {
17435        foreach ($sa in $mmSAs) {
17436            $result.AppendLine("  Local: $($sa.LocalAddress)  <-->  Remote: $($sa.RemoteAddress)") | Out-Null
17437            $result.AppendLine("    AuthMethod: $($sa.LocalFirstId)  Cipher: $($sa.Cipher)") | Out-Null
17438        }
17439    } else {
17440        $result.AppendLine("  No active main-mode IPSec SAs.") | Out-Null
17441    }
17442} catch {
17443    $result.AppendLine("  Get-NetIPsecMainModeSA unavailable.") | Out-Null
17444}
17445
17446# Active quick-mode SAs
17447$result.AppendLine("") | Out-Null
17448$result.AppendLine("=== Active IPSec quick-mode SAs ===") | Out-Null
17449try {
17450    $qmSAs = Get-NetIPsecQuickModeSA -ErrorAction SilentlyContinue
17451    if ($qmSAs) {
17452        foreach ($sa in $qmSAs) {
17453            $result.AppendLine("  Local: $($sa.LocalAddress)  <-->  Remote: $($sa.RemoteAddress)") | Out-Null
17454            $result.AppendLine("    Encapsulation: $($sa.EncapsulationMode)  Protocol: $($sa.TransportLayerProtocol)") | Out-Null
17455        }
17456    } else {
17457        $result.AppendLine("  No active quick-mode IPSec SAs.") | Out-Null
17458    }
17459} catch {
17460    $result.AppendLine("  Get-NetIPsecQuickModeSA unavailable.") | Out-Null
17461}
17462
17463# IKE service state
17464$result.AppendLine("") | Out-Null
17465$result.AppendLine("=== IKE / IPSec Policy Agent service ===") | Out-Null
17466$ikeAgentSvc = Get-Service -Name 'PolicyAgent' -ErrorAction SilentlyContinue
17467if ($ikeAgentSvc) {
17468    $result.AppendLine("  PolicyAgent (IPSec Policy Agent): $($ikeAgentSvc.Status)") | Out-Null
17469} else {
17470    $result.AppendLine("  PolicyAgent service not found.") | Out-Null
17471}
17472
17473# Findings
17474$findings = [System.Collections.Generic.List[string]]::new()
17475$mmSACount = 0
17476try { $mmSACount = (Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue | Measure-Object).Count } catch {}
17477if ($mmSACount -gt 0) {
17478    $findings.Add("$mmSACount active IPSec main-mode SA(s) — IPSec tunnel is active.")
17479}
17480
17481$result.AppendLine("") | Out-Null
17482$result.AppendLine("=== Findings ===") | Out-Null
17483if ($findings.Count -eq 0) {
17484    $result.AppendLine("- No active IPSec SAs detected (no IPSec tunnel currently established).") | Out-Null
17485} else {
17486    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17487}
17488
17489Write-Output $result.ToString()
17490"#;
17491    let out = run_powershell(script)?;
17492    Ok(format!("Host inspection: ipsec\n\n{out}"))
17493}
17494
17495#[cfg(not(windows))]
17496fn inspect_ipsec() -> Result<String, String> {
17497    let mut out = String::from("Host inspection: ipsec\n\n=== IPSec SAs (ip xfrm state) ===\n");
17498    if let Ok(o) = std::process::Command::new("ip")
17499        .args(["xfrm", "state"])
17500        .output()
17501    {
17502        let body = String::from_utf8_lossy(&o.stdout);
17503        if body.trim().is_empty() {
17504            out.push_str("  No active IPSec SAs.\n");
17505        } else {
17506            out.push_str(&body);
17507        }
17508    }
17509    out.push_str("\n=== IPSec policies (ip xfrm policy) ===\n");
17510    if let Ok(o) = std::process::Command::new("ip")
17511        .args(["xfrm", "policy"])
17512        .output()
17513    {
17514        let body = String::from_utf8_lossy(&o.stdout);
17515        if body.trim().is_empty() {
17516            out.push_str("  No IPSec policies.\n");
17517        } else {
17518            out.push_str(&body);
17519        }
17520    }
17521    Ok(out)
17522}
17523
17524// ── NetBIOS ──────────────────────────────────────────────────────────────────
17525
17526#[cfg(windows)]
17527fn inspect_netbios() -> Result<String, String> {
17528    let script = r#"
17529$result = [System.Text.StringBuilder]::new()
17530
17531# NetBIOS node type and WINS per adapter
17532$result.AppendLine("=== NetBIOS configuration per adapter ===") | Out-Null
17533try {
17534    $adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17535        Where-Object { $_.IPEnabled -eq $true }
17536    foreach ($a in $adapters) {
17537        $nodeType = switch ($a.TcpipNetbiosOptions) {
17538            0 { "EnableNetBIOSViaDHCP" }
17539            1 { "Enabled" }
17540            2 { "Disabled" }
17541            default { "Unknown ($($a.TcpipNetbiosOptions))" }
17542        }
17543        $result.AppendLine("  [$($a.Description)]") | Out-Null
17544        $result.AppendLine("    NetBIOS over TCP/IP: $nodeType") | Out-Null
17545        if ($a.WINSPrimaryServer) {
17546            $result.AppendLine("    WINS Primary:        $($a.WINSPrimaryServer)") | Out-Null
17547        }
17548        if ($a.WINSSecondaryServer) {
17549            $result.AppendLine("    WINS Secondary:      $($a.WINSSecondaryServer)") | Out-Null
17550        }
17551    }
17552} catch {
17553    $result.AppendLine("  Could not query NetBIOS adapter config.") | Out-Null
17554}
17555
17556# nbtstat -n — registered local NetBIOS names
17557$result.AppendLine("") | Out-Null
17558$result.AppendLine("=== Registered NetBIOS names (nbtstat -n) ===") | Out-Null
17559try {
17560    $nbt = nbtstat -n 2>$null
17561    foreach ($line in $nbt) {
17562        $l = $line.Trim()
17563        if ($l -and $l -notmatch '^Node|^Host|^Registered|^-{3}') {
17564            $result.AppendLine("  $l") | Out-Null
17565        }
17566    }
17567} catch {
17568    $result.AppendLine("  nbtstat not available.") | Out-Null
17569}
17570
17571# NetBIOS session table
17572$result.AppendLine("") | Out-Null
17573$result.AppendLine("=== Active NetBIOS sessions (nbtstat -s) ===") | Out-Null
17574try {
17575    $sessions = nbtstat -s 2>$null | Where-Object { $_.Trim() -ne '' }
17576    if ($sessions) {
17577        foreach ($s in $sessions) { $result.AppendLine("  $($s.Trim())") | Out-Null }
17578    } else {
17579        $result.AppendLine("  No active NetBIOS sessions.") | Out-Null
17580    }
17581} catch {
17582    $result.AppendLine("  Could not query NetBIOS sessions.") | Out-Null
17583}
17584
17585# Findings
17586$findings = [System.Collections.Generic.List[string]]::new()
17587try {
17588    $enabled = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17589        Where-Object { $_.IPEnabled -and $_.TcpipNetbiosOptions -ne 2 }
17590    if ($enabled) {
17591        $findings.Add("NetBIOS over TCP/IP is enabled on $($enabled.Count) adapter(s) — potential attack surface if not required.")
17592    }
17593    $wins = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17594        Where-Object { $_.WINSPrimaryServer }
17595    if ($wins) {
17596        $findings.Add("WINS server configured: $($wins[0].WINSPrimaryServer) — verify this is intentional.")
17597    }
17598} catch {}
17599
17600$result.AppendLine("") | Out-Null
17601$result.AppendLine("=== Findings ===") | Out-Null
17602if ($findings.Count -eq 0) {
17603    $result.AppendLine("- NetBIOS configuration looks standard.") | Out-Null
17604} else {
17605    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17606}
17607
17608Write-Output $result.ToString()
17609"#;
17610    let out = run_powershell(script)?;
17611    Ok(format!("Host inspection: netbios\n\n{out}"))
17612}
17613
17614#[cfg(not(windows))]
17615fn inspect_netbios() -> Result<String, String> {
17616    let mut out = String::from("Host inspection: netbios\n\n=== NetBIOS (nmblookup) ===\n");
17617    if let Ok(o) = std::process::Command::new("nmblookup")
17618        .arg("-A")
17619        .arg("localhost")
17620        .output()
17621    {
17622        out.push_str(&String::from_utf8_lossy(&o.stdout));
17623    } else {
17624        out.push_str("  nmblookup not available (Samba not installed).\n");
17625    }
17626    Ok(out)
17627}
17628
17629// ── NIC Teaming ──────────────────────────────────────────────────────────────
17630
17631#[cfg(windows)]
17632fn inspect_nic_teaming() -> Result<String, String> {
17633    let script = r#"
17634$result = [System.Text.StringBuilder]::new()
17635
17636# Team inventory
17637$result.AppendLine("=== NIC teams ===") | Out-Null
17638try {
17639    $teams = Get-NetLbfoTeam -ErrorAction SilentlyContinue
17640    if ($teams) {
17641        foreach ($t in $teams) {
17642            $result.AppendLine("  Team: $($t.Name)") | Out-Null
17643            $result.AppendLine("    Mode:            $($t.TeamingMode)") | Out-Null
17644            $result.AppendLine("    LB Algorithm:    $($t.LoadBalancingAlgorithm)") | Out-Null
17645            $result.AppendLine("    Status:          $($t.Status)") | Out-Null
17646            $result.AppendLine("    Members:         $($t.Members -join ', ')") | Out-Null
17647            $result.AppendLine("    VLANs:           $($t.TransmitLinkSpeed / 1000000) Mbps TX / $($t.ReceiveLinkSpeed / 1000000) Mbps RX") | Out-Null
17648        }
17649    } else {
17650        $result.AppendLine("  No NIC teams configured on this machine.") | Out-Null
17651    }
17652} catch {
17653    $result.AppendLine("  Get-NetLbfoTeam unavailable (feature may not be installed).") | Out-Null
17654}
17655
17656# Team members detail
17657$result.AppendLine("") | Out-Null
17658$result.AppendLine("=== Team member detail ===") | Out-Null
17659try {
17660    $members = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue
17661    if ($members) {
17662        foreach ($m in $members) {
17663            $result.AppendLine("  [$($m.Team)] $($m.Name)  Role=$($m.AdministrativeMode)  Status=$($m.OperationalStatus)") | Out-Null
17664        }
17665    } else {
17666        $result.AppendLine("  No team members found.") | Out-Null
17667    }
17668} catch {
17669    $result.AppendLine("  Could not query team members.") | Out-Null
17670}
17671
17672# Findings
17673$findings = [System.Collections.Generic.List[string]]::new()
17674try {
17675    $degraded = Get-NetLbfoTeam -ErrorAction SilentlyContinue | Where-Object { $_.Status -ne 'Up' }
17676    if ($degraded) {
17677        foreach ($d in $degraded) { $findings.Add("Team '$($d.Name)' is in degraded state: $($d.Status)") }
17678    }
17679    $downMembers = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue | Where-Object { $_.OperationalStatus -ne 'Active' }
17680    if ($downMembers) {
17681        foreach ($m in $downMembers) { $findings.Add("Team member '$($m.Name)' in team '$($m.Team)' is not Active: $($m.OperationalStatus)") }
17682    }
17683} catch {}
17684
17685$result.AppendLine("") | Out-Null
17686$result.AppendLine("=== Findings ===") | Out-Null
17687if ($findings.Count -eq 0) {
17688    $result.AppendLine("- NIC teaming state looks healthy (or no teams configured).") | Out-Null
17689} else {
17690    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17691}
17692
17693Write-Output $result.ToString()
17694"#;
17695    let out = run_powershell(script)?;
17696    Ok(format!("Host inspection: nic_teaming\n\n{out}"))
17697}
17698
17699#[cfg(not(windows))]
17700fn inspect_nic_teaming() -> Result<String, String> {
17701    let mut out = String::from("Host inspection: nic_teaming\n\n=== Bond interfaces ===\n");
17702    if let Ok(o) = std::process::Command::new("cat")
17703        .arg("/proc/net/bonding/bond0")
17704        .output()
17705    {
17706        if o.status.success() {
17707            out.push_str(&String::from_utf8_lossy(&o.stdout));
17708        } else {
17709            out.push_str("  No bond0 interface found.\n");
17710        }
17711    }
17712    if let Ok(o) = std::process::Command::new("ip")
17713        .args(["link", "show", "type", "bond"])
17714        .output()
17715    {
17716        let body = String::from_utf8_lossy(&o.stdout);
17717        if !body.trim().is_empty() {
17718            out.push_str("\n=== Bond links (ip link) ===\n");
17719            out.push_str(&body);
17720        }
17721    }
17722    Ok(out)
17723}
17724
17725// ── SNMP ─────────────────────────────────────────────────────────────────────
17726
17727#[cfg(windows)]
17728fn inspect_snmp() -> Result<String, String> {
17729    let script = r#"
17730$result = [System.Text.StringBuilder]::new()
17731
17732# SNMP service state
17733$result.AppendLine("=== SNMP service state ===") | Out-Null
17734$svc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
17735if ($svc) {
17736    $result.AppendLine("  SNMP Agent service: $($svc.Status) (Startup: $($svc.StartType))") | Out-Null
17737} else {
17738    $result.AppendLine("  SNMP Agent service not installed.") | Out-Null
17739}
17740
17741$svcTrap = Get-Service -Name 'SNMPTRAP' -ErrorAction SilentlyContinue
17742if ($svcTrap) {
17743    $result.AppendLine("  SNMP Trap service:  $($svcTrap.Status) (Startup: $($svcTrap.StartType))") | Out-Null
17744}
17745
17746# Community strings (presence only — values redacted)
17747$result.AppendLine("") | Out-Null
17748$result.AppendLine("=== SNMP community strings (presence only) ===") | Out-Null
17749try {
17750    $communities = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
17751    if ($communities) {
17752        $names = $communities.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Name
17753        if ($names) {
17754            foreach ($n in $names) {
17755                $result.AppendLine("  Community: '$n'  (value redacted)") | Out-Null
17756            }
17757        } else {
17758            $result.AppendLine("  No community strings configured.") | Out-Null
17759        }
17760    } else {
17761        $result.AppendLine("  Registry key not found (SNMP may not be configured).") | Out-Null
17762    }
17763} catch {
17764    $result.AppendLine("  Could not read community strings (SNMP not configured or access denied).") | Out-Null
17765}
17766
17767# Permitted managers
17768$result.AppendLine("") | Out-Null
17769$result.AppendLine("=== Permitted SNMP managers ===") | Out-Null
17770try {
17771    $managers = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\PermittedManagers' -ErrorAction SilentlyContinue
17772    if ($managers) {
17773        $mgrs = $managers.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Value
17774        if ($mgrs) {
17775            foreach ($m in $mgrs) { $result.AppendLine("  $m") | Out-Null }
17776        } else {
17777            $result.AppendLine("  No permitted managers configured (accepts from any host).") | Out-Null
17778        }
17779    } else {
17780        $result.AppendLine("  No manager restrictions configured.") | Out-Null
17781    }
17782} catch {
17783    $result.AppendLine("  Could not read permitted managers.") | Out-Null
17784}
17785
17786# Findings
17787$findings = [System.Collections.Generic.List[string]]::new()
17788$snmpSvc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
17789if ($snmpSvc -and $snmpSvc.Status -eq 'Running') {
17790    $findings.Add("SNMP Agent is running — verify community strings and permitted managers are locked down.")
17791    try {
17792        $comms = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
17793        $publicExists = $comms.PSObject.Properties | Where-Object { $_.Name -eq 'public' }
17794        if ($publicExists) { $findings.Add("Community string 'public' is configured — this is a well-known default and a security risk.") }
17795    } catch {}
17796}
17797
17798$result.AppendLine("") | Out-Null
17799$result.AppendLine("=== Findings ===") | Out-Null
17800if ($findings.Count -eq 0) {
17801    $result.AppendLine("- SNMP agent is not running (or not installed). No exposure.") | Out-Null
17802} else {
17803    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17804}
17805
17806Write-Output $result.ToString()
17807"#;
17808    let out = run_powershell(script)?;
17809    Ok(format!("Host inspection: snmp\n\n{out}"))
17810}
17811
17812#[cfg(not(windows))]
17813fn inspect_snmp() -> Result<String, String> {
17814    let mut out = String::from("Host inspection: snmp\n\n=== SNMP daemon state ===\n");
17815    for svc in &["snmpd", "snmp"] {
17816        if let Ok(o) = std::process::Command::new("systemctl")
17817            .args(["is-active", svc])
17818            .output()
17819        {
17820            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
17821            let _ = write!(out, "  {svc}: {status}\n");
17822        }
17823    }
17824    out.push_str("\n=== snmpd.conf community strings (presence check) ===\n");
17825    if let Ok(o) = std::process::Command::new("grep")
17826        .args(["-i", "community", "/etc/snmp/snmpd.conf"])
17827        .output()
17828    {
17829        if o.status.success() {
17830            for line in String::from_utf8_lossy(&o.stdout).lines() {
17831                let _ = write!(out, "  {line}\n");
17832            }
17833        } else {
17834            out.push_str("  /etc/snmp/snmpd.conf not found or no community lines.\n");
17835        }
17836    }
17837    Ok(out)
17838}
17839
17840// ── Port Test ─────────────────────────────────────────────────────────────────
17841
17842#[cfg(windows)]
17843fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
17844    let target_host = host.unwrap_or("8.8.8.8");
17845    let target_port = port.unwrap_or(443);
17846
17847    let script = format!(
17848        r#"
17849$result = [System.Text.StringBuilder]::new()
17850$result.AppendLine("=== Port reachability test ===") | Out-Null
17851$result.AppendLine("  Target: {target_host}:{target_port}") | Out-Null
17852$result.AppendLine("") | Out-Null
17853
17854try {{
17855    $test = Test-NetConnection -ComputerName '{target_host}' -Port {target_port} -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
17856    if ($test) {{
17857        $status = if ($test.TcpTestSucceeded) {{ "OPEN (reachable)" }} else {{ "CLOSED or FILTERED" }}
17858        $result.AppendLine("  Result:          $status") | Out-Null
17859        $result.AppendLine("  Remote address:  $($test.RemoteAddress)") | Out-Null
17860        $result.AppendLine("  Remote port:     $($test.RemotePort)") | Out-Null
17861        if ($test.PingSucceeded) {{
17862            $result.AppendLine("  ICMP ping:       Succeeded ($($test.PingReplyDetails.RoundtripTime) ms)") | Out-Null
17863        }} else {{
17864            $result.AppendLine("  ICMP ping:       Failed (host may block ICMP)") | Out-Null
17865        }}
17866        $result.AppendLine("  Interface used:  $($test.InterfaceAlias)") | Out-Null
17867        $result.AppendLine("  Source address:  $($test.SourceAddress.IPAddress)") | Out-Null
17868
17869        $result.AppendLine("") | Out-Null
17870        $result.AppendLine("=== Findings ===") | Out-Null
17871        if ($test.TcpTestSucceeded) {{
17872            $result.AppendLine("- Port {target_port} on {target_host} is OPEN — TCP handshake succeeded.") | Out-Null
17873        }} else {{
17874            $result.AppendLine("- Port {target_port} on {target_host} is CLOSED or FILTERED — TCP handshake failed.") | Out-Null
17875            $result.AppendLine("  Check: firewall rules, route to host, or service not listening on that port.") | Out-Null
17876        }}
17877    }}
17878}} catch {{
17879    $result.AppendLine("  Test-NetConnection failed: $($_.Exception.Message)") | Out-Null
17880}}
17881
17882Write-Output $result.ToString()
17883"#
17884    );
17885    let out = run_powershell(&script)?;
17886    Ok(format!("Host inspection: port_test\n\n{out}"))
17887}
17888
17889#[cfg(not(windows))]
17890fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
17891    let target_host = host.unwrap_or("8.8.8.8");
17892    let target_port = port.unwrap_or(443);
17893    let mut out = format!("Host inspection: port_test\n\n=== Port reachability test ===\n  Target: {target_host}:{target_port}\n\n");
17894    // nc -zv with timeout
17895    let nc = std::process::Command::new("nc")
17896        .args(["-zv", "-w", "3", target_host, &target_port.to_string()])
17897        .output();
17898    match nc {
17899        Ok(o) => {
17900            let stderr = String::from_utf8_lossy(&o.stderr);
17901            let stdout = String::from_utf8_lossy(&o.stdout);
17902            let body = if !stdout.trim().is_empty() {
17903                stdout.as_ref()
17904            } else {
17905                stderr.as_ref()
17906            };
17907            let _ = write!(out, "  {}\n", body.trim());
17908            out.push_str("\n=== Findings ===\n");
17909            if o.status.success() {
17910                let _ = write!(out, "- Port {target_port} on {target_host} is OPEN.\n");
17911            } else {
17912                let _ = write!(
17913                    out,
17914                    "- Port {target_port} on {target_host} is CLOSED or FILTERED.\n"
17915                );
17916            }
17917        }
17918        Err(e) => {
17919            let _ = write!(out, "  nc not available: {e}\n");
17920        }
17921    }
17922    Ok(out)
17923}
17924
17925// ── Network Profile ───────────────────────────────────────────────────────────
17926
17927#[cfg(windows)]
17928fn inspect_network_profile() -> Result<String, String> {
17929    let script = r#"
17930$result = [System.Text.StringBuilder]::new()
17931
17932$result.AppendLine("=== Network location profiles ===") | Out-Null
17933try {
17934    $profiles = Get-NetConnectionProfile -ErrorAction SilentlyContinue
17935    if ($profiles) {
17936        foreach ($p in $profiles) {
17937            $result.AppendLine("  Interface: $($p.InterfaceAlias)") | Out-Null
17938            $result.AppendLine("    Network name:    $($p.Name)") | Out-Null
17939            $result.AppendLine("    Category:        $($p.NetworkCategory)") | Out-Null
17940            $result.AppendLine("    IPv4 conn:       $($p.IPv4Connectivity)") | Out-Null
17941            $result.AppendLine("    IPv6 conn:       $($p.IPv6Connectivity)") | Out-Null
17942            $result.AppendLine("") | Out-Null
17943        }
17944    } else {
17945        $result.AppendLine("  No network connection profiles found.") | Out-Null
17946    }
17947} catch {
17948    $result.AppendLine("  Could not query network profiles.") | Out-Null
17949}
17950
17951# Findings
17952$findings = [System.Collections.Generic.List[string]]::new()
17953try {
17954    $pub = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'Public' }
17955    if ($pub) {
17956        foreach ($p in $pub) {
17957            $findings.Add("Interface '$($p.InterfaceAlias)' is set to Public — firewall restrictions are maximum. Change to Private if this is a trusted network.")
17958        }
17959    }
17960    $domain = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'DomainAuthenticated' }
17961    if ($domain) {
17962        foreach ($d in $domain) {
17963            $findings.Add("Interface '$($d.InterfaceAlias)' is domain-authenticated — domain GPO firewall rules apply.")
17964        }
17965    }
17966} catch {}
17967
17968$result.AppendLine("=== Findings ===") | Out-Null
17969if ($findings.Count -eq 0) {
17970    $result.AppendLine("- Network profiles look normal.") | Out-Null
17971} else {
17972    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17973}
17974
17975Write-Output $result.ToString()
17976"#;
17977    let out = run_powershell(script)?;
17978    Ok(format!("Host inspection: network_profile\n\n{out}"))
17979}
17980
17981#[cfg(not(windows))]
17982fn inspect_network_profile() -> Result<String, String> {
17983    let mut out = String::from(
17984        "Host inspection: network_profile\n\n=== Network manager connection profiles ===\n",
17985    );
17986    if let Ok(o) = std::process::Command::new("nmcli")
17987        .args([
17988            "-t",
17989            "-f",
17990            "NAME,TYPE,STATE,DEVICE",
17991            "connection",
17992            "show",
17993            "--active",
17994        ])
17995        .output()
17996    {
17997        out.push_str(&String::from_utf8_lossy(&o.stdout));
17998    } else {
17999        out.push_str("  nmcli not available.\n");
18000    }
18001    Ok(out)
18002}
18003
18004// ── Storage Spaces ────────────────────────────────────────────────────────────
18005
18006#[cfg(windows)]
18007fn inspect_storage_spaces() -> Result<String, String> {
18008    let script = r#"
18009$result = [System.Text.StringBuilder]::new()
18010
18011# Storage Pools
18012try {
18013    $pools = Get-StoragePool -IsPrimordial $false -ErrorAction SilentlyContinue
18014    if ($pools) {
18015        $result.AppendLine("=== Storage Pools ===") | Out-Null
18016        foreach ($pool in $pools) {
18017            $health = $pool.HealthStatus
18018            $oper   = $pool.OperationalStatus
18019            $sizGB  = [math]::Round($pool.Size / 1GB, 1)
18020            $allocGB= [math]::Round($pool.AllocatedSize / 1GB, 1)
18021            $result.AppendLine("  Pool: $($pool.FriendlyName)  Size: ${sizGB}GB  Allocated: ${allocGB}GB  Health: $health  Status: $oper") | Out-Null
18022        }
18023        $result.AppendLine("") | Out-Null
18024    } else {
18025        $result.AppendLine("=== Storage Pools ===") | Out-Null
18026        $result.AppendLine("  No Storage Spaces pools configured.") | Out-Null
18027        $result.AppendLine("") | Out-Null
18028    }
18029} catch {
18030    $result.AppendLine("=== Storage Pools ===") | Out-Null
18031    $result.AppendLine("  Unable to query storage pools (may require elevation).") | Out-Null
18032    $result.AppendLine("") | Out-Null
18033}
18034
18035# Virtual Disks
18036try {
18037    $vdisks = Get-VirtualDisk -ErrorAction SilentlyContinue
18038    if ($vdisks) {
18039        $result.AppendLine("=== Virtual Disks ===") | Out-Null
18040        foreach ($vd in $vdisks) {
18041            $health  = $vd.HealthStatus
18042            $oper    = $vd.OperationalStatus
18043            $layout  = $vd.ResiliencySettingName
18044            $sizGB   = [math]::Round($vd.Size / 1GB, 1)
18045            $result.AppendLine("  VDisk: $($vd.FriendlyName)  Layout: $layout  Size: ${sizGB}GB  Health: $health  Status: $oper") | Out-Null
18046        }
18047        $result.AppendLine("") | Out-Null
18048    } else {
18049        $result.AppendLine("=== Virtual Disks ===") | Out-Null
18050        $result.AppendLine("  No Storage Spaces virtual disks configured.") | Out-Null
18051        $result.AppendLine("") | Out-Null
18052    }
18053} catch {
18054    $result.AppendLine("=== Virtual Disks ===") | Out-Null
18055    $result.AppendLine("  Unable to query virtual disks.") | Out-Null
18056    $result.AppendLine("") | Out-Null
18057}
18058
18059# Physical Disks in pools
18060try {
18061    $pdisks = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.Usage -ne 'Journal' }
18062    if ($pdisks) {
18063        $result.AppendLine("=== Physical Disks ===") | Out-Null
18064        foreach ($pd in $pdisks) {
18065            $sizGB  = [math]::Round($pd.Size / 1GB, 1)
18066            $health = $pd.HealthStatus
18067            $usage  = $pd.Usage
18068            $media  = $pd.MediaType
18069            $result.AppendLine("  $($pd.FriendlyName)  ${sizGB}GB  $media  Usage: $usage  Health: $health") | Out-Null
18070        }
18071        $result.AppendLine("") | Out-Null
18072    }
18073} catch {}
18074
18075# Findings
18076$findings = @()
18077try {
18078    $unhealthy = Get-StoragePool -IsPrimordial $false -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -ne 'Healthy' }
18079    foreach ($p in $unhealthy) { $findings += "WARN: Pool '$($p.FriendlyName)' health is $($p.HealthStatus)" }
18080    $degraded = Get-VirtualDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -ne 'Healthy' }
18081    foreach ($v in $degraded) { $findings += "WARN: VDisk '$($v.FriendlyName)' health is $($v.HealthStatus) — check for failed drives" }
18082    $failedPd = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -eq 'Unhealthy' -or $_.OperationalStatus -eq 'Lost Communication' }
18083    foreach ($d in $failedPd) { $findings += "CRITICAL: Physical disk '$($d.FriendlyName)' is $($d.HealthStatus) / $($d.OperationalStatus)" }
18084} catch {}
18085
18086if ($findings.Count -gt 0) {
18087    $result.AppendLine("=== Findings ===") | Out-Null
18088    foreach ($f in $findings) { $result.AppendLine("  $f") | Out-Null }
18089} else {
18090    $result.AppendLine("=== Findings ===") | Out-Null
18091    $result.AppendLine("  All storage pool components healthy (or no Storage Spaces configured).") | Out-Null
18092}
18093
18094Write-Output $result.ToString().TrimEnd()
18095"#;
18096    let out = run_powershell(script)?;
18097    Ok(format!("Host inspection: storage_spaces\n\n{out}"))
18098}
18099
18100#[cfg(not(windows))]
18101fn inspect_storage_spaces() -> Result<String, String> {
18102    let mut out = String::from("Host inspection: storage_spaces\n\n");
18103    // Linux: check mdadm software RAID
18104    let mdstat = std::fs::read_to_string("/proc/mdstat").unwrap_or_default();
18105    if !mdstat.is_empty() {
18106        out.push_str("=== Software RAID (/proc/mdstat) ===\n");
18107        out.push_str(&mdstat);
18108    } else {
18109        out.push_str("No mdadm software RAID detected (/proc/mdstat not found or empty).\n");
18110    }
18111    // Check LVM
18112    if let Ok(o) = Command::new("lvs")
18113        .args(["--noheadings", "-o", "lv_name,vg_name,lv_size,lv_attr"])
18114        .output()
18115    {
18116        let lvs = String::from_utf8_lossy(&o.stdout).into_owned();
18117        if !lvs.trim().is_empty() {
18118            out.push_str("\n=== LVM Logical Volumes ===\n");
18119            out.push_str(&lvs);
18120        }
18121    }
18122    Ok(out)
18123}
18124
18125// ── Defender Quarantine / Threat History ─────────────────────────────────────
18126
18127#[cfg(windows)]
18128fn inspect_defender_quarantine(max_entries: usize) -> Result<String, String> {
18129    let limit = max_entries.min(50);
18130    let script = format!(
18131        r#"
18132$result = [System.Text.StringBuilder]::new()
18133
18134# Current threat detections (active + quarantined)
18135try {{
18136    $threats = Get-MpThreatDetection -ErrorAction SilentlyContinue | Sort-Object InitialDetectionTime -Descending | Select-Object -First {limit}
18137    if ($threats) {{
18138        $result.AppendLine("=== Recent Threat Detections (last {limit}) ===") | Out-Null
18139        foreach ($t in $threats) {{
18140            $name    = (Get-MpThreat -ThreatID $t.ThreatID -ErrorAction SilentlyContinue).ThreatName
18141            if (-not $name) {{ $name = "ID:$($t.ThreatID)" }}
18142            $time    = $t.InitialDetectionTime.ToString("yyyy-MM-dd HH:mm")
18143            $action  = $t.ActionSuccess
18144            $status  = $t.CurrentThreatExecutionStatusID
18145            $result.AppendLine("  [$time] $name  ActionSuccess:$action  Status:$status") | Out-Null
18146        }}
18147        $result.AppendLine("") | Out-Null
18148    }} else {{
18149        $result.AppendLine("=== Recent Threat Detections ===") | Out-Null
18150        $result.AppendLine("  No threat detections on record — Defender history is clean.") | Out-Null
18151        $result.AppendLine("") | Out-Null
18152    }}
18153}} catch {{
18154    $result.AppendLine("=== Recent Threat Detections ===") | Out-Null
18155    $result.AppendLine("  Unable to query threat detections: $_") | Out-Null
18156    $result.AppendLine("") | Out-Null
18157}}
18158
18159# Quarantine items
18160try {{
18161    $quarantine = Get-MpThreat -ErrorAction SilentlyContinue | Where-Object {{ $_.IsActive -eq $false }} | Select-Object -First {limit}
18162    if ($quarantine) {{
18163        $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18164        foreach ($q in $quarantine) {{
18165            $result.AppendLine("  $($q.ThreatName)  Severity:$($q.SeverityID)  Category:$($q.CategoryID)  Active:$($q.IsActive)") | Out-Null
18166        }}
18167        $result.AppendLine("") | Out-Null
18168    }} else {{
18169        $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18170        $result.AppendLine("  No quarantined threats found.") | Out-Null
18171        $result.AppendLine("") | Out-Null
18172    }}
18173}} catch {{
18174    $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18175    $result.AppendLine("  Unable to query quarantine list: $_") | Out-Null
18176    $result.AppendLine("") | Out-Null
18177}}
18178
18179# Defender scan stats
18180try {{
18181    $status = Get-MpComputerStatus -ErrorAction SilentlyContinue
18182    if ($status) {{
18183        $lastScan   = $status.QuickScanStartTime
18184        $lastFull   = $status.FullScanStartTime
18185        $sigDate    = $status.AntivirusSignatureLastUpdated
18186        $result.AppendLine("=== Defender Scan Summary ===") | Out-Null
18187        $result.AppendLine("  Last quick scan : $lastScan") | Out-Null
18188        $result.AppendLine("  Last full scan  : $lastFull") | Out-Null
18189        $result.AppendLine("  Signature date  : $sigDate") | Out-Null
18190    }}
18191}} catch {{}}
18192
18193Write-Output $result.ToString().TrimEnd()
18194"#,
18195        limit = limit
18196    );
18197    let out = run_powershell(&script)?;
18198    Ok(format!("Host inspection: defender_quarantine\n\n{out}"))
18199}
18200
18201// ── inspect_domain_health ─────────────────────────────────────────────────────
18202
18203#[cfg(windows)]
18204fn inspect_domain_health() -> Result<String, String> {
18205    let script = r#"
18206$result = [System.Text.StringBuilder]::new()
18207
18208# Domain membership
18209try {
18210    $cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
18211    $joined = $cs.PartOfDomain
18212    $domain = $cs.Domain
18213    $result.AppendLine("=== Domain Membership ===") | Out-Null
18214    $result.AppendLine("  Join Status : $(if ($joined) { 'DOMAIN JOINED' } else { 'WORKGROUP' })") | Out-Null
18215    if ($joined) { $result.AppendLine("  Domain      : $domain") | Out-Null }
18216    $result.AppendLine("  Computer    : $($cs.Name)") | Out-Null
18217} catch {
18218    $result.AppendLine("  Domain membership check failed: $_") | Out-Null
18219}
18220
18221# dsregcmd device registration state
18222try {
18223    $dsreg = dsregcmd /status 2>&1 | Where-Object { $_ -match '(AzureAdJoined|DomainJoined|WorkplaceJoined|TenantName|DeviceId|MdmUrl)' }
18224    if ($dsreg) {
18225        $result.AppendLine("") | Out-Null
18226        $result.AppendLine("=== Device Registration (dsregcmd) ===") | Out-Null
18227        foreach ($line in $dsreg) { $result.AppendLine("  $($line.Trim())") | Out-Null }
18228    }
18229} catch {}
18230
18231# DC discovery via nltest
18232$result.AppendLine("") | Out-Null
18233$result.AppendLine("=== Domain Controller Discovery ===") | Out-Null
18234try {
18235    $nl = nltest /dsgetdc:. 2>&1
18236    $dc_name = $null
18237    foreach ($line in $nl) {
18238        if ($line -match '(DC:|Address:|Dom Guid|DomainName)') {
18239            $result.AppendLine("  $($line.Trim())") | Out-Null
18240        }
18241        if ($line -match 'DC: \\\\(.+)') { $dc_name = $Matches[1].Trim() }
18242    }
18243    if ($dc_name) {
18244        $result.AppendLine("") | Out-Null
18245        $result.AppendLine("=== DC Port Connectivity (DC: $dc_name) ===") | Out-Null
18246        foreach ($entry in @(@{p=389;n='LDAP'},@{p=636;n='LDAPS'},@{p=88;n='Kerberos'},@{p=3268;n='GC-LDAP'})) {
18247            try {
18248                $tcp = New-Object System.Net.Sockets.TcpClient
18249                $conn = $tcp.BeginConnect($dc_name, $entry.p, $null, $null)
18250                $ok = $conn.AsyncWaitHandle.WaitOne(1200, $false)
18251                $tcp.Close()
18252                $status = if ($ok) { 'OPEN' } else { 'TIMEOUT' }
18253            } catch { $status = 'FAILED' }
18254            $result.AppendLine("  Port $($entry.p) ($($entry.n)): $status") | Out-Null
18255        }
18256    }
18257} catch {
18258    $result.AppendLine("  nltest unavailable (not domain-joined or missing RSAT): $_") | Out-Null
18259}
18260
18261# Last GPO machine refresh time
18262try {
18263    $gpoKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Group Policy\State\Machine\Extension-List\{00000000-0000-0000-0000-000000000000}'
18264    if (Test-Path $gpoKey) {
18265        $gpo = Get-ItemProperty $gpoKey -ErrorAction SilentlyContinue
18266        $result.AppendLine("") | Out-Null
18267        $result.AppendLine("=== Group Policy Last Refresh ===") | Out-Null
18268        $result.AppendLine("  Machine GPO last applied: $($gpo.EndTime)") | Out-Null
18269    }
18270} catch {}
18271
18272Write-Output $result.ToString().TrimEnd()
18273"#;
18274    let out = run_powershell(script)?;
18275    Ok(format!("Host inspection: domain_health\n\n{out}"))
18276}
18277
18278#[cfg(not(windows))]
18279fn inspect_domain_health() -> Result<String, String> {
18280    let mut out = String::from("Host inspection: domain_health\n\n");
18281    for cmd_args in &[vec!["realm", "list"], vec!["sssd", "--version"]] {
18282        if let Ok(o) = Command::new(cmd_args[0]).args(&cmd_args[1..]).output() {
18283            let s = String::from_utf8_lossy(&o.stdout);
18284            if !s.trim().is_empty() {
18285                let _ = write!(out, "$ {}\n{}\n", cmd_args.join(" "), s.trim_end());
18286            }
18287        }
18288    }
18289    if out.trim_end().ends_with("domain_health") {
18290        out.push_str("Not domain-joined or realm/sssd not installed.\n");
18291    }
18292    Ok(out)
18293}
18294
18295// ── inspect_service_dependencies ─────────────────────────────────────────────
18296
18297#[cfg(windows)]
18298fn inspect_service_dependencies(max_entries: usize) -> Result<String, String> {
18299    let limit = max_entries.min(60);
18300    let script = format!(
18301        r#"
18302$result = [System.Text.StringBuilder]::new()
18303$result.AppendLine("=== Service Dependency Graph ===") | Out-Null
18304$result.AppendLine("Format: [Status] ServiceName — requires: ... | needed by: ...") | Out-Null
18305$result.AppendLine("") | Out-Null
18306$svc = Get-Service | Where-Object {{ $_.DependentServices.Count -gt 0 -or $_.RequiredServices.Count -gt 0 }} | Sort-Object Name | Select-Object -First {limit}
18307foreach ($s in $svc) {{
18308    $req  = if ($s.RequiredServices.Count  -gt 0) {{ "requires: $($s.RequiredServices.Name  -join ', ')" }} else {{ "" }}
18309    $dep  = if ($s.DependentServices.Count -gt 0) {{ "needed by: $($s.DependentServices.Name -join ', ')" }} else {{ "" }}
18310    $parts = @($req, $dep) | Where-Object {{ $_ }}
18311    if ($parts) {{
18312        $result.AppendLine("  [$($s.Status)] $($s.Name) — $($parts -join ' | ')") | Out-Null
18313    }}
18314}}
18315Write-Output $result.ToString().TrimEnd()
18316"#,
18317        limit = limit
18318    );
18319    let out = run_powershell(&script)?;
18320    Ok(format!("Host inspection: service_dependencies\n\n{out}"))
18321}
18322
18323#[cfg(not(windows))]
18324fn inspect_service_dependencies(_max_entries: usize) -> Result<String, String> {
18325    let out = Command::new("systemctl")
18326        .args(["list-dependencies", "--no-pager", "--plain"])
18327        .output()
18328        .ok()
18329        .and_then(|o| String::from_utf8(o.stdout).ok())
18330        .unwrap_or_else(|| "systemctl not available.\n".to_string());
18331    Ok(format!(
18332        "Host inspection: service_dependencies\n\n{}",
18333        out.trim_end()
18334    ))
18335}
18336
18337// ── inspect_wmi_health ────────────────────────────────────────────────────────
18338
18339#[cfg(windows)]
18340fn inspect_wmi_health() -> Result<String, String> {
18341    let script = r#"
18342$result = [System.Text.StringBuilder]::new()
18343$result.AppendLine("=== WMI Repository Health ===") | Out-Null
18344
18345# Basic WMI query test
18346try {
18347    $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
18348    $result.AppendLine("  Query (Win32_OperatingSystem): OK") | Out-Null
18349    $result.AppendLine("  OS: $($os.Caption) Build $($os.BuildNumber)") | Out-Null
18350} catch {
18351    $result.AppendLine("  Query FAILED: $_") | Out-Null
18352    $result.AppendLine("  FINDING: WMI may be corrupt. Run winmgmt /verifyrepository") | Out-Null
18353}
18354
18355# Repository integrity
18356try {
18357    $verify = & winmgmt /verifyrepository 2>&1
18358    $result.AppendLine("  winmgmt /verifyrepository: $verify") | Out-Null
18359} catch {
18360    $result.AppendLine("  winmgmt check unavailable: $_") | Out-Null
18361}
18362
18363# WMI service state
18364$svc = Get-Service winmgmt -ErrorAction SilentlyContinue
18365if ($svc) {
18366    $result.AppendLine("  Service (winmgmt): $($svc.Status) / $($svc.StartType)") | Out-Null
18367}
18368
18369# Repository folder size
18370$repPath = "$env:SystemRoot\System32\wbem\Repository"
18371if (Test-Path $repPath) {
18372    $bytes = (Get-ChildItem $repPath -Recurse -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
18373    $mb = [math]::Round($bytes / 1MB, 1)
18374    $result.AppendLine("  Repository size: $mb MB  ($repPath)") | Out-Null
18375    if ($mb -gt 200) {
18376        $result.AppendLine("  FINDING: Repository is unusually large (>200 MB). May indicate corruption or bloat.") | Out-Null
18377    }
18378}
18379
18380$result.AppendLine("") | Out-Null
18381$result.AppendLine("=== Recovery Steps (if corrupt) ===") | Out-Null
18382$result.AppendLine("  1. net stop winmgmt") | Out-Null
18383$result.AppendLine("  2. winmgmt /salvagerepository   (try first)") | Out-Null
18384$result.AppendLine("  3. winmgmt /resetrepository     (last resort — loses custom namespaces)") | Out-Null
18385$result.AppendLine("  4. net start winmgmt") | Out-Null
18386
18387Write-Output $result.ToString().TrimEnd()
18388"#;
18389    let out = run_powershell(script)?;
18390    Ok(format!("Host inspection: wmi_health\n\n{out}"))
18391}
18392
18393#[cfg(not(windows))]
18394fn inspect_wmi_health() -> Result<String, String> {
18395    Ok("Host inspection: wmi_health\n\nWMI is Windows-only. On Linux use 'systemctl status' for service health.\n".to_string())
18396}
18397
18398// ── inspect_local_security_policy ────────────────────────────────────────────
18399
18400#[cfg(windows)]
18401fn inspect_local_security_policy() -> Result<String, String> {
18402    let script = r#"
18403$result = [System.Text.StringBuilder]::new()
18404$result.AppendLine("=== Local Account & Password Policy ===") | Out-Null
18405$na = net accounts 2>&1
18406foreach ($line in $na) {
18407    if ($line -match '(password|lockout|observation|force|maximum|minimum|length|duration)') {
18408        $result.AppendLine("  $($line.Trim())") | Out-Null
18409    }
18410}
18411
18412$result.AppendLine("") | Out-Null
18413$result.AppendLine("=== LAN Manager / NTLM Authentication Level ===") | Out-Null
18414try {
18415    $lmLevel = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name LmCompatibilityLevel -ErrorAction SilentlyContinue).LmCompatibilityLevel
18416    if ($null -eq $lmLevel) { $lmLevel = 3 }
18417    $map = @{0='Send LM+NTLM'; 1='LM+NTLMv2 if negotiated'; 2='Send NTLM only'; 3='Send NTLMv2 only (default)'; 4='DC refuses LM'; 5='DC refuses LM+NTLM'}
18418    $result.AppendLine("  LmCompatibilityLevel: $lmLevel — $($map[$lmLevel])") | Out-Null
18419    if ($lmLevel -lt 3) {
18420        $result.AppendLine("  FINDING: LmCompatibilityLevel < 3 allows weak LM/NTLM. Recommend level 3 or higher.") | Out-Null
18421    }
18422} catch {}
18423
18424$result.AppendLine("") | Out-Null
18425$result.AppendLine("=== UAC Settings ===") | Out-Null
18426try {
18427    $uac = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -ErrorAction SilentlyContinue
18428    if ($uac) {
18429        $result.AppendLine("  UAC Enabled             : $($uac.EnableLUA)   (1=on, 0=disabled)") | Out-Null
18430        $behavMap = @{0='No prompt (risk)'; 1='Prompt for creds'; 2='Prompt for consent'; 5='Secure consent (default)'}
18431        $bval = $uac.ConsentPromptBehaviorAdmin
18432        $result.AppendLine("  Admin Prompt Behavior   : $bval — $($behavMap[$bval])") | Out-Null
18433        if ($uac.EnableLUA -eq 0) {
18434            $result.AppendLine("  FINDING: UAC is disabled. Processes run with full admin rights without prompting.") | Out-Null
18435        }
18436    }
18437} catch {}
18438
18439Write-Output $result.ToString().TrimEnd()
18440"#;
18441    let out = run_powershell(script)?;
18442    Ok(format!("Host inspection: local_security_policy\n\n{out}"))
18443}
18444
18445#[cfg(not(windows))]
18446fn inspect_local_security_policy() -> Result<String, String> {
18447    let mut out = String::from("Host inspection: local_security_policy\n\n");
18448    if let Ok(content) = std::fs::read_to_string("/etc/login.defs") {
18449        out.push_str("=== /etc/login.defs ===\n");
18450        for line in content.lines() {
18451            let t = line.trim();
18452            if !t.is_empty() && !t.starts_with('#') {
18453                let _ = write!(out, "  {t}\n");
18454            }
18455        }
18456    }
18457    Ok(out)
18458}
18459
18460// ── inspect_usb_history ───────────────────────────────────────────────────────
18461
18462#[cfg(windows)]
18463fn inspect_usb_history(max_entries: usize) -> Result<String, String> {
18464    let limit = max_entries.min(50);
18465    let script = format!(
18466        r#"
18467$result = [System.Text.StringBuilder]::new()
18468$result.AppendLine("=== USB Device History (USBSTOR Registry) ===") | Out-Null
18469$usbPath = 'HKLM:\SYSTEM\CurrentControlSet\Enum\USBSTOR'
18470if (Test-Path $usbPath) {{
18471    $count = 0
18472    $seen = @{{}}
18473    $classes = Get-ChildItem $usbPath -ErrorAction SilentlyContinue
18474    foreach ($class in $classes) {{
18475        $instances = Get-ChildItem $class.PSPath -ErrorAction SilentlyContinue
18476        foreach ($inst in $instances) {{
18477            if ($count -ge {limit}) {{ break }}
18478            try {{
18479                $props = Get-ItemProperty $inst.PSPath -ErrorAction SilentlyContinue
18480                $fn = if ($props.FriendlyName) {{ $props.FriendlyName }} else {{ $class.PSChildName }}
18481                if (-not $seen[$fn]) {{
18482                    $seen[$fn] = $true
18483                    $result.AppendLine("  $fn") | Out-Null
18484                    $count++
18485                }}
18486            }} catch {{}}
18487        }}
18488    }}
18489    if ($count -eq 0) {{
18490        $result.AppendLine("  No USB storage devices found in registry.") | Out-Null
18491    }} else {{
18492        $result.AppendLine("") | Out-Null
18493        $result.AppendLine("  ($count unique devices; requires elevation for full history)") | Out-Null
18494    }}
18495}} else {{
18496    $result.AppendLine("  USBSTOR key not found. Requires elevation or USB storage policy may block it.") | Out-Null
18497}}
18498Write-Output $result.ToString().TrimEnd()
18499"#,
18500        limit = limit
18501    );
18502    let out = run_powershell(&script)?;
18503    Ok(format!("Host inspection: usb_history\n\n{out}"))
18504}
18505
18506#[cfg(not(windows))]
18507fn inspect_usb_history(_max_entries: usize) -> Result<String, String> {
18508    let mut out = String::from("Host inspection: usb_history\n\n");
18509    if let Ok(o) = Command::new("journalctl")
18510        .args(["-k", "--no-pager", "-q", "--since", "30 days ago"])
18511        .output()
18512    {
18513        let s = String::from_utf8_lossy(&o.stdout);
18514        let usb_lines: Vec<&str> = s
18515            .lines()
18516            .filter(|l| l.to_ascii_lowercase().contains("usb"))
18517            .take(30)
18518            .collect();
18519        if !usb_lines.is_empty() {
18520            out.push_str("=== Recent USB kernel events (journalctl) ===\n");
18521            for line in usb_lines {
18522                let _ = write!(out, "  {line}\n");
18523            }
18524        }
18525    } else {
18526        out.push_str("USB history via journalctl not available.\n");
18527    }
18528    Ok(out)
18529}
18530
18531// ── inspect_print_spooler ─────────────────────────────────────────────────────
18532
18533#[cfg(windows)]
18534fn inspect_print_spooler() -> Result<String, String> {
18535    let script = r#"
18536$result = [System.Text.StringBuilder]::new()
18537
18538$svc = Get-Service Spooler -ErrorAction SilentlyContinue
18539$result.AppendLine("=== Print Spooler Service ===") | Out-Null
18540if ($svc) {
18541    $result.AppendLine("  Status     : $($svc.Status)") | Out-Null
18542    $result.AppendLine("  Start Type : $($svc.StartType)") | Out-Null
18543} else {
18544    $result.AppendLine("  Spooler service not found.") | Out-Null
18545}
18546
18547# PrintNightmare mitigations (CVE-2021-34527)
18548$result.AppendLine("") | Out-Null
18549$result.AppendLine("=== PrintNightmare Hardening (CVE-2021-34527) ===") | Out-Null
18550try {
18551    $val = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Print' -Name RpcAuthnLevelPrivacyEnabled -ErrorAction SilentlyContinue).RpcAuthnLevelPrivacyEnabled
18552    if ($val -eq 1) {
18553        $result.AppendLine("  RpcAuthnLevelPrivacyEnabled: 1 — HARDENED (good)") | Out-Null
18554    } else {
18555        $result.AppendLine("  RpcAuthnLevelPrivacyEnabled: $val — NOT hardened") | Out-Null
18556        $result.AppendLine("  FINDING: PrintNightmare RPC mitigation not applied. Set to 1 via registry.") | Out-Null
18557    }
18558} catch { $result.AppendLine("  Mitigation key not readable: $_") | Out-Null }
18559
18560try {
18561    $pnpPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Printers\PointAndPrint'
18562    if (Test-Path $pnpPath) {
18563        $pnp = Get-ItemProperty $pnpPath -ErrorAction SilentlyContinue
18564        $result.AppendLine("  RestrictDriverInstallationToAdministrators: $($pnp.RestrictDriverInstallationToAdministrators)") | Out-Null
18565        $result.AppendLine("  NoWarningNoElevationOnInstall              : $($pnp.NoWarningNoElevationOnInstall)") | Out-Null
18566        if ($pnp.NoWarningNoElevationOnInstall -eq 1) {
18567            $result.AppendLine("  FINDING: Point and Print allows silent driver install without elevation (risk).") | Out-Null
18568        }
18569    } else {
18570        $result.AppendLine("  No Point and Print policy (using Windows defaults).") | Out-Null
18571    }
18572} catch {}
18573
18574# Pending print jobs
18575$result.AppendLine("") | Out-Null
18576$result.AppendLine("=== Print Queue ===") | Out-Null
18577try {
18578    $jobs = Get-PrintJob -ErrorAction SilentlyContinue
18579    if ($jobs) {
18580        foreach ($j in $jobs | Select-Object -First 5) {
18581            $result.AppendLine("  $($j.DocumentName) — $($j.JobStatus)") | Out-Null
18582        }
18583    } else {
18584        $result.AppendLine("  No pending print jobs.") | Out-Null
18585    }
18586} catch {
18587    $result.AppendLine("  Print queue check requires elevation.") | Out-Null
18588}
18589
18590Write-Output $result.ToString().TrimEnd()
18591"#;
18592    let out = run_powershell(script)?;
18593    Ok(format!("Host inspection: print_spooler\n\n{out}"))
18594}
18595
18596#[cfg(not(windows))]
18597fn inspect_print_spooler() -> Result<String, String> {
18598    let mut out = String::from("Host inspection: print_spooler\n\n");
18599    if let Ok(o) = Command::new("lpstat").args(["-s"]).output() {
18600        let s = String::from_utf8_lossy(&o.stdout);
18601        if !s.trim().is_empty() {
18602            out.push_str("=== CUPS Status (lpstat -s) ===\n");
18603            out.push_str(s.trim_end());
18604            out.push('\n');
18605        }
18606    } else {
18607        out.push_str("CUPS not detected (lpstat not found).\n");
18608    }
18609    Ok(out)
18610}
18611
18612#[cfg(not(windows))]
18613fn inspect_defender_quarantine(_max_entries: usize) -> Result<String, String> {
18614    let mut out = String::from("Host inspection: defender_quarantine\n\n");
18615    out.push_str("Windows Defender is Windows-only.\n");
18616    // Check ClamAV on Linux/macOS
18617    if let Ok(o) = Command::new("clamscan").arg("--version").output() {
18618        if o.status.success() {
18619            out.push_str("\nClamAV detected. Run `clamscan -r --infected /path` for a scan.\n");
18620            if let Ok(log) = std::fs::read_to_string("/var/log/clamav/clamav.log") {
18621                out.push_str("\n=== ClamAV Recent Log ===\n");
18622                for line in log.lines().rev().take(20) {
18623                    let _ = write!(out, "  {line}\n");
18624                }
18625            }
18626        }
18627    } else {
18628        out.push_str("No AV tool detected (ClamAV not found).\n");
18629    }
18630    Ok(out)
18631}