Skip to main content

hematite/tools/
host_inspect.rs

1use serde_json::Value;
2use std::collections::HashSet;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7const DEFAULT_MAX_ENTRIES: usize = 10;
8const MAX_ENTRIES_CAP: usize = 25;
9const DIRECTORY_SCAN_NODE_BUDGET: usize = 25_000;
10
11pub async fn inspect_host(args: &Value) -> Result<String, String> {
12    let mut topic = args
13        .get("topic")
14        .and_then(|v| v.as_str())
15        .unwrap_or("summary")
16        .to_string();
17    let max_entries = parse_max_entries(args);
18    let filter = parse_name_filter(args).unwrap_or_default().to_lowercase();
19
20    // Topic Interceptor: Force ad_user for AD-related queries to resolve model variance
21    if (topic == "processes" || topic == "network" || topic == "summary")
22        && (filter.contains("ad")
23            || filter.contains("sid")
24            || filter.contains("administrator")
25            || filter.contains("domain"))
26    {
27        topic = "ad_user".to_string();
28    }
29
30    let result = match topic.as_str() {
31        "summary" => inspect_summary(max_entries),
32        "toolchains" => inspect_toolchains(),
33        "path" => inspect_path(max_entries),
34        "env_doctor" => inspect_env_doctor(max_entries),
35        "fix_plan" => inspect_fix_plan(parse_issue_text(args), parse_port_filter(args), max_entries).await,
36        "network" => inspect_network(max_entries),
37        "lan_discovery" | "network_neighborhood" | "upnp" | "neighborhood" => {
38            inspect_lan_discovery(max_entries)
39        }
40        "audio" | "sound" | "microphone" | "speakers" | "speaker" | "mic" => {
41            inspect_audio(max_entries)
42        }
43        "bluetooth" | "bt" | "paired_devices" | "wireless_audio" => {
44            inspect_bluetooth(max_entries)
45        }
46        "camera" | "webcam" | "camera_privacy" => inspect_camera(max_entries),
47        "sign_in" | "windows_hello" | "hello" | "pin" | "login_issues" | "signin" => {
48            inspect_sign_in(max_entries)
49        }
50        "installer_health" | "installer" | "msi" | "msiexec" | "app_installer" => {
51            inspect_installer_health(max_entries)
52        }
53        "onedrive" | "sync_client" | "cloud_sync" | "known_folder_backup" => {
54            inspect_onedrive(max_entries)
55        }
56        "browser_health" | "browser" | "webview2" | "default_browser" => {
57            inspect_browser_health(max_entries)
58        }
59        "identity_auth"
60        | "office_auth"
61        | "m365_auth"
62        | "microsoft_365_auth"
63        | "auth_broker" => inspect_identity_auth(max_entries),
64        "outlook" | "outlook_health" | "ms_outlook" => inspect_outlook(max_entries),
65        "teams" | "ms_teams" | "teams_health" => inspect_teams(max_entries),
66        "windows_backup" | "backup" | "file_history" | "wbadmin" | "system_restore" => {
67            inspect_windows_backup(max_entries)
68        }
69        "search_index" | "windows_search" | "indexing" | "search" => {
70            inspect_search_index(max_entries)
71        }
72        "services" => inspect_services(parse_name_filter(args), max_entries),
73        "processes" => inspect_processes(parse_name_filter(args), max_entries),
74        "desktop" => inspect_known_directory("Desktop", desktop_dir(), max_entries).await,
75        "downloads" => inspect_known_directory("Downloads", downloads_dir(), max_entries).await,
76        "disk" => {
77            let path = resolve_optional_path(args)?;
78            inspect_disk(path, max_entries).await
79        }
80        "ports" => inspect_ports(parse_port_filter(args), max_entries),
81        "log_check" => inspect_log_check(parse_lookback_hours(args), max_entries),
82        "startup_items" | "startup" | "boot" | "autorun" => inspect_startup_items(max_entries),
83        "health_report" | "system_health" => inspect_health_report(),
84        "storage" => inspect_storage(max_entries),
85        "hardware" => inspect_hardware(),
86        "updates" | "windows_update" => inspect_updates(),
87        "security" | "antivirus" | "defender" => inspect_security(),
88        "pending_reboot" | "reboot_required" => inspect_pending_reboot(),
89        "disk_health" | "smart" | "drive_health" => inspect_disk_health(),
90        "battery" => inspect_battery(),
91        "recent_crashes" | "crashes" | "bsod" => inspect_recent_crashes(max_entries),
92        "scheduled_tasks" | "tasks" => inspect_scheduled_tasks(max_entries),
93        "dev_conflicts" | "dev_environment" => inspect_dev_conflicts(),
94        "connectivity" | "internet" | "internet_check" => inspect_connectivity(),
95        "wifi" | "wi-fi" | "wireless" | "wlan" => inspect_wifi(),
96        "connections" | "tcp_connections" | "active_connections" => inspect_connections(max_entries),
97        "vpn" => inspect_vpn(),
98        "proxy" | "proxy_settings" => inspect_proxy(),
99        "firewall_rules" | "firewall-rules" => inspect_firewall_rules(max_entries),
100        "traceroute" | "tracert" | "trace_route" | "trace" => {
101            let host = args
102                .get("host")
103                .and_then(|v| v.as_str())
104                .unwrap_or("8.8.8.8")
105                .to_string();
106            inspect_traceroute(&host, max_entries)
107        }
108        "dns_cache" | "dnscache" | "dns-cache" => inspect_dns_cache(max_entries),
109        "arp" | "arp_table" => inspect_arp(),
110        "route_table" | "routes" | "routing_table" => inspect_route_table(max_entries),
111        "os_config" | "system_config" => inspect_os_config(),
112        "resource_load" | "performance" | "system_load" | "performance_diagnosis" => inspect_resource_load(),
113        "env" | "environment" | "environment_variables" | "env_vars" => inspect_env(max_entries),
114        "hosts_file" | "hosts" | "etc_hosts" => inspect_hosts_file(),
115        "docker" | "containers" | "docker_status" => inspect_docker(max_entries),
116        "docker_filesystems" | "docker_mounts" | "docker_storage" | "container_mounts" => {
117            inspect_docker_filesystems(max_entries)
118        }
119        "wsl" | "wsl_distros" | "subsystem" => inspect_wsl(),
120        "wsl_filesystems" | "wsl_storage" | "wsl_mounts" => inspect_wsl_filesystems(max_entries),
121        "ssh" | "ssh_config" | "ssh_status" => inspect_ssh(),
122        "installed_software" | "installed" | "programs" | "software" | "packages" => inspect_installed_software(max_entries),
123        "git_config" | "git_global" => inspect_git_config(),
124        "databases" | "database" | "db_services" | "db" => inspect_databases(),
125        "user_accounts" | "users" | "local_users" | "accounts" => inspect_user_accounts(max_entries),
126        "audit_policy" | "audit" | "auditpol" => inspect_audit_policy(),
127        "shares" | "smb_shares" | "network_shares" | "mapped_drives" => inspect_shares(max_entries),
128        "dns_servers" | "dns_config" | "dns_resolver" | "nameservers" => inspect_dns_servers(),
129        "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => inspect_bitlocker(),
130        "rdp" | "remote_desktop" | "rdp_status" => inspect_rdp(),
131        "shadow_copies" | "vss" | "volume_shadow" | "backups" | "snapshots" => inspect_shadow_copies(),
132        "pagefile" | "page_file" | "virtual_memory" | "swap" => inspect_pagefile(),
133        "windows_features" | "optional_features" | "installed_features" | "features" => inspect_windows_features(max_entries),
134        "printers" | "printer" | "print_queue" | "printing" => inspect_printers(max_entries),
135        "winrm" | "remote_management" | "psremoting" => inspect_winrm(),
136        "network_stats" | "adapter_stats" | "nic_stats" | "interface_stats" => inspect_network_stats(max_entries),
137        "udp_ports" | "udp_listeners" | "udp" => inspect_udp_ports(max_entries),
138        "gpo" | "group_policy" | "applied_policies" => inspect_gpo(),
139        "certificates" | "certs" | "ssl_certs" => inspect_certificates(max_entries),
140        "integrity" | "sfc" | "dism" | "system_health_deep" => inspect_integrity(),
141        "domain" | "active_directory" | "ad_context" | "workgroup" => inspect_domain(),
142        "device_health" | "hardware_errors" | "yellow_bangs" => inspect_device_health(),
143        "drivers" | "system_drivers" | "driver_list" => inspect_drivers(max_entries),
144        "peripherals" | "usb" | "input_devices" | "connected_hardware" => inspect_peripherals(max_entries),
145        "sessions" | "logins" | "active_sessions" => inspect_sessions(max_entries),
146        "repo_doctor" => {
147            let path = resolve_optional_path(args)?;
148            inspect_repo_doctor(path, max_entries)
149        }
150        "directory" => {
151            let raw_path = args
152                .get("path")
153                .and_then(|v| v.as_str())
154                .ok_or_else(|| {
155                    "Missing required argument: 'path' for inspect_host(topic: \"directory\")"
156                        .to_string()
157                })?;
158            let resolved = resolve_path(raw_path)?;
159            inspect_directory("Directory", resolved, max_entries).await
160        }
161        "disk_benchmark" | "stress_test" | "io_intensity" => {
162            let path = resolve_optional_path(args)?;
163            inspect_disk_benchmark(path).await
164        }
165        "permissions" | "acl" | "access_control" => {
166            let path = resolve_optional_path(args)?;
167            inspect_permissions(path, max_entries)
168        }
169        "login_history" | "logon_history" | "user_logins" => {
170            inspect_login_history(max_entries)
171        }
172        "share_access" | "unc_access" | "remote_share" => {
173            let path = resolve_path(args.get("path").and_then(|v| v.as_str()).unwrap_or(""))?;
174            inspect_share_access(path)
175        }
176        "registry_audit" | "persistence" | "integrity_audit" => inspect_registry_audit(),
177        "thermal" | "throttling" | "overheating" => inspect_thermal(),
178        "activation" | "license_status" | "slmgr" => inspect_activation(),
179        "patch_history" | "hotfixes" | "recent_patches" => inspect_patch_history(max_entries),
180        "ad_user" | "ad" | "domain_user" => {
181            let identity = parse_name_filter(args).unwrap_or_default();
182            inspect_ad_user(&identity)
183        }
184        "dns_lookup" | "dig" | "nslookup" => {
185            let name = parse_name_filter(args).unwrap_or_default();
186            let record_type = args.get("type").and_then(|v| v.as_str()).unwrap_or("A");
187            inspect_dns_lookup(&name, record_type)
188        }
189        "hyperv" | "hyper-v" | "vms" => inspect_hyperv(),
190        "ip_config" | "ip_detail" => inspect_ip_config(),
191        "dhcp" | "dhcp_lease" | "lease" | "dhcp_detail" => inspect_dhcp(),
192        "mtu" | "path_mtu" | "pmtu" | "frame_size" | "mtu_discovery" => inspect_mtu(),
193        "ipv6" | "ipv6_status" | "ipv6_address" | "ipv6_prefix" | "ipv6_config" | "slaac" | "dhcpv6" => inspect_ipv6(),
194        "tcp_params" | "tcp_settings" | "tcp_autotuning" | "tcp_config" | "tcp_tuning" | "tcp_window" => inspect_tcp_params(),
195        "wlan_profiles" | "wifi_profiles" | "wireless_profiles" | "saved_wifi" | "saved_networks" => inspect_wlan_profiles(),
196        "ipsec" | "ipsec_sa" | "ipsec_policy" | "ipsec_rules" | "ipsec_tunnel" | "ike" => inspect_ipsec(),
197        "netbios" | "netbios_status" | "wins" | "nbtstat" | "netbios_config" => inspect_netbios(),
198        "nic_teaming" | "nic_team" | "teaming" | "lacp" | "bonding" | "link_aggregation" => inspect_nic_teaming(),
199        "snmp" | "snmp_agent" | "snmp_service" | "snmp_config" => inspect_snmp(),
200        "port_test" | "port_check" | "test_port" | "check_port" | "tcp_test" | "reachable" => {
201            let pt_host = args.get("host").and_then(|v| v.as_str()).map(|s| s.to_string());
202            let pt_port = args.get("port").and_then(|v| v.as_u64()).map(|p| p as u16);
203            inspect_port_test(pt_host.as_deref(), pt_port)
204        }
205        "network_profile" | "network_location" | "net_profile" | "network_category" => inspect_network_profile(),
206        "overclocker" | "thermal_deep" | "clocks" | "voltage" => inspect_overclocker().await,
207        "display_config" | "display" | "monitor" | "monitors" | "resolution" | "refresh_rate" | "screen" => {
208            inspect_display_config(max_entries)
209        }
210        "ntp" | "time_sync" | "time_synchronization" | "clock_sync" | "w32tm" | "clock" => {
211            inspect_ntp()
212        }
213        "cpu_power" | "turbo_boost" | "cpu_frequency" | "cpu_freq" | "processor_power" | "boost" => {
214            inspect_cpu_power()
215        }
216        "credentials" | "credential_manager" | "saved_passwords" | "stored_credentials" => {
217            inspect_credentials(max_entries)
218        }
219        "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
220            inspect_tpm()
221        }
222        "latency" | "ping" | "ping_test" | "rtt" | "packet_loss" | "reachability" => {
223            inspect_latency()
224        }
225        "network_adapter" | "nic" | "nic_settings" | "adapter_settings" | "nic_offload" | "nic_advanced" => {
226            inspect_network_adapter()
227        }
228        "event_query" | "event_log" | "events" | "event_search" | "eventlog" => {
229            let event_id = args.get("event_id").and_then(|v| v.as_u64()).map(|n| n as u32);
230            let log_name = args.get("log").and_then(|v| v.as_str()).map(|s| s.to_string());
231            let source = args.get("source").and_then(|v| v.as_str()).map(|s| s.to_string());
232            let hours = args.get("hours").and_then(|v| v.as_u64()).unwrap_or(24) as u32;
233            let level = args.get("level").and_then(|v| v.as_str()).map(|s| s.to_string());
234            inspect_event_query(event_id, log_name.as_deref(), source.as_deref(), hours, level.as_deref(), max_entries)
235        }
236        "app_crashes" | "application_crashes" | "app_errors" | "application_errors" | "faulting_application" => {
237            let process_filter = args.get("process").and_then(|v| v.as_str()).map(|s| s.to_string());
238            inspect_app_crashes(process_filter.as_deref(), max_entries)
239        }
240        "mdm_enrollment" | "mdm" | "intune" | "intune_enrollment" | "device_enrollment" | "autopilot" => {
241            inspect_mdm_enrollment()
242        }
243        "storage_spaces" | "storage_pool" | "storage_pools" | "virtual_disk" | "virtual_disks" | "windows_raid" => {
244            inspect_storage_spaces()
245        }
246        "defender_quarantine" | "quarantine" | "threat_history" | "malware_history" | "defender_history" | "detected_threats" => {
247            inspect_defender_quarantine(max_entries)
248        }
249        "domain_health" | "dc_connectivity" | "ad_connectivity" | "kerberos_health" => {
250            inspect_domain_health()
251        }
252        "service_dependencies" | "svc_deps" | "service_deps" => {
253            inspect_service_dependencies(max_entries)
254        }
255        "wmi_health" | "wmi_repository" | "wmi_status" => {
256            inspect_wmi_health()
257        }
258        "local_security_policy" | "password_policy" | "account_policy" | "ntlm_policy" | "lm_policy" => {
259            inspect_local_security_policy()
260        }
261        "usb_history" | "usb_devices" | "usb_forensics" | "usbstor" => {
262            inspect_usb_history(max_entries)
263        }
264        "print_spooler" | "spooler" | "printnightmare" | "print_security" | "printer_security" => {
265            inspect_print_spooler()
266        }
267        other => Err(format!(
268            "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, 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.",
269            other
270        )),
271
272    };
273
274    result.map(|body| annotate_privilege_limited_output(topic.as_str(), body))
275}
276
277fn annotate_privilege_limited_output(topic: &str, body: String) -> String {
278    let Some(scope) = admin_sensitive_topic_scope(topic) else {
279        return body;
280    };
281    let lower = body.to_lowercase();
282    let privilege_limited = lower.contains("access denied")
283        || lower.contains("administrator privilege is required")
284        || lower.contains("administrator privileges required")
285        || lower.contains("requires administrator")
286        || lower.contains("requires elevation")
287        || lower.contains("non-admin session")
288        || lower.contains("could not be fully determined from this session");
289    if !privilege_limited || lower.contains("=== elevation note ===") {
290        return body;
291    }
292
293    let mut annotated = body;
294    annotated.push_str("\n=== Elevation note ===\n");
295    annotated.push_str("- Hematite should stay non-admin by default.\n");
296    annotated.push_str(
297        "- This result may be partial because Windows restricted one or more read-only provider calls in the current session.\n",
298    );
299    annotated.push_str(&format!(
300        "- Rerun Hematite as Administrator only if you need a definitive {scope} answer.\n"
301    ));
302    annotated
303}
304
305fn admin_sensitive_topic_scope(topic: &str) -> Option<&'static str> {
306    match topic {
307        "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
308            Some("TPM / Secure Boot / firmware")
309        }
310        "gpo" | "group_policy" | "applied_policies" => Some("Group Policy"),
311        "audit_policy" | "audit" | "auditpol" => Some("audit policy"),
312        "winrm" | "remote_management" | "psremoting" => Some("WinRM"),
313        "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => Some("BitLocker"),
314        "windows_features" | "optional_features" | "installed_features" | "features" => {
315            Some("Windows Features")
316        }
317        "udp_ports" | "udp_listeners" | "udp" => Some("UDP listener"),
318        _ => None,
319    }
320}
321
322#[cfg(test)]
323mod privilege_hint_tests {
324    use super::annotate_privilege_limited_output;
325
326    #[test]
327    fn annotate_privilege_limited_output_only_tags_admin_sensitive_topics() {
328        let body = "Host inspection: network\nError: Access denied.\n".to_string();
329        let annotated = annotate_privilege_limited_output("network", body.clone());
330        assert_eq!(annotated, body);
331    }
332
333    #[test]
334    fn annotate_privilege_limited_output_adds_targeted_note_for_tpm() {
335        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();
336        let annotated = annotate_privilege_limited_output("tpm", body);
337        assert!(annotated.contains("=== Elevation note ==="));
338        assert!(annotated.contains("stay non-admin by default"));
339        assert!(annotated.contains("definitive TPM / Secure Boot / firmware answer"));
340    }
341}
342
343#[cfg(test)]
344mod event_query_tests {
345    use super::is_event_query_no_results_message;
346
347    #[cfg(target_os = "windows")]
348    #[test]
349    fn treats_windows_no_results_message_as_empty_query() {
350        assert!(is_event_query_no_results_message(
351            "No events were found that match the specified selection criteria."
352        ));
353    }
354
355    #[cfg(target_os = "windows")]
356    #[test]
357    fn does_not_treat_real_errors_as_empty_query() {
358        assert!(!is_event_query_no_results_message("Access is denied."));
359    }
360}
361
362fn parse_max_entries(args: &Value) -> usize {
363    args.get("max_entries")
364        .and_then(|v| v.as_u64())
365        .map(|n| n as usize)
366        .unwrap_or(DEFAULT_MAX_ENTRIES)
367        .clamp(1, MAX_ENTRIES_CAP)
368}
369
370fn parse_port_filter(args: &Value) -> Option<u16> {
371    args.get("port")
372        .and_then(|v| v.as_u64())
373        .and_then(|n| u16::try_from(n).ok())
374}
375
376fn parse_name_filter(args: &Value) -> Option<String> {
377    args.get("name")
378        .and_then(|v| v.as_str())
379        .map(str::trim)
380        .filter(|value| !value.is_empty())
381        .map(|value| value.to_string())
382}
383
384fn parse_lookback_hours(args: &Value) -> Option<u32> {
385    args.get("lookback_hours")
386        .and_then(|v| v.as_u64())
387        .map(|n| n as u32)
388}
389
390fn parse_issue_text(args: &Value) -> Option<String> {
391    args.get("issue")
392        .and_then(|v| v.as_str())
393        .map(str::trim)
394        .filter(|value| !value.is_empty())
395        .map(|value| value.to_string())
396}
397
398#[cfg(target_os = "windows")]
399fn is_event_query_no_results_message(message: &str) -> bool {
400    let lower = message.to_ascii_lowercase();
401    lower.contains("no events were found")
402        || lower.contains("no events match the specified selection criteria")
403}
404
405fn resolve_optional_path(args: &Value) -> Result<PathBuf, String> {
406    match args.get("path").and_then(|v| v.as_str()) {
407        Some(raw_path) => resolve_path(raw_path),
408        None => {
409            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))
410        }
411    }
412}
413
414fn inspect_summary(max_entries: usize) -> Result<String, String> {
415    let current_dir =
416        std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
417    let workspace_root = crate::tools::file_ops::workspace_root();
418    let workspace_mode = workspace_mode_label(&workspace_root);
419    let path_stats = analyze_path_env();
420    let toolchains = collect_toolchains();
421
422    let mut out = String::from("Host inspection: summary\n\n");
423    out.push_str(&format!("- OS: {}\n", std::env::consts::OS));
424    out.push_str(&format!("- Current directory: {}\n", current_dir.display()));
425    out.push_str(&format!("- Workspace root: {}\n", workspace_root.display()));
426    out.push_str(&format!("- Workspace mode: {}\n", workspace_mode));
427    out.push_str(&format!("- Preferred shell: {}\n", preferred_shell_label()));
428    out.push_str(&format!(
429        "- PATH entries: {} total, {} unique, {} duplicates, {} missing\n",
430        path_stats.total_entries,
431        path_stats.unique_entries,
432        path_stats.duplicate_entries.len(),
433        path_stats.missing_entries.len()
434    ));
435
436    if toolchains.found.is_empty() {
437        out.push_str(
438            "- Toolchains found: none of the common developer tools were detected on PATH\n",
439        );
440    } else {
441        out.push_str("- Toolchains found:\n");
442        for (label, version) in toolchains.found.iter().take(max_entries.min(8)) {
443            out.push_str(&format!("  - {}: {}\n", label, version));
444        }
445        if toolchains.found.len() > max_entries.min(8) {
446            out.push_str(&format!(
447                "  - ... {} more found tools omitted\n",
448                toolchains.found.len() - max_entries.min(8)
449            ));
450        }
451    }
452
453    if !toolchains.missing.is_empty() {
454        out.push_str(&format!(
455            "- Common tools not detected on PATH: {}\n",
456            toolchains.missing.join(", ")
457        ));
458    }
459
460    for (label, path) in [("Desktop", desktop_dir()), ("Downloads", downloads_dir())] {
461        match path {
462            Some(path) if path.exists() => match count_top_level_items(&path) {
463                Ok(count) => out.push_str(&format!(
464                    "- {}: {} top-level items at {}\n",
465                    label,
466                    count,
467                    path.display()
468                )),
469                Err(e) => out.push_str(&format!(
470                    "- {}: exists at {} but could not inspect ({})\n",
471                    label,
472                    path.display(),
473                    e
474                )),
475            },
476            Some(path) => out.push_str(&format!(
477                "- {}: expected at {} but not found\n",
478                label,
479                path.display()
480            )),
481            None => out.push_str(&format!("- {}: location unavailable on this host\n", label)),
482        }
483    }
484
485    Ok(out.trim_end().to_string())
486}
487
488fn inspect_toolchains() -> Result<String, String> {
489    let report = collect_toolchains();
490    let mut out = String::from("Host inspection: toolchains\n\n");
491
492    if report.found.is_empty() {
493        out.push_str("- No common developer tools were detected on PATH.");
494    } else {
495        out.push_str("Detected developer tools:\n");
496        for (label, version) in report.found {
497            out.push_str(&format!("- {}: {}\n", label, version));
498        }
499    }
500
501    if !report.missing.is_empty() {
502        out.push_str("\nNot detected on PATH:\n");
503        for label in report.missing {
504            out.push_str(&format!("- {}\n", label));
505        }
506    }
507
508    Ok(out.trim_end().to_string())
509}
510
511fn inspect_path(max_entries: usize) -> Result<String, String> {
512    let path_stats = analyze_path_env();
513    let mut out = String::from("Host inspection: PATH\n\n");
514    out.push_str(&format!("- Total entries: {}\n", path_stats.total_entries));
515    out.push_str(&format!(
516        "- Unique entries: {}\n",
517        path_stats.unique_entries
518    ));
519    out.push_str(&format!(
520        "- Duplicate entries: {}\n",
521        path_stats.duplicate_entries.len()
522    ));
523    out.push_str(&format!(
524        "- Missing paths: {}\n",
525        path_stats.missing_entries.len()
526    ));
527
528    out.push_str("\nPATH entries:\n");
529    for entry in path_stats.entries.iter().take(max_entries) {
530        out.push_str(&format!("- {}\n", entry));
531    }
532    if path_stats.entries.len() > max_entries {
533        out.push_str(&format!(
534            "- ... {} more entries omitted\n",
535            path_stats.entries.len() - max_entries
536        ));
537    }
538
539    if !path_stats.duplicate_entries.is_empty() {
540        out.push_str("\nDuplicate entries:\n");
541        for entry in path_stats.duplicate_entries.iter().take(max_entries) {
542            out.push_str(&format!("- {}\n", entry));
543        }
544        if path_stats.duplicate_entries.len() > max_entries {
545            out.push_str(&format!(
546                "- ... {} more duplicates omitted\n",
547                path_stats.duplicate_entries.len() - max_entries
548            ));
549        }
550    }
551
552    if !path_stats.missing_entries.is_empty() {
553        out.push_str("\nMissing directories:\n");
554        for entry in path_stats.missing_entries.iter().take(max_entries) {
555            out.push_str(&format!("- {}\n", entry));
556        }
557        if path_stats.missing_entries.len() > max_entries {
558            out.push_str(&format!(
559                "- ... {} more missing entries omitted\n",
560                path_stats.missing_entries.len() - max_entries
561            ));
562        }
563    }
564
565    Ok(out.trim_end().to_string())
566}
567
568fn inspect_env_doctor(max_entries: usize) -> Result<String, String> {
569    let path_stats = analyze_path_env();
570    let toolchains = collect_toolchains();
571    let package_managers = collect_package_managers();
572    let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
573
574    let mut out = String::from("Host inspection: env_doctor\n\n");
575    out.push_str(&format!(
576        "- PATH health: {} duplicates, {} missing entries\n",
577        path_stats.duplicate_entries.len(),
578        path_stats.missing_entries.len()
579    ));
580    out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
581    out.push_str(&format!(
582        "- Package managers found: {}\n",
583        package_managers.found.len()
584    ));
585
586    if !package_managers.found.is_empty() {
587        out.push_str("\nPackage managers:\n");
588        for (label, version) in package_managers.found.iter().take(max_entries) {
589            out.push_str(&format!("- {}: {}\n", label, version));
590        }
591        if package_managers.found.len() > max_entries {
592            out.push_str(&format!(
593                "- ... {} more package managers omitted\n",
594                package_managers.found.len() - max_entries
595            ));
596        }
597    }
598
599    if !path_stats.duplicate_entries.is_empty() {
600        out.push_str("\nDuplicate PATH entries:\n");
601        for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
602            out.push_str(&format!("- {}\n", entry));
603        }
604        if path_stats.duplicate_entries.len() > max_entries.min(5) {
605            out.push_str(&format!(
606                "- ... {} more duplicate entries omitted\n",
607                path_stats.duplicate_entries.len() - max_entries.min(5)
608            ));
609        }
610    }
611
612    if !path_stats.missing_entries.is_empty() {
613        out.push_str("\nMissing PATH entries:\n");
614        for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
615            out.push_str(&format!("- {}\n", entry));
616        }
617        if path_stats.missing_entries.len() > max_entries.min(5) {
618            out.push_str(&format!(
619                "- ... {} more missing entries omitted\n",
620                path_stats.missing_entries.len() - max_entries.min(5)
621            ));
622        }
623    }
624
625    if !findings.is_empty() {
626        out.push_str("\nFindings:\n");
627        for finding in findings.iter().take(max_entries.max(5)) {
628            out.push_str(&format!("- {}\n", finding));
629        }
630        if findings.len() > max_entries.max(5) {
631            out.push_str(&format!(
632                "- ... {} more findings omitted\n",
633                findings.len() - max_entries.max(5)
634            ));
635        }
636    } else {
637        out.push_str("\nFindings:\n- No obvious environment drift was detected from PATH and package-manager checks.");
638    }
639
640    out.push_str(
641        "\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.",
642    );
643
644    Ok(out.trim_end().to_string())
645}
646
647#[derive(Clone, Copy, Debug, Eq, PartialEq)]
648enum FixPlanKind {
649    EnvPath,
650    PortConflict,
651    LmStudio,
652    DriverInstall,
653    GroupPolicy,
654    FirewallRule,
655    SshKey,
656    WslSetup,
657    ServiceConfig,
658    WindowsActivation,
659    RegistryEdit,
660    ScheduledTaskCreate,
661    DiskCleanup,
662    DnsResolution,
663    Generic,
664}
665
666async fn inspect_fix_plan(
667    issue: Option<String>,
668    port_filter: Option<u16>,
669    max_entries: usize,
670) -> Result<String, String> {
671    let issue = issue.unwrap_or_else(|| {
672        "Help me fix PATH, toolchain, port-conflict, or LM Studio connectivity problems."
673            .to_string()
674    });
675    let plan_kind = classify_fix_plan_kind(&issue, port_filter);
676    match plan_kind {
677        FixPlanKind::EnvPath => inspect_env_fix_plan(&issue, max_entries),
678        FixPlanKind::PortConflict => inspect_port_fix_plan(&issue, port_filter, max_entries),
679        FixPlanKind::LmStudio => inspect_lm_studio_fix_plan(&issue, max_entries).await,
680        FixPlanKind::DriverInstall => inspect_driver_install_fix_plan(&issue),
681        FixPlanKind::GroupPolicy => inspect_group_policy_fix_plan(&issue),
682        FixPlanKind::FirewallRule => inspect_firewall_rule_fix_plan(&issue),
683        FixPlanKind::SshKey => inspect_ssh_key_fix_plan(&issue),
684        FixPlanKind::WslSetup => inspect_wsl_setup_fix_plan(&issue),
685        FixPlanKind::ServiceConfig => inspect_service_config_fix_plan(&issue),
686        FixPlanKind::WindowsActivation => inspect_windows_activation_fix_plan(&issue),
687        FixPlanKind::RegistryEdit => inspect_registry_edit_fix_plan(&issue),
688        FixPlanKind::ScheduledTaskCreate => inspect_scheduled_task_fix_plan(&issue),
689        FixPlanKind::DiskCleanup => inspect_disk_cleanup_fix_plan(&issue),
690        FixPlanKind::DnsResolution => inspect_dns_fix_plan(&issue),
691        FixPlanKind::Generic => inspect_generic_fix_plan(&issue),
692    }
693}
694
695fn classify_fix_plan_kind(issue: &str, port_filter: Option<u16>) -> FixPlanKind {
696    let lower = issue.to_ascii_lowercase();
697    // FirewallRule must be checked before PortConflict — "open port 80 in the firewall"
698    // is firewall rule creation, not a port ownership conflict.
699    if lower.contains("firewall rule")
700        || lower.contains("inbound rule")
701        || lower.contains("outbound rule")
702        || (lower.contains("firewall")
703            && (lower.contains("allow")
704                || lower.contains("block")
705                || lower.contains("create")
706                || lower.contains("open")))
707    {
708        FixPlanKind::FirewallRule
709    } else if port_filter.is_some()
710        || lower.contains("port ")
711        || lower.contains("address already in use")
712        || lower.contains("already in use")
713        || lower.contains("what owns port")
714        || lower.contains("listening on port")
715    {
716        FixPlanKind::PortConflict
717    } else if lower.contains("lm studio")
718        || lower.contains("localhost:1234")
719        || lower.contains("/v1/models")
720        || lower.contains("no coding model loaded")
721        || lower.contains("embedding model")
722        || lower.contains("server on port 1234")
723        || lower.contains("runtime refresh")
724    {
725        FixPlanKind::LmStudio
726    } else if lower.contains("driver")
727        || lower.contains("gpu driver")
728        || lower.contains("nvidia driver")
729        || lower.contains("amd driver")
730        || lower.contains("install driver")
731        || lower.contains("update driver")
732    {
733        FixPlanKind::DriverInstall
734    } else if lower.contains("group policy")
735        || lower.contains("gpedit")
736        || lower.contains("local policy")
737        || lower.contains("secpol")
738        || lower.contains("administrative template")
739    {
740        FixPlanKind::GroupPolicy
741    } else if lower.contains("ssh key")
742        || lower.contains("ssh-keygen")
743        || lower.contains("generate ssh")
744        || lower.contains("authorized_keys")
745        || lower.contains("id_rsa")
746        || lower.contains("id_ed25519")
747    {
748        FixPlanKind::SshKey
749    } else if lower.contains("wsl")
750        || lower.contains("windows subsystem for linux")
751        || lower.contains("install ubuntu")
752        || lower.contains("install linux on windows")
753        || lower.contains("wsl2")
754    {
755        FixPlanKind::WslSetup
756    } else if lower.contains("service")
757        && (lower.contains("start ")
758            || lower.contains("stop ")
759            || lower.contains("restart ")
760            || lower.contains("enable ")
761            || lower.contains("disable ")
762            || lower.contains("configure service"))
763    {
764        FixPlanKind::ServiceConfig
765    } else if lower.contains("activate windows")
766        || lower.contains("windows activation")
767        || lower.contains("product key")
768        || lower.contains("kms")
769        || lower.contains("not activated")
770    {
771        FixPlanKind::WindowsActivation
772    } else if lower.contains("registry")
773        || lower.contains("regedit")
774        || lower.contains("hklm")
775        || lower.contains("hkcu")
776        || lower.contains("reg add")
777        || lower.contains("reg delete")
778        || lower.contains("registry key")
779    {
780        FixPlanKind::RegistryEdit
781    } else if lower.contains("scheduled task")
782        || lower.contains("task scheduler")
783        || lower.contains("schtasks")
784        || lower.contains("create task")
785        || lower.contains("run on startup")
786        || lower.contains("run on schedule")
787        || lower.contains("cron")
788    {
789        FixPlanKind::ScheduledTaskCreate
790    } else if lower.contains("disk cleanup")
791        || lower.contains("free up disk")
792        || lower.contains("free up space")
793        || lower.contains("clear cache")
794        || lower.contains("disk full")
795        || lower.contains("low disk space")
796        || lower.contains("reclaim space")
797    {
798        FixPlanKind::DiskCleanup
799    } else if lower.contains("cargo")
800        || lower.contains("rustc")
801        || lower.contains("path")
802        || lower.contains("package manager")
803        || lower.contains("package managers")
804        || lower.contains("toolchain")
805        || lower.contains("winget")
806        || lower.contains("choco")
807        || lower.contains("scoop")
808        || lower.contains("python")
809        || lower.contains("node")
810    {
811        FixPlanKind::EnvPath
812    } else if lower.contains("dns ")
813        || lower.contains("nameserver")
814        || lower.contains("cannot resolve")
815        || lower.contains("nslookup")
816        || lower.contains("flushdns")
817    {
818        FixPlanKind::DnsResolution
819    } else {
820        FixPlanKind::Generic
821    }
822}
823
824fn inspect_env_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
825    let path_stats = analyze_path_env();
826    let toolchains = collect_toolchains();
827    let package_managers = collect_package_managers();
828    let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
829    let found_tools = toolchains
830        .found
831        .iter()
832        .map(|(label, _)| label.as_str())
833        .collect::<HashSet<_>>();
834    let found_managers = package_managers
835        .found
836        .iter()
837        .map(|(label, _)| label.as_str())
838        .collect::<HashSet<_>>();
839
840    let mut out = String::from("Host inspection: fix_plan\n\n");
841    out.push_str(&format!("- Requested issue: {}\n", issue));
842    out.push_str("- Fix-plan type: environment/path\n");
843    out.push_str(&format!(
844        "- PATH health: {} duplicates, {} missing entries\n",
845        path_stats.duplicate_entries.len(),
846        path_stats.missing_entries.len()
847    ));
848    out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
849    out.push_str(&format!(
850        "- Package managers found: {}\n",
851        package_managers.found.len()
852    ));
853
854    out.push_str("\nLikely causes:\n");
855    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
856        out.push_str(
857            "- 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",
858        );
859    }
860    if path_stats.duplicate_entries.is_empty()
861        && path_stats.missing_entries.is_empty()
862        && !findings.is_empty()
863    {
864        for finding in findings.iter().take(max_entries.max(4)) {
865            out.push_str(&format!("- {}\n", finding));
866        }
867    } else {
868        if !path_stats.duplicate_entries.is_empty() {
869            out.push_str("- Duplicate PATH rows create clutter and can hide which install path is actually winning.\n");
870        }
871        if !path_stats.missing_entries.is_empty() {
872            out.push_str("- Stale PATH rows point at directories that no longer exist, which makes environment drift harder to reason about.\n");
873        }
874    }
875    if found_tools.contains("node")
876        && !found_managers.contains("npm")
877        && !found_managers.contains("pnpm")
878    {
879        out.push_str("- Node is present without a detected package manager. That usually means a partial install or PATH drift.\n");
880    }
881    if found_tools.contains("python")
882        && !found_managers.contains("pip")
883        && !found_managers.contains("uv")
884        && !found_managers.contains("pipx")
885    {
886        out.push_str("- Python is present without a detected package manager. That usually means the launcher works but Scripts/bin is not discoverable.\n");
887    }
888
889    out.push_str("\nFix plan:\n");
890    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");
891    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
892        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");
893    } else if !found_tools.contains("rustc") && !found_managers.contains("cargo") {
894        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");
895    }
896    if !path_stats.duplicate_entries.is_empty() || !path_stats.missing_entries.is_empty() {
897        out.push_str("- Clean duplicate or dead PATH rows in Environment Variables so the winning toolchain path is obvious and stable.\n");
898    }
899    if found_tools.contains("node")
900        && !found_managers.contains("npm")
901        && !found_managers.contains("pnpm")
902    {
903        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");
904    }
905    if found_tools.contains("python")
906        && !found_managers.contains("pip")
907        && !found_managers.contains("uv")
908        && !found_managers.contains("pipx")
909    {
910        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");
911    }
912
913    if !path_stats.duplicate_entries.is_empty() {
914        out.push_str("\nExample duplicate PATH rows:\n");
915        for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
916            out.push_str(&format!("- {}\n", entry));
917        }
918    }
919    if !path_stats.missing_entries.is_empty() {
920        out.push_str("\nExample missing PATH rows:\n");
921        for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
922            out.push_str(&format!("- {}\n", entry));
923        }
924    }
925
926    out.push_str(
927        "\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.",
928    );
929    Ok(out.trim_end().to_string())
930}
931
932fn inspect_port_fix_plan(
933    issue: &str,
934    port_filter: Option<u16>,
935    max_entries: usize,
936) -> Result<String, String> {
937    let requested_port = port_filter.or_else(|| first_port_in_text(issue));
938    let listeners = collect_listening_ports().unwrap_or_default();
939    let mut matching = listeners;
940    if let Some(port) = requested_port {
941        matching.retain(|entry| entry.port == port);
942    }
943    let processes = collect_processes().unwrap_or_default();
944
945    let mut out = String::from("Host inspection: fix_plan\n\n");
946    out.push_str(&format!("- Requested issue: {}\n", issue));
947    out.push_str("- Fix-plan type: port_conflict\n");
948    if let Some(port) = requested_port {
949        out.push_str(&format!("- Requested port: {}\n", port));
950    } else {
951        out.push_str("- Requested port: not parsed from the issue text\n");
952    }
953    out.push_str(&format!("- Matching listeners found: {}\n", matching.len()));
954
955    if !matching.is_empty() {
956        out.push_str("\nCurrent listeners:\n");
957        for entry in matching.iter().take(max_entries.min(5)) {
958            let process_name = entry
959                .pid
960                .as_deref()
961                .and_then(|pid| pid.parse::<u32>().ok())
962                .and_then(|pid| {
963                    processes
964                        .iter()
965                        .find(|process| process.pid == pid)
966                        .map(|process| process.name.as_str())
967                })
968                .unwrap_or("unknown");
969            let pid = entry.pid.as_deref().unwrap_or("unknown");
970            out.push_str(&format!(
971                "- {} {} ({}) pid {} process {}\n",
972                entry.protocol, entry.local, entry.state, pid, process_name
973            ));
974        }
975    }
976
977    out.push_str("\nFix plan:\n");
978    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");
979    if !matching.is_empty() {
980        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");
981    } else {
982        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");
983    }
984    out.push_str("- If the port is intentionally occupied, move your app to another port instead of fighting the existing process.\n");
985    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");
986    out.push_str(
987        "\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.",
988    );
989    Ok(out.trim_end().to_string())
990}
991
992async fn inspect_lm_studio_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
993    let config = crate::agent::config::load_config();
994    let configured_api = config
995        .api_url
996        .unwrap_or_else(|| "http://localhost:1234/v1".to_string());
997    let models_url = format!("{}/models", configured_api.trim_end_matches('/'));
998    let reachability = probe_http_endpoint(&models_url).await;
999    let embed_model = detect_loaded_embed_model(&configured_api).await;
1000
1001    let mut out = String::from("Host inspection: fix_plan\n\n");
1002    out.push_str(&format!("- Requested issue: {}\n", issue));
1003    out.push_str("- Fix-plan type: lm_studio\n");
1004    out.push_str(&format!("- Configured API URL: {}\n", configured_api));
1005    out.push_str(&format!("- Probe URL: {}\n", models_url));
1006    match &reachability {
1007        EndpointProbe::Reachable(status) => {
1008            out.push_str(&format!("- Endpoint reachable: yes (HTTP {})\n", status))
1009        }
1010        EndpointProbe::Unreachable(detail) => {
1011            out.push_str(&format!("- Endpoint reachable: no ({})\n", detail))
1012        }
1013    }
1014    out.push_str(&format!(
1015        "- Embedding model loaded: {}\n",
1016        embed_model.as_deref().unwrap_or("none detected")
1017    ));
1018
1019    out.push_str("\nFix plan:\n");
1020    match reachability {
1021        EndpointProbe::Reachable(_) => {
1022            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");
1023        }
1024        EndpointProbe::Unreachable(_) => {
1025            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");
1026        }
1027    }
1028    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");
1029    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");
1030    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");
1031    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");
1032    if let Some(model) = embed_model {
1033        out.push_str(&format!(
1034            "- Current embedding model already visible: {}. That means the embeddings lane is configured, so focus on the chat model or endpoint next.\n",
1035            model
1036        ));
1037    }
1038    if max_entries > 0 {
1039        out.push_str(
1040            "\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.",
1041        );
1042    }
1043    Ok(out.trim_end().to_string())
1044}
1045
1046fn inspect_driver_install_fix_plan(issue: &str) -> Result<String, String> {
1047    // Read GPU info from the hardware topic output for grounding
1048    #[cfg(target_os = "windows")]
1049    let gpu_info = {
1050        let out = Command::new("powershell")
1051            .args([
1052                "-NoProfile",
1053                "-NonInteractive",
1054                "-Command",
1055                "Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate | ForEach-Object { \"GPU: $($_.Name) | Driver: $($_.DriverVersion) | Date: $($_.DriverDate)\" }",
1056            ])
1057            .output()
1058            .ok()
1059            .and_then(|o| String::from_utf8(o.stdout).ok())
1060            .unwrap_or_default();
1061        out.trim().to_string()
1062    };
1063    #[cfg(not(target_os = "windows"))]
1064    let gpu_info = String::from("(GPU detection not available on this platform)");
1065
1066    let mut out = String::from("Host inspection: fix_plan\n\n");
1067    out.push_str(&format!("- Requested issue: {}\n", issue));
1068    out.push_str("- Fix-plan type: driver_install\n");
1069    if !gpu_info.is_empty() {
1070        out.push_str(&format!("\nDetected GPU(s):\n{}\n", gpu_info));
1071    }
1072    out.push_str("\nFix plan — Installing or updating GPU drivers:\n");
1073    out.push_str("1. Identify your GPU make from the detection above (NVIDIA, AMD, or Intel).\n");
1074    out.push_str(
1075        "2. Open Device Manager: press Win+X → Device Manager → expand Display Adapters.\n",
1076    );
1077    out.push_str("3. Right-click your GPU → Properties → Driver tab — note the current driver version and date.\n");
1078    out.push_str("4. Download the latest driver directly from the manufacturer:\n");
1079    out.push_str("   - NVIDIA: geforce.com/drivers (use GeForce Experience for auto-detection)\n");
1080    out.push_str("   - AMD: amd.com/support (use Auto-Detect tool)\n");
1081    out.push_str("   - Intel: intel.com/content/www/us/en/download-center/home.html\n");
1082    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");
1083    out.push_str("6. Reboot when prompted — driver installs always require a restart.\n");
1084    out.push_str("\nVerification:\n");
1085    out.push_str("- After reboot, run in PowerShell:\n  Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate\n");
1086    out.push_str("- The DriverVersion should match what you installed.\n");
1087    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.");
1088    Ok(out.trim_end().to_string())
1089}
1090
1091fn inspect_group_policy_fix_plan(issue: &str) -> Result<String, String> {
1092    // Check Windows edition — Group Policy editor is not available on Home editions
1093    #[cfg(target_os = "windows")]
1094    let edition = {
1095        Command::new("powershell")
1096            .args([
1097                "-NoProfile",
1098                "-NonInteractive",
1099                "-Command",
1100                "(Get-CimInstance Win32_OperatingSystem).Caption",
1101            ])
1102            .output()
1103            .ok()
1104            .and_then(|o| String::from_utf8(o.stdout).ok())
1105            .unwrap_or_default()
1106            .trim()
1107            .to_string()
1108    };
1109    #[cfg(not(target_os = "windows"))]
1110    let edition = String::from("(Windows edition detection not available)");
1111
1112    let is_home = edition.to_lowercase().contains("home");
1113
1114    let mut out = String::from("Host inspection: fix_plan\n\n");
1115    out.push_str(&format!("- Requested issue: {}\n", issue));
1116    out.push_str("- Fix-plan type: group_policy\n");
1117    out.push_str(&format!(
1118        "- Windows edition detected: {}\n",
1119        if edition.is_empty() {
1120            "unknown".to_string()
1121        } else {
1122            edition.clone()
1123        }
1124    ));
1125
1126    if is_home {
1127        out.push_str("\nWARNING: Windows Home does not include the Local Group Policy Editor (gpedit.msc).\n");
1128        out.push_str("Options on Home edition:\n");
1129        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");
1130        out.push_str(
1131            "2. Install the gpedit.msc enabler script (third-party — use with caution).\n",
1132        );
1133        out.push_str("3. Upgrade to Windows Pro if you need full Group Policy support.\n");
1134    } else {
1135        out.push_str("\nFix plan — Editing Local Group Policy:\n");
1136        out.push_str("1. Press Win+R → type gpedit.msc → press Enter (requires administrator).\n");
1137        out.push_str("2. Navigate the tree: Computer Configuration (machine-wide) or User Configuration (current user).\n");
1138        out.push_str("3. Drill into Administrative Templates → find the policy you want.\n");
1139        out.push_str("4. Double-click a policy → set to Enabled, Disabled, or Not Configured.\n");
1140        out.push_str("5. Click OK — most policies apply on next logon or after gpupdate.\n");
1141        out.push_str("6. To force immediate application, run in an elevated PowerShell:\n  gpupdate /force\n");
1142    }
1143    out.push_str("\nVerification:\n");
1144    out.push_str("- Run `gpresult /r` in an elevated command prompt to see applied policies.\n");
1145    out.push_str(
1146        "- Or: `Get-GPResultantSetOfPolicy` in PowerShell (requires RSAT on domain machines).\n",
1147    );
1148    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.");
1149    Ok(out.trim_end().to_string())
1150}
1151
1152fn inspect_firewall_rule_fix_plan(issue: &str) -> Result<String, String> {
1153    #[cfg(target_os = "windows")]
1154    let profile_state = {
1155        Command::new("powershell")
1156            .args([
1157                "-NoProfile",
1158                "-NonInteractive",
1159                "-Command",
1160                "Get-NetFirewallProfile | Select-Object Name,Enabled | ForEach-Object { \"$($_.Name): $($_.Enabled)\" }",
1161            ])
1162            .output()
1163            .ok()
1164            .and_then(|o| String::from_utf8(o.stdout).ok())
1165            .unwrap_or_default()
1166            .trim()
1167            .to_string()
1168    };
1169    #[cfg(not(target_os = "windows"))]
1170    let profile_state = String::new();
1171
1172    let mut out = String::from("Host inspection: fix_plan\n\n");
1173    out.push_str(&format!("- Requested issue: {}\n", issue));
1174    out.push_str("- Fix-plan type: firewall_rule\n");
1175    if !profile_state.is_empty() {
1176        out.push_str(&format!("\nFirewall profile state:\n{}\n", profile_state));
1177    }
1178    out.push_str("\nFix plan — Creating or modifying a Windows Firewall rule (PowerShell, run as Administrator):\n");
1179    out.push_str("\nTo ALLOW inbound traffic on a port:\n");
1180    out.push_str("  New-NetFirewallRule -DisplayName \"My App Port 8080\" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any\n");
1181    out.push_str("\nTo BLOCK outbound traffic to an address:\n");
1182    out.push_str("  New-NetFirewallRule -DisplayName \"Block Example\" -Direction Outbound -RemoteAddress 1.2.3.4 -Action Block\n");
1183    out.push_str("\nTo ALLOW an application through the firewall:\n");
1184    out.push_str("  New-NetFirewallRule -DisplayName \"My App\" -Direction Inbound -Program \"C:\\Path\\To\\App.exe\" -Action Allow\n");
1185    out.push_str("\nTo REMOVE a rule you created:\n");
1186    out.push_str("  Remove-NetFirewallRule -DisplayName \"My App Port 8080\"\n");
1187    out.push_str("\nTo see existing custom rules:\n");
1188    out.push_str("  Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.PolicyStoreSourceType -ne 'GroupPolicy' } | Select-Object DisplayName,Direction,Action\n");
1189    out.push_str("\nVerification:\n");
1190    out.push_str("- After creating the rule, test reachability from another machine or use:\n  Test-NetConnection -ComputerName localhost -Port 8080\n");
1191    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.");
1192    Ok(out.trim_end().to_string())
1193}
1194
1195fn inspect_ssh_key_fix_plan(issue: &str) -> Result<String, String> {
1196    let home = dirs_home().unwrap_or_else(|| std::path::PathBuf::from("~"));
1197    let ssh_dir = home.join(".ssh");
1198    let has_ssh_dir = ssh_dir.exists();
1199    let has_ed25519 = ssh_dir.join("id_ed25519").exists();
1200    let has_rsa = ssh_dir.join("id_rsa").exists();
1201    let has_authorized_keys = ssh_dir.join("authorized_keys").exists();
1202
1203    let mut out = String::from("Host inspection: fix_plan\n\n");
1204    out.push_str(&format!("- Requested issue: {}\n", issue));
1205    out.push_str("- Fix-plan type: ssh_key\n");
1206    out.push_str(&format!("- ~/.ssh directory exists: {}\n", has_ssh_dir));
1207    out.push_str(&format!("- id_ed25519 key found: {}\n", has_ed25519));
1208    out.push_str(&format!("- id_rsa key found: {}\n", has_rsa));
1209    out.push_str(&format!(
1210        "- authorized_keys found: {}\n",
1211        has_authorized_keys
1212    ));
1213
1214    if has_ed25519 {
1215        out.push_str("\nYou already have an Ed25519 key. If you want to use it, skip to the 'Add to agent' step.\n");
1216    }
1217
1218    out.push_str("\nFix plan — Generating an SSH key pair:\n");
1219    out.push_str("1. Open PowerShell (or Terminal) — no elevation needed.\n");
1220    out.push_str("2. Generate an Ed25519 key (preferred over RSA):\n");
1221    out.push_str("   ssh-keygen -t ed25519 -C \"your@email.com\"\n");
1222    out.push_str(
1223        "   - Accept the default path (~/.ssh/id_ed25519) unless you need a custom name.\n",
1224    );
1225    out.push_str("   - Set a passphrase (recommended) or press Enter twice for no passphrase.\n");
1226    out.push_str("3. Start the SSH agent and add your key:\n");
1227    out.push_str("   # Windows (PowerShell, run as Admin once to enable the service):\n");
1228    out.push_str("   Set-Service -Name ssh-agent -StartupType Automatic\n");
1229    out.push_str("   Start-Service ssh-agent\n");
1230    out.push_str("   # Then add the key (normal PowerShell):\n");
1231    out.push_str("   ssh-add ~/.ssh/id_ed25519\n");
1232    out.push_str("4. Copy your PUBLIC key to the target server's authorized_keys:\n");
1233    out.push_str("   # Print your public key:\n");
1234    out.push_str("   cat ~/.ssh/id_ed25519.pub\n");
1235    out.push_str("   # On the target server, append it:\n");
1236    out.push_str("   echo \"<paste public key>\" >> ~/.ssh/authorized_keys\n");
1237    out.push_str("   chmod 600 ~/.ssh/authorized_keys\n");
1238    out.push_str("5. Test the connection:\n");
1239    out.push_str("   ssh user@server-address\n");
1240    out.push_str("\nFor GitHub/GitLab:\n");
1241    out.push_str("- Copy the public key: Get-Content ~/.ssh/id_ed25519.pub | Set-Clipboard\n");
1242    out.push_str("- Paste it into GitHub Settings → SSH and GPG keys → New SSH key\n");
1243    out.push_str("- Test: ssh -T git@github.com\n");
1244    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.");
1245    Ok(out.trim_end().to_string())
1246}
1247
1248fn inspect_wsl_setup_fix_plan(issue: &str) -> Result<String, String> {
1249    #[cfg(target_os = "windows")]
1250    let wsl_status = {
1251        let out = Command::new("wsl")
1252            .args(["--status"])
1253            .output()
1254            .ok()
1255            .and_then(|o| {
1256                let stdout = String::from_utf8(o.stdout).unwrap_or_default();
1257                let stderr = String::from_utf8(o.stderr).unwrap_or_default();
1258                Some(format!("{}{}", stdout, stderr))
1259            })
1260            .unwrap_or_default();
1261        out.trim().to_string()
1262    };
1263    #[cfg(not(target_os = "windows"))]
1264    let wsl_status = String::new();
1265
1266    let wsl_installed =
1267        !wsl_status.is_empty() && !wsl_status.to_lowercase().contains("not installed");
1268
1269    let mut out = String::from("Host inspection: fix_plan\n\n");
1270    out.push_str(&format!("- Requested issue: {}\n", issue));
1271    out.push_str("- Fix-plan type: wsl_setup\n");
1272    out.push_str(&format!("- WSL already installed: {}\n", wsl_installed));
1273    if !wsl_status.is_empty() {
1274        out.push_str(&format!("- WSL status:\n{}\n", wsl_status));
1275    }
1276
1277    if wsl_installed {
1278        out.push_str("\nWSL is already installed. To install a new Linux distro:\n");
1279        out.push_str("1. Run in PowerShell (Admin): wsl --install -d Ubuntu\n");
1280        out.push_str("   Available distros: wsl --list --online\n");
1281        out.push_str("2. After install, launch from Start menu or type 'ubuntu' in PowerShell.\n");
1282        out.push_str("3. Create your Linux username and password when prompted.\n");
1283    } else {
1284        out.push_str("\nFix plan — Installing WSL2 (Windows Subsystem for Linux):\n");
1285        out.push_str("1. Open PowerShell as Administrator.\n");
1286        out.push_str("2. Install WSL with the default Ubuntu distro:\n");
1287        out.push_str("   wsl --install\n");
1288        out.push_str("   (This enables the required Windows features, downloads WSL2, and installs Ubuntu)\n");
1289        out.push_str("3. Reboot when prompted — WSL requires a restart after the first install.\n");
1290        out.push_str("4. After reboot, Ubuntu will launch automatically and ask you to create a username and password.\n");
1291        out.push_str("5. Set WSL2 as the default version (should already be set, but confirm):\n");
1292        out.push_str("   wsl --set-default-version 2\n");
1293        out.push_str("\nTo install a different distro instead of Ubuntu:\n");
1294        out.push_str("   wsl --install -d Debian\n");
1295        out.push_str("   wsl --list --online   # to see all available distros\n");
1296    }
1297    out.push_str("\nVerification:\n");
1298    out.push_str("- Run: wsl --list --verbose\n");
1299    out.push_str("- You should see your distro with State: Running and Version: 2\n");
1300    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.");
1301    Ok(out.trim_end().to_string())
1302}
1303
1304fn inspect_service_config_fix_plan(issue: &str) -> Result<String, String> {
1305    let lower = issue.to_ascii_lowercase();
1306    // Extract service name hints from the issue text
1307    let service_hint = if lower.contains("ssh") {
1308        Some("sshd")
1309    } else if lower.contains("mysql") {
1310        Some("MySQL80")
1311    } else if lower.contains("postgres") || lower.contains("postgresql") {
1312        Some("postgresql")
1313    } else if lower.contains("redis") {
1314        Some("Redis")
1315    } else if lower.contains("nginx") {
1316        Some("nginx")
1317    } else if lower.contains("apache") {
1318        Some("Apache2.4")
1319    } else {
1320        None
1321    };
1322
1323    #[cfg(target_os = "windows")]
1324    let service_state = if let Some(svc) = service_hint {
1325        Command::new("powershell")
1326            .args([
1327                "-NoProfile",
1328                "-NonInteractive",
1329                "-Command",
1330                &format!("Get-Service -Name '{}' -ErrorAction SilentlyContinue | Select-Object Name,Status,StartType | ForEach-Object {{ \"Service: $($_.Name) | Status: $($_.Status) | StartType: $($_.StartType)\" }}", svc),
1331            ])
1332            .output()
1333            .ok()
1334            .and_then(|o| String::from_utf8(o.stdout).ok())
1335            .unwrap_or_default()
1336            .trim()
1337            .to_string()
1338    } else {
1339        String::new()
1340    };
1341    #[cfg(not(target_os = "windows"))]
1342    let service_state = String::new();
1343
1344    let mut out = String::from("Host inspection: fix_plan\n\n");
1345    out.push_str(&format!("- Requested issue: {}\n", issue));
1346    out.push_str("- Fix-plan type: service_config\n");
1347    if let Some(svc) = service_hint {
1348        out.push_str(&format!("- Service detected in request: {}\n", svc));
1349    }
1350    if !service_state.is_empty() {
1351        out.push_str(&format!("- Current state: {}\n", service_state));
1352    }
1353
1354    out.push_str("\nFix plan — Managing Windows services (PowerShell, run as Administrator):\n");
1355    out.push_str("\nStart a service:\n");
1356    out.push_str("  Start-Service -Name \"ServiceName\"\n");
1357    out.push_str("\nStop a service:\n");
1358    out.push_str("  Stop-Service -Name \"ServiceName\"\n");
1359    out.push_str("\nRestart a service:\n");
1360    out.push_str("  Restart-Service -Name \"ServiceName\"\n");
1361    out.push_str("\nEnable a service to start automatically:\n");
1362    out.push_str("  Set-Service -Name \"ServiceName\" -StartupType Automatic\n");
1363    out.push_str("\nDisable a service (stops it from auto-starting):\n");
1364    out.push_str("  Set-Service -Name \"ServiceName\" -StartupType Disabled\n");
1365    out.push_str("\nFind the exact service name:\n");
1366    out.push_str("  Get-Service | Where-Object { $_.DisplayName -like '*mysql*' }\n");
1367    out.push_str("\nVerification:\n");
1368    out.push_str("  Get-Service -Name \"ServiceName\" | Select-Object Name,Status,StartType\n");
1369    if let Some(svc) = service_hint {
1370        out.push_str(&format!(
1371            "\nFor your detected service ({}):\n  Get-Service -Name '{}'\n",
1372            svc, svc
1373        ));
1374    }
1375    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.");
1376    Ok(out.trim_end().to_string())
1377}
1378
1379fn inspect_windows_activation_fix_plan(issue: &str) -> Result<String, String> {
1380    #[cfg(target_os = "windows")]
1381    let activation_status = {
1382        Command::new("powershell")
1383            .args([
1384                "-NoProfile",
1385                "-NonInteractive",
1386                "-Command",
1387                "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 + ')' })\" }",
1388            ])
1389            .output()
1390            .ok()
1391            .and_then(|o| String::from_utf8(o.stdout).ok())
1392            .unwrap_or_default()
1393            .trim()
1394            .to_string()
1395    };
1396    #[cfg(not(target_os = "windows"))]
1397    let activation_status = String::new();
1398
1399    let is_licensed = activation_status.to_lowercase().contains("licensed")
1400        && !activation_status.to_lowercase().contains("not licensed");
1401
1402    let mut out = String::from("Host inspection: fix_plan\n\n");
1403    out.push_str(&format!("- Requested issue: {}\n", issue));
1404    out.push_str("- Fix-plan type: windows_activation\n");
1405    if !activation_status.is_empty() {
1406        out.push_str(&format!(
1407            "- Current activation state:\n{}\n",
1408            activation_status
1409        ));
1410    }
1411
1412    if is_licensed {
1413        out.push_str(
1414            "\nWindows appears to be activated. If you are still seeing activation prompts, try:\n",
1415        );
1416        out.push_str("1. Run in elevated PowerShell: slmgr /ato\n");
1417        out.push_str("   (Forces an online activation attempt)\n");
1418        out.push_str("2. Check activation details: slmgr /dli\n");
1419    } else {
1420        out.push_str("\nFix plan — Activating Windows:\n");
1421        out.push_str("1. Check your current status first:\n");
1422        out.push_str("   slmgr /dli   (basic info)\n");
1423        out.push_str("   slmgr /dlv   (detailed — shows remaining rearms, grace period)\n");
1424        out.push_str("\n2. If you have a retail product key:\n");
1425        out.push_str("   slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX   (install key)\n");
1426        out.push_str("   slmgr /ato                                   (activate online)\n");
1427        out.push_str("\n3. If you had a digital license (linked to your Microsoft account):\n");
1428        out.push_str("   - Go to Settings → System → Activation\n");
1429        out.push_str("   - Click 'Troubleshoot' → 'I changed hardware on this device recently'\n");
1430        out.push_str("   - Sign in with the Microsoft account that holds the license\n");
1431        out.push_str("\n4. If using a volume license (organization/enterprise):\n");
1432        out.push_str("   - Contact your IT department for the KMS server address\n");
1433        out.push_str("   - Set KMS host: slmgr /skms kms.yourorg.com\n");
1434        out.push_str("   - Activate:    slmgr /ato\n");
1435    }
1436    out.push_str("\nVerification:\n");
1437    out.push_str("  slmgr /dli   — should show 'License Status: Licensed'\n");
1438    out.push_str("  Or: Settings → System → Activation → 'Windows is activated'\n");
1439    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.");
1440    Ok(out.trim_end().to_string())
1441}
1442
1443fn inspect_registry_edit_fix_plan(issue: &str) -> Result<String, String> {
1444    let mut out = String::from("Host inspection: fix_plan\n\n");
1445    out.push_str(&format!("- Requested issue: {}\n", issue));
1446    out.push_str("- Fix-plan type: registry_edit\n");
1447    out.push_str(
1448        "\nCAUTION: Registry edits affect core Windows behavior. Always back up before editing.\n",
1449    );
1450    out.push_str("\nFix plan — Safely editing the Windows Registry:\n");
1451    out.push_str("\n1. Back up before you touch anything:\n");
1452    out.push_str("   # Export the key you're about to change (PowerShell):\n");
1453    out.push_str("   reg export \"HKLM\\SOFTWARE\\MyKey\" C:\\backup\\MyKey_backup.reg\n");
1454    out.push_str("   # Or export the whole registry (takes a while):\n");
1455    out.push_str("   reg export HKLM C:\\backup\\HKLM_full.reg\n");
1456    out.push_str("\n2. Read a value (PowerShell, no elevation needed for HKCU):\n");
1457    out.push_str("   Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1458    out.push_str("\n3. Create or update a DWORD value (PowerShell, Admin for HKLM):\n");
1459    out.push_str(
1460        "   Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue' -Value 1 -Type DWord\n",
1461    );
1462    out.push_str("\n4. Create a new key:\n");
1463    out.push_str("   New-Item -Path 'HKLM:\\SOFTWARE\\MyNewKey' -Force\n");
1464    out.push_str("\n5. Delete a value:\n");
1465    out.push_str("   Remove-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1466    out.push_str("\n6. Restore from backup if something breaks:\n");
1467    out.push_str("   reg import C:\\backup\\MyKey_backup.reg\n");
1468    out.push_str("\nCommon registry hives:\n");
1469    out.push_str("  HKLM = HKEY_LOCAL_MACHINE  (machine-wide, requires Admin)\n");
1470    out.push_str("  HKCU = HKEY_CURRENT_USER   (current user, no elevation needed)\n");
1471    out.push_str("  HKCR = HKEY_CLASSES_ROOT    (file associations)\n");
1472    out.push_str("\nVerification:\n");
1473    out.push_str("  Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' | Select-Object MyValue\n");
1474    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.");
1475    Ok(out.trim_end().to_string())
1476}
1477
1478fn inspect_scheduled_task_fix_plan(issue: &str) -> Result<String, String> {
1479    let mut out = String::from("Host inspection: fix_plan\n\n");
1480    out.push_str(&format!("- Requested issue: {}\n", issue));
1481    out.push_str("- Fix-plan type: scheduled_task_create\n");
1482    out.push_str("\nFix plan — Creating a Scheduled Task (PowerShell, run as Administrator):\n");
1483    out.push_str("\nExample: Run a script at 9 AM every day\n");
1484    out.push_str("  $action  = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-File C:\\Scripts\\MyScript.ps1'\n");
1485    out.push_str("  $trigger = New-ScheduledTaskTrigger -Daily -At '09:00AM'\n");
1486    out.push_str("  Register-ScheduledTask -TaskName 'MyDailyTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1487    out.push_str("\nExample: Run at Windows startup\n");
1488    out.push_str("  $trigger = New-ScheduledTaskTrigger -AtStartup\n");
1489    out.push_str("  Register-ScheduledTask -TaskName 'MyStartupTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1490    out.push_str("\nExample: Run at user logon\n");
1491    out.push_str("  $trigger = New-ScheduledTaskTrigger -AtLogon\n");
1492    out.push_str(
1493        "  Register-ScheduledTask -TaskName 'MyLogonTask' -Action $action -Trigger $trigger\n",
1494    );
1495    out.push_str("\nExample: Run every 30 minutes\n");
1496    out.push_str("  $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 30) -Once -At (Get-Date)\n");
1497    out.push_str("\nView all tasks:\n");
1498    out.push_str("  Get-ScheduledTask | Select-Object TaskName,State | Sort-Object TaskName\n");
1499    out.push_str("\nDelete a task:\n");
1500    out.push_str("  Unregister-ScheduledTask -TaskName 'MyDailyTask' -Confirm:$false\n");
1501    out.push_str("\nRun a task immediately:\n");
1502    out.push_str("  Start-ScheduledTask -TaskName 'MyDailyTask'\n");
1503    out.push_str("\nVerification:\n");
1504    out.push_str("  Get-ScheduledTask -TaskName 'MyDailyTask' | Select-Object TaskName,State,LastRunTime,NextRunTime\n");
1505    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.");
1506    Ok(out.trim_end().to_string())
1507}
1508
1509fn inspect_disk_cleanup_fix_plan(issue: &str) -> Result<String, String> {
1510    #[cfg(target_os = "windows")]
1511    let disk_info = {
1512        Command::new("powershell")
1513            .args([
1514                "-NoProfile",
1515                "-NonInteractive",
1516                "-Command",
1517                "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\" }",
1518            ])
1519            .output()
1520            .ok()
1521            .and_then(|o| String::from_utf8(o.stdout).ok())
1522            .unwrap_or_default()
1523            .trim()
1524            .to_string()
1525    };
1526    #[cfg(not(target_os = "windows"))]
1527    let disk_info = String::new();
1528
1529    let mut out = String::from("Host inspection: fix_plan\n\n");
1530    out.push_str(&format!("- Requested issue: {}\n", issue));
1531    out.push_str("- Fix-plan type: disk_cleanup\n");
1532    if !disk_info.is_empty() {
1533        out.push_str(&format!("\nCurrent drive usage:\n{}\n", disk_info));
1534    }
1535    out.push_str("\nFix plan — Reclaiming disk space (ordered by impact):\n");
1536    out.push_str("\n1. Run Windows Disk Cleanup (built-in, GUI):\n");
1537    out.push_str("   cleanmgr /sageset:1    (configure what to clean)\n");
1538    out.push_str("   cleanmgr /sagerun:1    (run the cleanup)\n");
1539    out.push_str("   Tick 'Windows Update Cleanup' for the biggest reclaim (often 5-20 GB).\n");
1540    out.push_str("\n2. Clear the Windows Update cache (PowerShell, Admin):\n");
1541    out.push_str("   Stop-Service wuauserv\n");
1542    out.push_str("   Remove-Item C:\\Windows\\SoftwareDistribution\\Download\\* -Recurse -Force\n");
1543    out.push_str("   Start-Service wuauserv\n");
1544    out.push_str("\n3. Clear Windows Temp folder:\n");
1545    out.push_str("   Remove-Item $env:TEMP\\* -Recurse -Force -ErrorAction SilentlyContinue\n");
1546    out.push_str(
1547        "   Remove-Item C:\\Windows\\Temp\\* -Recurse -Force -ErrorAction SilentlyContinue\n",
1548    );
1549    out.push_str("\n4. Developer cache directories (often the biggest culprits):\n");
1550    out.push_str("   - Rust build artifacts: cargo clean  (inside each project)\n");
1551    out.push_str("   - npm cache:  npm cache clean --force\n");
1552    out.push_str("   - pip cache:  pip cache purge\n");
1553    out.push_str(
1554        "   - Docker:     docker system prune -a  (removes all unused images/containers)\n",
1555    );
1556    out.push_str("   - Cargo registry cache: Remove-Item ~\\.cargo\\registry -Recurse -Force  (will redownload on next build)\n");
1557    out.push_str("\n5. Check for large files:\n");
1558    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");
1559    out.push_str("\nVerification:\n");
1560    out.push_str(
1561        "  Get-PSDrive C | Select-Object @{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}}\n",
1562    );
1563    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.");
1564    Ok(out.trim_end().to_string())
1565}
1566
1567fn inspect_generic_fix_plan(issue: &str) -> Result<String, String> {
1568    let mut out = String::from("Host inspection: fix_plan\n\n");
1569    out.push_str(&format!("- Requested issue: {}\n", issue));
1570    out.push_str("- Fix-plan type: generic\n");
1571    out.push_str(
1572        "\nGuidance:\n- Use `fix_plan` with a descriptive issue string to get a grounded, machine-specific walkthrough.\n\
1573         Structured lanes available:\n\
1574         - PATH/toolchain drift (cargo, rustc, node, python, winget, choco, scoop)\n\
1575         - Port conflict (address already in use, what owns port)\n\
1576         - LM Studio connectivity (localhost:1234, no coding model loaded, embedding model)\n\
1577         - Driver install (GPU driver, nvidia driver, install driver, update driver)\n\
1578         - Group Policy (gpedit, local policy, administrative template)\n\
1579         - Firewall rule (inbound rule, outbound rule, open port, allow port, block port)\n\
1580         - SSH key (ssh-keygen, generate ssh, authorized_keys)\n\
1581         - WSL setup (wsl2, windows subsystem for linux, install ubuntu)\n\
1582         - Service config (start/stop/restart/enable/disable a service)\n\
1583         - Windows activation (product key, not activated, kms)\n\
1584         - Registry edit (regedit, reg add, hklm, hkcu, registry key)\n\
1585         - Scheduled task (task scheduler, schtasks, run on startup, cron)\n\
1586         - Disk cleanup (free up disk, clear cache, disk full, reclaim space)\n\
1587         - If your issue is outside these lanes, run the closest `inspect_host` topic first to ground the diagnosis.",
1588    );
1589    Ok(out.trim_end().to_string())
1590}
1591
1592fn inspect_resource_load() -> Result<String, String> {
1593    #[cfg(target_os = "windows")]
1594    {
1595        let output = Command::new("powershell")
1596            .args([
1597                "-NoProfile",
1598                "-Command",
1599                "(Get-CimInstance Win32_Processor).LoadPercentage; Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json -Compress",
1600            ])
1601            .output()
1602            .map_err(|e| format!("Failed to run powershell: {e}"))?;
1603
1604        let text = String::from_utf8_lossy(&output.stdout);
1605        let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
1606
1607        let cpu_load = lines
1608            .next()
1609            .and_then(|l| l.parse::<u32>().ok())
1610            .unwrap_or(0);
1611        let mem_json = lines.collect::<Vec<_>>().join("");
1612        let mem_val: Value = serde_json::from_str(&mem_json).unwrap_or(Value::Null);
1613
1614        let total_kb = mem_val["TotalVisibleMemorySize"].as_u64().unwrap_or(1);
1615        let free_kb = mem_val["FreePhysicalMemory"].as_u64().unwrap_or(0);
1616        let used_kb = total_kb.saturating_sub(free_kb);
1617        let mem_percent = if total_kb > 0 {
1618            (used_kb * 100) / total_kb
1619        } else {
1620            0
1621        };
1622
1623        let mut out = String::from("Host inspection: resource_load\n\n");
1624        out.push_str("**System Performance Summary:**\n");
1625        out.push_str(&format!("- CPU Load: {}%\n", cpu_load));
1626        out.push_str(&format!(
1627            "- Memory Usage: {} / {} ({}%)\n",
1628            human_bytes(used_kb * 1024),
1629            human_bytes(total_kb * 1024),
1630            mem_percent
1631        ));
1632
1633        if cpu_load > 85 {
1634            out.push_str("\n[Warning] CPU load is extremely high. System may be unresponsive.\n");
1635        }
1636        if mem_percent > 90 {
1637            out.push_str("\n[Warning] Memory usage is near capacity. Swap activity may slow down the machine.\n");
1638        }
1639
1640        Ok(out)
1641    }
1642    #[cfg(not(target_os = "windows"))]
1643    {
1644        Ok("Resource load inspection is not yet implemented for this platform.".to_string())
1645    }
1646}
1647
1648#[derive(Debug)]
1649enum EndpointProbe {
1650    Reachable(u16),
1651    Unreachable(String),
1652}
1653
1654async fn probe_http_endpoint(url: &str) -> EndpointProbe {
1655    let client = match reqwest::Client::builder()
1656        .timeout(std::time::Duration::from_secs(3))
1657        .build()
1658    {
1659        Ok(client) => client,
1660        Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1661    };
1662
1663    match client.get(url).send().await {
1664        Ok(resp) => EndpointProbe::Reachable(resp.status().as_u16()),
1665        Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1666    }
1667}
1668
1669async fn detect_loaded_embed_model(configured_api: &str) -> Option<String> {
1670    if configured_api.contains("11434") {
1671        let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1672        let url = format!("{}/api/ps", base);
1673        let client = reqwest::Client::builder()
1674            .timeout(std::time::Duration::from_secs(3))
1675            .build()
1676            .ok()?;
1677        let response = client.get(url).send().await.ok()?;
1678        let body = response.json::<serde_json::Value>().await.ok()?;
1679        let entries = body["models"].as_array()?;
1680        for entry in entries {
1681            let name = entry["name"]
1682                .as_str()
1683                .or_else(|| entry["model"].as_str())
1684                .unwrap_or_default();
1685            let lower = name.to_ascii_lowercase();
1686            if lower.contains("embed")
1687                || lower.contains("embedding")
1688                || lower.contains("minilm")
1689                || lower.contains("bge")
1690                || lower.contains("e5")
1691            {
1692                return Some(name.to_string());
1693            }
1694        }
1695        return None;
1696    }
1697
1698    let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1699    let url = format!("{}/api/v0/models", base);
1700    let client = reqwest::Client::builder()
1701        .timeout(std::time::Duration::from_secs(3))
1702        .build()
1703        .ok()?;
1704
1705    #[derive(serde::Deserialize)]
1706    struct ModelList {
1707        data: Vec<ModelEntry>,
1708    }
1709    #[derive(serde::Deserialize)]
1710    struct ModelEntry {
1711        id: String,
1712        #[serde(rename = "type", default)]
1713        model_type: String,
1714        #[serde(default)]
1715        state: String,
1716    }
1717
1718    let response = client.get(url).send().await.ok()?;
1719    let models = response.json::<ModelList>().await.ok()?;
1720    models
1721        .data
1722        .into_iter()
1723        .find(|model| model.model_type == "embeddings" && model.state == "loaded")
1724        .map(|model| model.id)
1725}
1726
1727fn first_port_in_text(text: &str) -> Option<u16> {
1728    text.split(|c: char| !c.is_ascii_digit())
1729        .find(|fragment| !fragment.is_empty())
1730        .and_then(|fragment| fragment.parse::<u16>().ok())
1731}
1732
1733fn inspect_processes(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1734    let mut processes = collect_processes()?;
1735    if let Some(filter) = name_filter.as_deref() {
1736        let lowered = filter.to_ascii_lowercase();
1737        processes.retain(|entry| entry.name.to_ascii_lowercase().contains(&lowered));
1738    }
1739    processes.sort_by(|a, b| {
1740        let a_cpu = a.cpu_percent.unwrap_or(0.0);
1741        let b_cpu = b.cpu_percent.unwrap_or(0.0);
1742        b_cpu
1743            .partial_cmp(&a_cpu)
1744            .unwrap_or(std::cmp::Ordering::Equal)
1745            .then_with(|| b.memory_bytes.cmp(&a.memory_bytes))
1746            .then_with(|| a.name.cmp(&b.name))
1747            .then_with(|| a.pid.cmp(&b.pid))
1748    });
1749
1750    let total_memory: u64 = processes.iter().map(|entry| entry.memory_bytes).sum();
1751
1752    let mut out = String::from("Host inspection: processes\n\n");
1753    if let Some(filter) = name_filter.as_deref() {
1754        out.push_str(&format!("- Filter name: {}\n", filter));
1755    }
1756    out.push_str(&format!("- Processes found: {}\n", processes.len()));
1757    out.push_str(&format!(
1758        "- Total reported working set: {}\n",
1759        human_bytes(total_memory)
1760    ));
1761
1762    if processes.is_empty() {
1763        out.push_str("\nNo running processes matched.");
1764        return Ok(out);
1765    }
1766
1767    out.push_str("\nTop processes by resource usage:\n");
1768    for entry in processes.iter().take(max_entries) {
1769        let cpu_str = entry
1770            .cpu_percent
1771            .map(|p| format!(" [CPU: {:.1}%]", p))
1772            .or_else(|| entry.cpu_seconds.map(|s| format!(" [CPU: {:.1}s]", s)))
1773            .unwrap_or_default();
1774        let io_str = if let (Some(r), Some(w)) = (entry.read_ops, entry.write_ops) {
1775            format!(" [I/O R:{}/W:{}]", r, w)
1776        } else {
1777            " [I/O unknown]".to_string()
1778        };
1779        out.push_str(&format!(
1780            "- {} (pid {}) - {}{}{}{}\n",
1781            entry.name,
1782            entry.pid,
1783            human_bytes(entry.memory_bytes),
1784            cpu_str,
1785            io_str,
1786            entry
1787                .detail
1788                .as_deref()
1789                .map(|detail| format!(" [{}]", detail))
1790                .unwrap_or_default()
1791        ));
1792    }
1793    if processes.len() > max_entries {
1794        out.push_str(&format!(
1795            "- ... {} more processes omitted\n",
1796            processes.len() - max_entries
1797        ));
1798    }
1799
1800    Ok(out.trim_end().to_string())
1801}
1802
1803fn inspect_network(max_entries: usize) -> Result<String, String> {
1804    let adapters = collect_network_adapters()?;
1805    let active_count = adapters
1806        .iter()
1807        .filter(|adapter| adapter.is_active())
1808        .count();
1809    let exposure = listener_exposure_summary(collect_listening_ports().ok().unwrap_or_default());
1810
1811    let mut out = String::from("Host inspection: network\n\n");
1812    out.push_str(&format!("- Adapters found: {}\n", adapters.len()));
1813    out.push_str(&format!("- Active adapters: {}\n", active_count));
1814    out.push_str(&format!(
1815        "- Listener exposure: {} loopback-only, {} wildcard/public, {} specific-bind\n",
1816        exposure.loopback_only, exposure.wildcard_public, exposure.specific_bind
1817    ));
1818
1819    if adapters.is_empty() {
1820        out.push_str("\nNo adapter details were detected.");
1821        return Ok(out);
1822    }
1823
1824    out.push_str("\nAdapter summary:\n");
1825    for adapter in adapters.iter().take(max_entries) {
1826        let status = if adapter.is_active() {
1827            "active"
1828        } else if adapter.disconnected {
1829            "disconnected"
1830        } else {
1831            "idle"
1832        };
1833        let mut details = vec![status.to_string()];
1834        if !adapter.ipv4.is_empty() {
1835            details.push(format!("ipv4 {}", adapter.ipv4.join(", ")));
1836        }
1837        if !adapter.ipv6.is_empty() {
1838            details.push(format!("ipv6 {}", adapter.ipv6.join(", ")));
1839        }
1840        if !adapter.gateways.is_empty() {
1841            details.push(format!("gateway {}", adapter.gateways.join(", ")));
1842        }
1843        if !adapter.dns_servers.is_empty() {
1844            details.push(format!("dns {}", adapter.dns_servers.join(", ")));
1845        }
1846        out.push_str(&format!("- {} - {}\n", adapter.name, details.join(" | ")));
1847    }
1848    if adapters.len() > max_entries {
1849        out.push_str(&format!(
1850            "- ... {} more adapters omitted\n",
1851            adapters.len() - max_entries
1852        ));
1853    }
1854
1855    Ok(out.trim_end().to_string())
1856}
1857
1858fn inspect_lan_discovery(max_entries: usize) -> Result<String, String> {
1859    let mut out = String::from("Host inspection: lan_discovery\n\n");
1860
1861    #[cfg(target_os = "windows")]
1862    {
1863        let n = max_entries.clamp(5, 20);
1864        let adapters = collect_network_adapters()?;
1865        let services = collect_services().unwrap_or_default();
1866        let active_adapters: Vec<&NetworkAdapter> = adapters
1867            .iter()
1868            .filter(|adapter| adapter.is_active())
1869            .collect();
1870        let gateways: Vec<String> = active_adapters
1871            .iter()
1872            .flat_map(|adapter| adapter.gateways.clone())
1873            .collect::<HashSet<_>>()
1874            .into_iter()
1875            .collect();
1876
1877        let neighbor_script = r#"
1878$neighbors = Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue |
1879    Where-Object {
1880        $_.IPAddress -notlike '127.*' -and
1881        $_.IPAddress -notlike '169.254*' -and
1882        $_.State -notin @('Unreachable','Invalid')
1883    } |
1884    Select-Object IPAddress, LinkLayerAddress, State, InterfaceAlias
1885$neighbors | ConvertTo-Json -Compress
1886"#;
1887        let neighbor_text = Command::new("powershell")
1888            .args(["-NoProfile", "-NonInteractive", "-Command", neighbor_script])
1889            .output()
1890            .ok()
1891            .and_then(|o| String::from_utf8(o.stdout).ok())
1892            .unwrap_or_default();
1893        let neighbors: Vec<(String, String, String, String)> = parse_lan_neighbors(&neighbor_text)
1894            .into_iter()
1895            .take(n)
1896            .collect();
1897
1898        let listener_script = r#"
1899Get-NetUDPEndpoint -ErrorAction SilentlyContinue |
1900    Where-Object { $_.LocalPort -in 137,138,1900,5353,5355 } |
1901    Select-Object LocalAddress, LocalPort, OwningProcess |
1902    ForEach-Object {
1903        $proc = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Name
1904        "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$proc"
1905    }
1906"#;
1907        let listener_text = Command::new("powershell")
1908            .args(["-NoProfile", "-NonInteractive", "-Command", listener_script])
1909            .output()
1910            .ok()
1911            .and_then(|o| String::from_utf8(o.stdout).ok())
1912            .unwrap_or_default();
1913        let listeners: Vec<(String, u16, String, String)> = listener_text
1914            .lines()
1915            .filter_map(|line| {
1916                let parts: Vec<&str> = line.trim().split('|').collect();
1917                if parts.len() < 4 {
1918                    return None;
1919                }
1920                Some((
1921                    parts[0].to_string(),
1922                    parts[1].parse::<u16>().ok()?,
1923                    parts[2].to_string(),
1924                    parts[3].to_string(),
1925                ))
1926            })
1927            .take(n)
1928            .collect();
1929
1930        let smb_mapping_script = r#"
1931Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } |
1932    ForEach-Object { "$($_.Name):|$($_.DisplayRoot)" }
1933"#;
1934        let smb_mappings: Vec<String> = Command::new("powershell")
1935            .args([
1936                "-NoProfile",
1937                "-NonInteractive",
1938                "-Command",
1939                smb_mapping_script,
1940            ])
1941            .output()
1942            .ok()
1943            .and_then(|o| String::from_utf8(o.stdout).ok())
1944            .unwrap_or_default()
1945            .lines()
1946            .take(n)
1947            .map(|line| line.trim().to_string())
1948            .filter(|line| !line.is_empty())
1949            .collect();
1950
1951        let smb_connections_script = r#"
1952Get-SmbConnection -ErrorAction SilentlyContinue |
1953    Select-Object ServerName, ShareName, NumOpens |
1954    ForEach-Object { "$($_.ServerName)|$($_.ShareName)|$($_.NumOpens)" }
1955"#;
1956        let smb_connections: Vec<String> = Command::new("powershell")
1957            .args([
1958                "-NoProfile",
1959                "-NonInteractive",
1960                "-Command",
1961                smb_connections_script,
1962            ])
1963            .output()
1964            .ok()
1965            .and_then(|o| String::from_utf8(o.stdout).ok())
1966            .unwrap_or_default()
1967            .lines()
1968            .take(n)
1969            .map(|line| line.trim().to_string())
1970            .filter(|line| !line.is_empty())
1971            .collect();
1972
1973        let discovery_service_names = [
1974            "FDResPub",
1975            "fdPHost",
1976            "SSDPSRV",
1977            "upnphost",
1978            "LanmanServer",
1979            "LanmanWorkstation",
1980            "lmhosts",
1981        ];
1982        let discovery_services: Vec<&ServiceEntry> = services
1983            .iter()
1984            .filter(|entry| {
1985                discovery_service_names
1986                    .iter()
1987                    .any(|name| entry.name.eq_ignore_ascii_case(name))
1988            })
1989            .collect();
1990
1991        let mut findings = Vec::new();
1992        if active_adapters.is_empty() {
1993            findings.push(AuditFinding {
1994                finding: "No active LAN adapters were detected.".to_string(),
1995                impact: "Neighborhood, SMB, mDNS, SSDP, and printer/NAS discovery cannot work without an active local interface.".to_string(),
1996                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(),
1997            });
1998        }
1999
2000        let stopped_discovery_services: Vec<&ServiceEntry> = discovery_services
2001            .iter()
2002            .copied()
2003            .filter(|entry| {
2004                !entry.status.eq_ignore_ascii_case("running")
2005                    && !entry.status.eq_ignore_ascii_case("active")
2006            })
2007            .collect();
2008        if !stopped_discovery_services.is_empty() {
2009            let names = stopped_discovery_services
2010                .iter()
2011                .map(|entry| entry.name.as_str())
2012                .collect::<Vec<_>>()
2013                .join(", ");
2014            findings.push(AuditFinding {
2015                finding: format!("Discovery-related services are not running: {names}"),
2016                impact: "Windows network neighborhood visibility, SSDP/UPnP discovery, or SMB browse behavior can look broken even when the network itself is fine.".to_string(),
2017                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(),
2018            });
2019        }
2020
2021        if listeners.is_empty() {
2022            findings.push(AuditFinding {
2023                finding: "No discovery-oriented UDP listeners were found on 137, 138, 1900, 5353, or 5355.".to_string(),
2024                impact: "NetBIOS, SSDP/UPnP, mDNS, and LLMNR discovery may be inactive on this host, so other devices may not see it automatically.".to_string(),
2025                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(),
2026            });
2027        }
2028
2029        if !active_adapters.is_empty() && neighbors.len() <= gateways.len() {
2030            findings.push(AuditFinding {
2031                finding: "Very little neighborhood evidence was observed beyond the default gateway.".to_string(),
2032                impact: "That often means discovery traffic is quiet, the LAN is isolated, or peer devices are not advertising themselves.".to_string(),
2033                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(),
2034            });
2035        }
2036
2037        out.push_str("=== Findings ===\n");
2038        if findings.is_empty() {
2039            out.push_str(
2040                "- Finding: LAN discovery signals look healthy from this inspection pass.\n",
2041            );
2042            out.push_str("  Impact: Neighborhood visibility, SMB browsing, and SSDP/mDNS discovery do not show an obvious host-side blocker.\n");
2043            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");
2044        } else {
2045            for finding in &findings {
2046                out.push_str(&format!("- Finding: {}\n", finding.finding));
2047                out.push_str(&format!("  Impact: {}\n", finding.impact));
2048                out.push_str(&format!("  Fix: {}\n", finding.fix));
2049            }
2050        }
2051
2052        out.push_str("\n=== Active adapter and gateway summary ===\n");
2053        if active_adapters.is_empty() {
2054            out.push_str("- No active adapters detected.\n");
2055        } else {
2056            for adapter in active_adapters.iter().take(n) {
2057                let ipv4 = if adapter.ipv4.is_empty() {
2058                    "no IPv4".to_string()
2059                } else {
2060                    adapter.ipv4.join(", ")
2061                };
2062                let gateway = if adapter.gateways.is_empty() {
2063                    "no gateway".to_string()
2064                } else {
2065                    adapter.gateways.join(", ")
2066                };
2067                out.push_str(&format!(
2068                    "- {} | IPv4: {} | Gateway: {}\n",
2069                    adapter.name, ipv4, gateway
2070                ));
2071            }
2072        }
2073
2074        out.push_str("\n=== Neighborhood evidence ===\n");
2075        out.push_str(&format!("- Gateway count: {}\n", gateways.len()));
2076        out.push_str(&format!(
2077            "- Neighbor entries observed: {}\n",
2078            neighbors.len()
2079        ));
2080        if neighbors.is_empty() {
2081            out.push_str("- No ARP/neighbor evidence retrieved.\n");
2082        } else {
2083            for (ip, mac, state, iface) in neighbors.iter().take(n) {
2084                out.push_str(&format!(
2085                    "- {} on {} | MAC: {} | State: {}\n",
2086                    ip, iface, mac, state
2087                ));
2088            }
2089        }
2090
2091        out.push_str("\n=== Discovery services ===\n");
2092        if discovery_services.is_empty() {
2093            out.push_str("- Discovery service status unavailable.\n");
2094        } else {
2095            for entry in discovery_services.iter().take(n) {
2096                let startup = entry.startup.as_deref().unwrap_or("unknown");
2097                out.push_str(&format!(
2098                    "- {} | Status: {} | Startup: {}\n",
2099                    entry.name, entry.status, startup
2100                ));
2101            }
2102        }
2103
2104        out.push_str("\n=== Discovery listener surface ===\n");
2105        if listeners.is_empty() {
2106            out.push_str("- No discovery-oriented UDP listeners detected.\n");
2107        } else {
2108            for (addr, port, pid, proc_name) in listeners.iter().take(n) {
2109                let label = match *port {
2110                    137 => "NetBIOS Name Service",
2111                    138 => "NetBIOS Datagram",
2112                    1900 => "SSDP/UPnP",
2113                    5353 => "mDNS",
2114                    5355 => "LLMNR",
2115                    _ => "Discovery",
2116                };
2117                let proc_label = if proc_name.is_empty() {
2118                    "unknown".to_string()
2119                } else {
2120                    proc_name.clone()
2121                };
2122                out.push_str(&format!(
2123                    "- {}:{} | {} | PID {} ({})\n",
2124                    addr, port, label, pid, proc_label
2125                ));
2126            }
2127        }
2128
2129        out.push_str("\n=== SMB and neighborhood visibility ===\n");
2130        if smb_mappings.is_empty() && smb_connections.is_empty() {
2131            out.push_str("- No mapped SMB drives or active SMB connections detected.\n");
2132        } else {
2133            if !smb_mappings.is_empty() {
2134                out.push_str("- Mapped drives:\n");
2135                for mapping in smb_mappings.iter().take(n) {
2136                    let parts: Vec<&str> = mapping.split('|').collect();
2137                    if parts.len() >= 2 {
2138                        out.push_str(&format!("  - {} -> {}\n", parts[0], parts[1]));
2139                    }
2140                }
2141            }
2142            if !smb_connections.is_empty() {
2143                out.push_str("- Active SMB connections:\n");
2144                for connection in smb_connections.iter().take(n) {
2145                    let parts: Vec<&str> = connection.split('|').collect();
2146                    if parts.len() >= 3 {
2147                        out.push_str(&format!(
2148                            "  - {}\\{} | Opens: {}\n",
2149                            parts[0], parts[1], parts[2]
2150                        ));
2151                    }
2152                }
2153            }
2154        }
2155    }
2156
2157    #[cfg(not(target_os = "windows"))]
2158    {
2159        let n = max_entries.clamp(5, 20);
2160        let adapters = collect_network_adapters()?;
2161        let arp_output = Command::new("ip")
2162            .args(["neigh"])
2163            .output()
2164            .ok()
2165            .and_then(|o| String::from_utf8(o.stdout).ok())
2166            .unwrap_or_default();
2167        let neighbors: Vec<&str> = arp_output
2168            .lines()
2169            .filter(|line| !line.trim().is_empty())
2170            .take(n)
2171            .collect();
2172
2173        out.push_str("=== Findings ===\n");
2174        if adapters.iter().any(|adapter| adapter.is_active()) {
2175            out.push_str(
2176                "- Finding: LAN discovery support is partially available on this platform.\n",
2177            );
2178            out.push_str("  Impact: Adapter and neighbor evidence can be inspected, but mDNS/UPnP coverage depends on local tools and services like Avahi.\n");
2179            out.push_str("  Fix: If discovery is failing, inspect Avahi/systemd-resolved, local firewall rules, and `udp_ports` next.\n");
2180        } else {
2181            out.push_str("- Finding: No active LAN adapters were detected.\n");
2182            out.push_str(
2183                "  Impact: Neighborhood discovery cannot work without an active interface.\n",
2184            );
2185            out.push_str("  Fix: Bring up Wi-Fi or Ethernet first, then rerun LAN discovery.\n");
2186        }
2187
2188        out.push_str("\n=== Active adapter and gateway summary ===\n");
2189        if adapters.is_empty() {
2190            out.push_str("- No adapters detected.\n");
2191        } else {
2192            for adapter in adapters.iter().take(n) {
2193                let ipv4 = if adapter.ipv4.is_empty() {
2194                    "no IPv4".to_string()
2195                } else {
2196                    adapter.ipv4.join(", ")
2197                };
2198                let gateway = if adapter.gateways.is_empty() {
2199                    "no gateway".to_string()
2200                } else {
2201                    adapter.gateways.join(", ")
2202                };
2203                out.push_str(&format!(
2204                    "- {} | IPv4: {} | Gateway: {}\n",
2205                    adapter.name, ipv4, gateway
2206                ));
2207            }
2208        }
2209
2210        out.push_str("\n=== Neighborhood evidence ===\n");
2211        if neighbors.is_empty() {
2212            out.push_str("- No neighbor entries detected.\n");
2213        } else {
2214            for line in neighbors {
2215                out.push_str(&format!("- {}\n", line.trim()));
2216            }
2217        }
2218    }
2219
2220    Ok(out.trim_end().to_string())
2221}
2222
2223fn inspect_services(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
2224    let mut services = collect_services()?;
2225    if let Some(filter) = name_filter.as_deref() {
2226        let lowered = filter.to_ascii_lowercase();
2227        services.retain(|entry| {
2228            entry.name.to_ascii_lowercase().contains(&lowered)
2229                || entry
2230                    .display_name
2231                    .as_deref()
2232                    .map(|d| d.to_ascii_lowercase().contains(&lowered))
2233                    .unwrap_or(false)
2234        });
2235    }
2236
2237    services.sort_by(|a, b| {
2238        let a_running =
2239            a.status.to_ascii_lowercase() == "running" || a.status.to_ascii_lowercase() == "active";
2240        let b_running =
2241            b.status.to_ascii_lowercase() == "running" || b.status.to_ascii_lowercase() == "active";
2242        b_running.cmp(&a_running).then_with(|| a.name.cmp(&b.name))
2243    });
2244
2245    let running = services
2246        .iter()
2247        .filter(|entry| {
2248            entry.status.eq_ignore_ascii_case("running")
2249                || entry.status.eq_ignore_ascii_case("active")
2250        })
2251        .count();
2252    let failed = services
2253        .iter()
2254        .filter(|entry| {
2255            entry.status.eq_ignore_ascii_case("failed")
2256                || entry.status.eq_ignore_ascii_case("error")
2257                || entry.status.eq_ignore_ascii_case("stopped")
2258        })
2259        .count();
2260
2261    let mut out = String::from("Host inspection: services\n\n");
2262    if let Some(filter) = name_filter.as_deref() {
2263        out.push_str(&format!("- Filter name: {}\n", filter));
2264    }
2265    out.push_str(&format!("- Services found: {}\n", services.len()));
2266    out.push_str(&format!("- Running/active: {}\n", running));
2267    out.push_str(&format!("- Failed/stopped: {}\n", failed));
2268
2269    if services.is_empty() {
2270        out.push_str("\nNo services matched.");
2271        return Ok(out);
2272    }
2273
2274    // Split into running and stopped sections so both are always visible.
2275    let per_section = (max_entries / 2).max(5);
2276
2277    let running_services: Vec<_> = services
2278        .iter()
2279        .filter(|e| {
2280            e.status.eq_ignore_ascii_case("running") || e.status.eq_ignore_ascii_case("active")
2281        })
2282        .collect();
2283    let stopped_services: Vec<_> = services
2284        .iter()
2285        .filter(|e| {
2286            e.status.eq_ignore_ascii_case("stopped")
2287                || e.status.eq_ignore_ascii_case("failed")
2288                || e.status.eq_ignore_ascii_case("error")
2289        })
2290        .collect();
2291
2292    let fmt_entry = |entry: &&ServiceEntry| {
2293        let startup = entry
2294            .startup
2295            .as_deref()
2296            .map(|v| format!(" | startup {}", v))
2297            .unwrap_or_default();
2298        let logon = entry
2299            .start_name
2300            .as_deref()
2301            .map(|v| format!(" | LogOn: {}", v))
2302            .unwrap_or_default();
2303        let display = entry
2304            .display_name
2305            .as_deref()
2306            .filter(|v| *v != &entry.name)
2307            .map(|v| format!(" [{}]", v))
2308            .unwrap_or_default();
2309        format!(
2310            "- {}{} - {}{}{}\n",
2311            entry.name, display, entry.status, startup, logon
2312        )
2313    };
2314
2315    out.push_str(&format!(
2316        "\nRunning services ({} total, showing up to {}):\n",
2317        running_services.len(),
2318        per_section
2319    ));
2320    for entry in running_services.iter().take(per_section) {
2321        out.push_str(&fmt_entry(entry));
2322    }
2323    if running_services.len() > per_section {
2324        out.push_str(&format!(
2325            "- ... {} more running services omitted\n",
2326            running_services.len() - per_section
2327        ));
2328    }
2329
2330    out.push_str(&format!(
2331        "\nStopped/failed services ({} total, showing up to {}):\n",
2332        stopped_services.len(),
2333        per_section
2334    ));
2335    for entry in stopped_services.iter().take(per_section) {
2336        out.push_str(&fmt_entry(entry));
2337    }
2338    if stopped_services.len() > per_section {
2339        out.push_str(&format!(
2340            "- ... {} more stopped services omitted\n",
2341            stopped_services.len() - per_section
2342        ));
2343    }
2344
2345    Ok(out.trim_end().to_string())
2346}
2347
2348async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
2349    inspect_directory("Disk", path, max_entries).await
2350}
2351
2352fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
2353    let mut listeners = collect_listening_ports()?;
2354    if let Some(port) = port_filter {
2355        listeners.retain(|entry| entry.port == port);
2356    }
2357    listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
2358
2359    let mut out = String::from("Host inspection: ports\n\n");
2360    if let Some(port) = port_filter {
2361        out.push_str(&format!("- Filter port: {}\n", port));
2362    }
2363    out.push_str(&format!(
2364        "- Listening endpoints found: {}\n",
2365        listeners.len()
2366    ));
2367
2368    if listeners.is_empty() {
2369        out.push_str("\nNo listening endpoints matched.");
2370        return Ok(out);
2371    }
2372
2373    out.push_str("\nListening endpoints:\n");
2374    for entry in listeners.iter().take(max_entries) {
2375        let pid_str = entry
2376            .pid
2377            .as_deref()
2378            .map(|p| format!(" pid {}", p))
2379            .unwrap_or_default();
2380        let name_str = entry
2381            .process_name
2382            .as_deref()
2383            .map(|n| format!(" [{}]", n))
2384            .unwrap_or_default();
2385        out.push_str(&format!(
2386            "- {} {} ({}){}{}\n",
2387            entry.protocol, entry.local, entry.state, pid_str, name_str
2388        ));
2389    }
2390    if listeners.len() > max_entries {
2391        out.push_str(&format!(
2392            "- ... {} more listening endpoints omitted\n",
2393            listeners.len() - max_entries
2394        ));
2395    }
2396
2397    Ok(out.trim_end().to_string())
2398}
2399
2400fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
2401    if !path.exists() {
2402        return Err(format!("Path does not exist: {}", path.display()));
2403    }
2404    if !path.is_dir() {
2405        return Err(format!("Path is not a directory: {}", path.display()));
2406    }
2407
2408    let markers = collect_project_markers(&path);
2409    let hematite_state = collect_hematite_state(&path);
2410    let git_state = inspect_git_state(&path);
2411    let release_state = inspect_release_artifacts(&path);
2412
2413    let mut out = String::from("Host inspection: repo_doctor\n\n");
2414    out.push_str(&format!("- Path: {}\n", path.display()));
2415    out.push_str(&format!(
2416        "- Workspace mode: {}\n",
2417        workspace_mode_for_path(&path)
2418    ));
2419
2420    if markers.is_empty() {
2421        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");
2422    } else {
2423        out.push_str("- Project markers:\n");
2424        for marker in markers.iter().take(max_entries) {
2425            out.push_str(&format!("  - {}\n", marker));
2426        }
2427    }
2428
2429    match git_state {
2430        Some(git) => {
2431            out.push_str(&format!("- Git root: {}\n", git.root.display()));
2432            out.push_str(&format!("- Git branch: {}\n", git.branch));
2433            out.push_str(&format!("- Git status: {}\n", git.status_label()));
2434        }
2435        None => out.push_str("- Git: not inside a detected work tree\n"),
2436    }
2437
2438    out.push_str(&format!(
2439        "- Hematite docs/imports/reports: {}/{}/{}\n",
2440        hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
2441    ));
2442    if hematite_state.workspace_profile {
2443        out.push_str("- Workspace profile: present\n");
2444    } else {
2445        out.push_str("- Workspace profile: absent\n");
2446    }
2447
2448    if let Some(release) = release_state {
2449        out.push_str(&format!("- Cargo version: {}\n", release.version));
2450        out.push_str(&format!(
2451            "- Windows artifacts for current version: {}/{}/{}\n",
2452            bool_label(release.portable_dir),
2453            bool_label(release.portable_zip),
2454            bool_label(release.setup_exe)
2455        ));
2456    }
2457
2458    Ok(out.trim_end().to_string())
2459}
2460
2461async fn inspect_known_directory(
2462    label: &str,
2463    path: Option<PathBuf>,
2464    max_entries: usize,
2465) -> Result<String, String> {
2466    let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
2467    inspect_directory(label, path, max_entries).await
2468}
2469
2470async fn inspect_directory(
2471    label: &str,
2472    path: PathBuf,
2473    max_entries: usize,
2474) -> Result<String, String> {
2475    let label = label.to_string();
2476    tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
2477        .await
2478        .map_err(|e| format!("inspect_host task failed: {e}"))?
2479}
2480
2481fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
2482    if !path.exists() {
2483        return Err(format!("Path does not exist: {}", path.display()));
2484    }
2485    if !path.is_dir() {
2486        return Err(format!("Path is not a directory: {}", path.display()));
2487    }
2488
2489    let mut top_level_entries = Vec::new();
2490    for entry in fs::read_dir(path)
2491        .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
2492    {
2493        match entry {
2494            Ok(entry) => top_level_entries.push(entry),
2495            Err(_) => continue,
2496        }
2497    }
2498    top_level_entries.sort_by_key(|entry| entry.file_name());
2499
2500    let top_level_count = top_level_entries.len();
2501    let mut sample_names = Vec::new();
2502    let mut largest_entries = Vec::new();
2503    let mut aggregate = PathAggregate::default();
2504    let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
2505
2506    for entry in top_level_entries {
2507        let name = entry.file_name().to_string_lossy().to_string();
2508        if sample_names.len() < max_entries {
2509            sample_names.push(name.clone());
2510        }
2511        let kind = match entry.file_type() {
2512            Ok(ft) if ft.is_dir() => "dir",
2513            Ok(ft) if ft.is_symlink() => "symlink",
2514            _ => "file",
2515        };
2516        let stats = measure_path(&entry.path(), &mut budget);
2517        aggregate.merge(&stats);
2518        largest_entries.push(LargestEntry {
2519            name,
2520            kind,
2521            bytes: stats.total_bytes,
2522        });
2523    }
2524
2525    largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
2526
2527    let mut out = format!("Directory inspection: {}\n\n", label);
2528    out.push_str(&format!("- Path: {}\n", path.display()));
2529    out.push_str(&format!("- Top-level items: {}\n", top_level_count));
2530    out.push_str(&format!("- Recursive files: {}\n", aggregate.file_count));
2531    out.push_str(&format!(
2532        "- Recursive directories: {}\n",
2533        aggregate.dir_count
2534    ));
2535    out.push_str(&format!(
2536        "- Total size: {}{}\n",
2537        human_bytes(aggregate.total_bytes),
2538        if aggregate.partial {
2539            " (partial scan)"
2540        } else {
2541            ""
2542        }
2543    ));
2544    if aggregate.skipped_entries > 0 {
2545        out.push_str(&format!(
2546            "- Skipped entries: {} (permissions, symlinks, or scan budget)\n",
2547            aggregate.skipped_entries
2548        ));
2549    }
2550
2551    if !largest_entries.is_empty() {
2552        out.push_str("\nLargest top-level entries:\n");
2553        for entry in largest_entries.iter().take(max_entries) {
2554            out.push_str(&format!(
2555                "- {} [{}] - {}\n",
2556                entry.name,
2557                entry.kind,
2558                human_bytes(entry.bytes)
2559            ));
2560        }
2561    }
2562
2563    if !sample_names.is_empty() {
2564        out.push_str("\nSample names:\n");
2565        for name in sample_names {
2566            out.push_str(&format!("- {}\n", name));
2567        }
2568    }
2569
2570    Ok(out.trim_end().to_string())
2571}
2572
2573fn resolve_path(raw: &str) -> Result<PathBuf, String> {
2574    let trimmed = raw.trim();
2575    if trimmed.is_empty() {
2576        return Err("Path must not be empty.".to_string());
2577    }
2578
2579    if let Some(rest) = trimmed
2580        .strip_prefix("~/")
2581        .or_else(|| trimmed.strip_prefix("~\\"))
2582    {
2583        let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
2584        return Ok(home.join(rest));
2585    }
2586
2587    let path = PathBuf::from(trimmed);
2588    if path.is_absolute() {
2589        Ok(path)
2590    } else {
2591        let cwd =
2592            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
2593        let full_path = cwd.join(&path);
2594
2595        // Heuristic: If it's a relative path to .hematite or hematite.exe and doesn't exist here,
2596        // check the user's home directory.
2597        if !full_path.exists()
2598            && (trimmed.starts_with(".hematite") || trimmed.starts_with("hematite.exe"))
2599        {
2600            if let Some(home) = home::home_dir() {
2601                let home_path = home.join(trimmed);
2602                if home_path.exists() {
2603                    return Ok(home_path);
2604                }
2605            }
2606        }
2607
2608        Ok(full_path)
2609    }
2610}
2611
2612fn workspace_mode_label(workspace_root: &Path) -> &'static str {
2613    workspace_mode_for_path(workspace_root)
2614}
2615
2616fn workspace_mode_for_path(path: &Path) -> &'static str {
2617    if is_project_marker_path(path) {
2618        "project"
2619    } else if path.join(".hematite").join("docs").exists()
2620        || path.join(".hematite").join("imports").exists()
2621        || path.join(".hematite").join("reports").exists()
2622    {
2623        "docs-only"
2624    } else {
2625        "general directory"
2626    }
2627}
2628
2629fn is_project_marker_path(path: &Path) -> bool {
2630    [
2631        "Cargo.toml",
2632        "package.json",
2633        "pyproject.toml",
2634        "go.mod",
2635        "composer.json",
2636        "requirements.txt",
2637        "Makefile",
2638        "justfile",
2639    ]
2640    .iter()
2641    .any(|name| path.join(name).exists())
2642        || path.join(".git").exists()
2643}
2644
2645fn preferred_shell_label() -> &'static str {
2646    #[cfg(target_os = "windows")]
2647    {
2648        "PowerShell"
2649    }
2650    #[cfg(not(target_os = "windows"))]
2651    {
2652        "sh"
2653    }
2654}
2655
2656fn desktop_dir() -> Option<PathBuf> {
2657    home::home_dir().map(|home| home.join("Desktop"))
2658}
2659
2660fn downloads_dir() -> Option<PathBuf> {
2661    home::home_dir().map(|home| home.join("Downloads"))
2662}
2663
2664fn count_top_level_items(path: &Path) -> Result<usize, String> {
2665    let mut count = 0usize;
2666    for entry in
2667        fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
2668    {
2669        if entry.is_ok() {
2670            count += 1;
2671        }
2672    }
2673    Ok(count)
2674}
2675
2676#[derive(Default)]
2677struct PathAggregate {
2678    total_bytes: u64,
2679    file_count: u64,
2680    dir_count: u64,
2681    skipped_entries: u64,
2682    partial: bool,
2683}
2684
2685impl PathAggregate {
2686    fn merge(&mut self, other: &PathAggregate) {
2687        self.total_bytes += other.total_bytes;
2688        self.file_count += other.file_count;
2689        self.dir_count += other.dir_count;
2690        self.skipped_entries += other.skipped_entries;
2691        self.partial |= other.partial;
2692    }
2693}
2694
2695struct LargestEntry {
2696    name: String,
2697    kind: &'static str,
2698    bytes: u64,
2699}
2700
2701fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
2702    if *budget == 0 {
2703        return PathAggregate {
2704            partial: true,
2705            skipped_entries: 1,
2706            ..PathAggregate::default()
2707        };
2708    }
2709    *budget -= 1;
2710
2711    let metadata = match fs::symlink_metadata(path) {
2712        Ok(metadata) => metadata,
2713        Err(_) => {
2714            return PathAggregate {
2715                skipped_entries: 1,
2716                ..PathAggregate::default()
2717            }
2718        }
2719    };
2720
2721    let file_type = metadata.file_type();
2722    if file_type.is_symlink() {
2723        return PathAggregate {
2724            skipped_entries: 1,
2725            ..PathAggregate::default()
2726        };
2727    }
2728
2729    if metadata.is_file() {
2730        return PathAggregate {
2731            total_bytes: metadata.len(),
2732            file_count: 1,
2733            ..PathAggregate::default()
2734        };
2735    }
2736
2737    if !metadata.is_dir() {
2738        return PathAggregate::default();
2739    }
2740
2741    let mut aggregate = PathAggregate {
2742        dir_count: 1,
2743        ..PathAggregate::default()
2744    };
2745
2746    let read_dir = match fs::read_dir(path) {
2747        Ok(read_dir) => read_dir,
2748        Err(_) => {
2749            aggregate.skipped_entries += 1;
2750            return aggregate;
2751        }
2752    };
2753
2754    for child in read_dir {
2755        match child {
2756            Ok(child) => {
2757                let child_stats = measure_path(&child.path(), budget);
2758                aggregate.merge(&child_stats);
2759            }
2760            Err(_) => aggregate.skipped_entries += 1,
2761        }
2762    }
2763
2764    aggregate
2765}
2766
2767struct PathAnalysis {
2768    total_entries: usize,
2769    unique_entries: usize,
2770    entries: Vec<String>,
2771    duplicate_entries: Vec<String>,
2772    missing_entries: Vec<String>,
2773}
2774
2775fn analyze_path_env() -> PathAnalysis {
2776    let mut entries = Vec::new();
2777    let mut duplicate_entries = Vec::new();
2778    let mut missing_entries = Vec::new();
2779    let mut seen = HashSet::new();
2780
2781    let raw_path = std::env::var_os("PATH").unwrap_or_default();
2782    for path in std::env::split_paths(&raw_path) {
2783        let display = path.display().to_string();
2784        if display.trim().is_empty() {
2785            continue;
2786        }
2787
2788        let normalized = normalize_path_entry(&display);
2789        if !seen.insert(normalized) {
2790            duplicate_entries.push(display.clone());
2791        }
2792        if !path.exists() {
2793            missing_entries.push(display.clone());
2794        }
2795        entries.push(display);
2796    }
2797
2798    let total_entries = entries.len();
2799    let unique_entries = seen.len();
2800
2801    PathAnalysis {
2802        total_entries,
2803        unique_entries,
2804        entries,
2805        duplicate_entries,
2806        missing_entries,
2807    }
2808}
2809
2810fn normalize_path_entry(value: &str) -> String {
2811    #[cfg(target_os = "windows")]
2812    {
2813        value
2814            .replace('/', "\\")
2815            .trim_end_matches(['\\', '/'])
2816            .to_ascii_lowercase()
2817    }
2818    #[cfg(not(target_os = "windows"))]
2819    {
2820        value.trim_end_matches('/').to_string()
2821    }
2822}
2823
2824struct ToolchainReport {
2825    found: Vec<(String, String)>,
2826    missing: Vec<String>,
2827}
2828
2829struct PackageManagerReport {
2830    found: Vec<(String, String)>,
2831}
2832
2833#[derive(Debug, Clone)]
2834struct ProcessEntry {
2835    name: String,
2836    pid: u32,
2837    memory_bytes: u64,
2838    cpu_seconds: Option<f64>,
2839    cpu_percent: Option<f64>,
2840    read_ops: Option<u64>,
2841    write_ops: Option<u64>,
2842    detail: Option<String>,
2843}
2844
2845#[derive(Debug, Clone)]
2846struct ServiceEntry {
2847    name: String,
2848    status: String,
2849    startup: Option<String>,
2850    display_name: Option<String>,
2851    start_name: Option<String>,
2852}
2853
2854#[derive(Debug, Clone, Default)]
2855struct NetworkAdapter {
2856    name: String,
2857    ipv4: Vec<String>,
2858    ipv6: Vec<String>,
2859    gateways: Vec<String>,
2860    dns_servers: Vec<String>,
2861    disconnected: bool,
2862}
2863
2864impl NetworkAdapter {
2865    fn is_active(&self) -> bool {
2866        !self.disconnected
2867            && (!self.ipv4.is_empty() || !self.ipv6.is_empty() || !self.gateways.is_empty())
2868    }
2869}
2870
2871#[derive(Debug, Clone, Copy, Default)]
2872struct ListenerExposureSummary {
2873    loopback_only: usize,
2874    wildcard_public: usize,
2875    specific_bind: usize,
2876}
2877
2878#[derive(Debug, Clone)]
2879struct ListeningPort {
2880    protocol: String,
2881    local: String,
2882    port: u16,
2883    state: String,
2884    pid: Option<String>,
2885    process_name: Option<String>,
2886}
2887
2888fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
2889    #[cfg(target_os = "windows")]
2890    {
2891        collect_windows_listening_ports()
2892    }
2893    #[cfg(not(target_os = "windows"))]
2894    {
2895        collect_unix_listening_ports()
2896    }
2897}
2898
2899fn collect_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2900    #[cfg(target_os = "windows")]
2901    {
2902        collect_windows_network_adapters()
2903    }
2904    #[cfg(not(target_os = "windows"))]
2905    {
2906        collect_unix_network_adapters()
2907    }
2908}
2909
2910fn collect_services() -> Result<Vec<ServiceEntry>, String> {
2911    #[cfg(target_os = "windows")]
2912    {
2913        collect_windows_services()
2914    }
2915    #[cfg(not(target_os = "windows"))]
2916    {
2917        collect_unix_services()
2918    }
2919}
2920
2921#[cfg(target_os = "windows")]
2922fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
2923    let output = Command::new("netstat")
2924        .args(["-ano", "-p", "tcp"])
2925        .output()
2926        .map_err(|e| format!("Failed to run netstat: {e}"))?;
2927    if !output.status.success() {
2928        return Err("netstat returned a non-success status.".to_string());
2929    }
2930
2931    let text = String::from_utf8_lossy(&output.stdout);
2932    let mut listeners = Vec::new();
2933    for line in text.lines() {
2934        let trimmed = line.trim();
2935        if !trimmed.starts_with("TCP") {
2936            continue;
2937        }
2938        let cols: Vec<&str> = trimmed.split_whitespace().collect();
2939        if cols.len() < 5 || cols[3] != "LISTENING" {
2940            continue;
2941        }
2942        let Some(port) = extract_port_from_socket(cols[1]) else {
2943            continue;
2944        };
2945        listeners.push(ListeningPort {
2946            protocol: cols[0].to_string(),
2947            local: cols[1].to_string(),
2948            port,
2949            state: cols[3].to_string(),
2950            pid: Some(cols[4].to_string()),
2951            process_name: None,
2952        });
2953    }
2954
2955    // Enrich with process names via PowerShell — works without elevation for
2956    // most user-space processes. System processes (PID 4, etc.) stay unnamed.
2957    let unique_pids: Vec<String> = listeners
2958        .iter()
2959        .filter_map(|l| l.pid.clone())
2960        .collect::<HashSet<_>>()
2961        .into_iter()
2962        .collect();
2963
2964    if !unique_pids.is_empty() {
2965        let pid_list = unique_pids.join(",");
2966        let ps_cmd = format!(
2967            "Get-Process -Id {} -ErrorAction SilentlyContinue | Select-Object Id,Name | Format-Table -HideTableHeaders",
2968            pid_list
2969        );
2970        if let Ok(ps_out) = Command::new("powershell")
2971            .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
2972            .output()
2973        {
2974            let mut pid_map = std::collections::HashMap::<String, String>::new();
2975            let ps_text = String::from_utf8_lossy(&ps_out.stdout);
2976            for line in ps_text.lines() {
2977                let parts: Vec<&str> = line.split_whitespace().collect();
2978                if parts.len() >= 2 {
2979                    pid_map.insert(parts[0].to_string(), parts[1].to_string());
2980                }
2981            }
2982            for listener in &mut listeners {
2983                if let Some(pid) = &listener.pid {
2984                    listener.process_name = pid_map.get(pid).cloned();
2985                }
2986            }
2987        }
2988    }
2989
2990    Ok(listeners)
2991}
2992
2993#[cfg(not(target_os = "windows"))]
2994fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
2995    let output = Command::new("ss")
2996        .args(["-ltn"])
2997        .output()
2998        .map_err(|e| format!("Failed to run ss: {e}"))?;
2999    if !output.status.success() {
3000        return Err("ss returned a non-success status.".to_string());
3001    }
3002
3003    let text = String::from_utf8_lossy(&output.stdout);
3004    let mut listeners = Vec::new();
3005    for line in text.lines().skip(1) {
3006        let cols: Vec<&str> = line.split_whitespace().collect();
3007        if cols.len() < 4 {
3008            continue;
3009        }
3010        let Some(port) = extract_port_from_socket(cols[3]) else {
3011            continue;
3012        };
3013        listeners.push(ListeningPort {
3014            protocol: "tcp".to_string(),
3015            local: cols[3].to_string(),
3016            port,
3017            state: cols[0].to_string(),
3018            pid: None,
3019            process_name: None,
3020        });
3021    }
3022
3023    Ok(listeners)
3024}
3025
3026fn collect_processes() -> Result<Vec<ProcessEntry>, String> {
3027    #[cfg(target_os = "windows")]
3028    {
3029        collect_windows_processes()
3030    }
3031    #[cfg(not(target_os = "windows"))]
3032    {
3033        collect_unix_processes()
3034    }
3035}
3036
3037#[cfg(target_os = "windows")]
3038fn collect_windows_services() -> Result<Vec<ServiceEntry>, String> {
3039    let command = "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,DisplayName,StartName | ConvertTo-Json -Compress";
3040    let output = Command::new("powershell")
3041        .args(["-NoProfile", "-Command", command])
3042        .output()
3043        .map_err(|e| format!("Failed to run PowerShell service inspection: {e}"))?;
3044    if !output.status.success() {
3045        return Err("PowerShell service inspection returned a non-success status.".to_string());
3046    }
3047
3048    parse_windows_services_json(&String::from_utf8_lossy(&output.stdout))
3049}
3050
3051#[cfg(not(target_os = "windows"))]
3052fn collect_unix_services() -> Result<Vec<ServiceEntry>, String> {
3053    let status_output = Command::new("systemctl")
3054        .args([
3055            "list-units",
3056            "--type=service",
3057            "--all",
3058            "--no-pager",
3059            "--no-legend",
3060            "--plain",
3061        ])
3062        .output()
3063        .map_err(|e| format!("Failed to run systemctl list-units: {e}"))?;
3064    if !status_output.status.success() {
3065        return Err("systemctl list-units returned a non-success status.".to_string());
3066    }
3067
3068    let startup_output = Command::new("systemctl")
3069        .args([
3070            "list-unit-files",
3071            "--type=service",
3072            "--no-legend",
3073            "--no-pager",
3074            "--plain",
3075        ])
3076        .output()
3077        .map_err(|e| format!("Failed to run systemctl list-unit-files: {e}"))?;
3078    if !startup_output.status.success() {
3079        return Err("systemctl list-unit-files returned a non-success status.".to_string());
3080    }
3081
3082    Ok(parse_unix_services(
3083        &String::from_utf8_lossy(&status_output.stdout),
3084        &String::from_utf8_lossy(&startup_output.stdout),
3085    ))
3086}
3087
3088#[cfg(target_os = "windows")]
3089fn collect_windows_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3090    let output = Command::new("ipconfig")
3091        .args(["/all"])
3092        .output()
3093        .map_err(|e| format!("Failed to run ipconfig: {e}"))?;
3094    if !output.status.success() {
3095        return Err("ipconfig returned a non-success status.".to_string());
3096    }
3097
3098    Ok(parse_windows_ipconfig_all(&String::from_utf8_lossy(
3099        &output.stdout,
3100    )))
3101}
3102
3103#[cfg(not(target_os = "windows"))]
3104fn collect_unix_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3105    let addr_output = Command::new("ip")
3106        .args(["-o", "addr", "show", "up"])
3107        .output()
3108        .map_err(|e| format!("Failed to run ip addr: {e}"))?;
3109    if !addr_output.status.success() {
3110        return Err("ip addr returned a non-success status.".to_string());
3111    }
3112
3113    let route_output = Command::new("ip")
3114        .args(["route", "show", "default"])
3115        .output()
3116        .map_err(|e| format!("Failed to run ip route: {e}"))?;
3117    if !route_output.status.success() {
3118        return Err("ip route returned a non-success status.".to_string());
3119    }
3120
3121    let mut adapters = parse_unix_ip_addr(&String::from_utf8_lossy(&addr_output.stdout));
3122    apply_unix_default_routes(
3123        &mut adapters,
3124        &String::from_utf8_lossy(&route_output.stdout),
3125    );
3126    apply_unix_dns_servers(&mut adapters);
3127    Ok(adapters)
3128}
3129
3130#[cfg(target_os = "windows")]
3131fn collect_windows_processes() -> Result<Vec<ProcessEntry>, String> {
3132    // We take two samples of CPU time separated by a short interval to calculate recent CPU %
3133    let script = r#"
3134        $s1 = Get-Process | Select-Object Id, CPU
3135        Start-Sleep -Milliseconds 250
3136        $s2 = Get-Process | Select-Object Name, Id, WorkingSet64, CPU, ReadOperationCount, WriteOperationCount
3137        $s2 | ForEach-Object {
3138            $p2 = $_
3139            $p1 = $s1 | Where-Object { $_.Id -eq $p2.Id }
3140            $pct = 0.0
3141            if ($p1 -and $p2.CPU -gt $p1.CPU) {
3142                # (Delta CPU seconds / interval) * 100 / LogicalProcessors
3143                # Note: We skip division by logical processors to show 'per-core' usage or just raw % if preferred.
3144                # Standard Task Manager style is (delta / interval) * 100.
3145                $pct = [math]::Round((($p2.CPU - $p1.CPU) / 0.25) * 100, 1)
3146            }
3147            "PID:$($p2.Id)|NAME:$($p2.Name)|MEM:$($p2.WorkingSet64)|CPU_S:$($p2.CPU)|CPU_P:$pct|READ:$($p2.ReadOperationCount)|WRITE:$($p2.WriteOperationCount)"
3148        }
3149    "#;
3150
3151    let output = Command::new("powershell")
3152        .args(["-NoProfile", "-Command", script])
3153        .output()
3154        .map_err(|e| format!("Failed to run powershell Get-Process: {e}"))?;
3155
3156    let text = String::from_utf8_lossy(&output.stdout);
3157    let mut out = Vec::new();
3158    for line in text.lines() {
3159        let parts: Vec<&str> = line.trim().split('|').collect();
3160        if parts.len() < 5 {
3161            continue;
3162        }
3163        let mut entry = ProcessEntry {
3164            name: "unknown".to_string(),
3165            pid: 0,
3166            memory_bytes: 0,
3167            cpu_seconds: None,
3168            cpu_percent: None,
3169            read_ops: None,
3170            write_ops: None,
3171            detail: None,
3172        };
3173        for p in parts {
3174            if let Some((k, v)) = p.split_once(':') {
3175                match k {
3176                    "PID" => entry.pid = v.parse().unwrap_or(0),
3177                    "NAME" => entry.name = v.to_string(),
3178                    "MEM" => entry.memory_bytes = v.parse().unwrap_or(0),
3179                    "CPU_S" => entry.cpu_seconds = v.parse().ok(),
3180                    "CPU_P" => entry.cpu_percent = v.parse().ok(),
3181                    "READ" => entry.read_ops = v.parse().ok(),
3182                    "WRITE" => entry.write_ops = v.parse().ok(),
3183                    _ => {}
3184                }
3185            }
3186        }
3187        out.push(entry);
3188    }
3189    Ok(out)
3190}
3191
3192#[cfg(not(target_os = "windows"))]
3193fn collect_unix_processes() -> Result<Vec<ProcessEntry>, String> {
3194    let output = Command::new("ps")
3195        .args(["-eo", "pid=,rss=,comm="])
3196        .output()
3197        .map_err(|e| format!("Failed to run ps: {e}"))?;
3198    if !output.status.success() {
3199        return Err("ps returned a non-success status.".to_string());
3200    }
3201
3202    let text = String::from_utf8_lossy(&output.stdout);
3203    let mut processes = Vec::new();
3204    for line in text.lines() {
3205        let cols: Vec<&str> = line.split_whitespace().collect();
3206        if cols.len() < 3 {
3207            continue;
3208        }
3209        let (Some(pid), Some(rss_kib)) = (cols[0].parse::<u32>().ok(), cols[1].parse::<u64>().ok())
3210        else {
3211            continue;
3212        };
3213        processes.push(ProcessEntry {
3214            name: cols[2..].join(" "),
3215            pid,
3216            memory_bytes: rss_kib * 1024,
3217            cpu_seconds: None,
3218            cpu_percent: None,
3219            read_ops: None,
3220            write_ops: None,
3221            detail: None,
3222        });
3223    }
3224
3225    Ok(processes)
3226}
3227
3228fn extract_port_from_socket(value: &str) -> Option<u16> {
3229    let cleaned = value.trim().trim_matches(['[', ']']);
3230    let port_str = cleaned.rsplit(':').next()?;
3231    port_str.parse::<u16>().ok()
3232}
3233
3234fn listener_exposure_summary(listeners: Vec<ListeningPort>) -> ListenerExposureSummary {
3235    let mut summary = ListenerExposureSummary::default();
3236    for entry in listeners {
3237        let local = entry.local.to_ascii_lowercase();
3238        if is_loopback_listener(&local) {
3239            summary.loopback_only += 1;
3240        } else if is_wildcard_listener(&local) {
3241            summary.wildcard_public += 1;
3242        } else {
3243            summary.specific_bind += 1;
3244        }
3245    }
3246    summary
3247}
3248
3249fn is_loopback_listener(local: &str) -> bool {
3250    local.starts_with("127.")
3251        || local.starts_with("[::1]")
3252        || local.starts_with("::1")
3253        || local.starts_with("localhost:")
3254}
3255
3256fn is_wildcard_listener(local: &str) -> bool {
3257    local.starts_with("0.0.0.0:")
3258        || local.starts_with("[::]:")
3259        || local.starts_with(":::")
3260        || local == "*:*"
3261}
3262
3263struct GitState {
3264    root: PathBuf,
3265    branch: String,
3266    dirty_entries: usize,
3267}
3268
3269impl GitState {
3270    fn status_label(&self) -> String {
3271        if self.dirty_entries == 0 {
3272            "clean".to_string()
3273        } else {
3274            format!("dirty ({} changed path(s))", self.dirty_entries)
3275        }
3276    }
3277}
3278
3279fn inspect_git_state(path: &Path) -> Option<GitState> {
3280    let root = capture_first_line(
3281        "git",
3282        &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
3283    )?;
3284    let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
3285        .unwrap_or_else(|| "detached".to_string());
3286    let output = Command::new("git")
3287        .args(["-C", path.to_str()?, "status", "--short"])
3288        .output()
3289        .ok()?;
3290    if !output.status.success() {
3291        return None;
3292    }
3293    let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
3294    Some(GitState {
3295        root: PathBuf::from(root),
3296        branch,
3297        dirty_entries,
3298    })
3299}
3300
3301struct HematiteState {
3302    docs_count: usize,
3303    import_count: usize,
3304    report_count: usize,
3305    workspace_profile: bool,
3306}
3307
3308fn collect_hematite_state(path: &Path) -> HematiteState {
3309    let root = path.join(".hematite");
3310    HematiteState {
3311        docs_count: count_entries_if_exists(&root.join("docs")),
3312        import_count: count_entries_if_exists(&root.join("imports")),
3313        report_count: count_entries_if_exists(&root.join("reports")),
3314        workspace_profile: root.join("workspace_profile.json").exists(),
3315    }
3316}
3317
3318fn count_entries_if_exists(path: &Path) -> usize {
3319    if !path.exists() || !path.is_dir() {
3320        return 0;
3321    }
3322    fs::read_dir(path)
3323        .ok()
3324        .map(|iter| iter.filter(|entry| entry.is_ok()).count())
3325        .unwrap_or(0)
3326}
3327
3328fn collect_project_markers(path: &Path) -> Vec<String> {
3329    [
3330        "Cargo.toml",
3331        "package.json",
3332        "pyproject.toml",
3333        "go.mod",
3334        "justfile",
3335        "Makefile",
3336        ".git",
3337    ]
3338    .iter()
3339    .filter_map(|name| path.join(name).exists().then(|| (*name).to_string()))
3340    .collect()
3341}
3342
3343struct ReleaseArtifactState {
3344    version: String,
3345    portable_dir: bool,
3346    portable_zip: bool,
3347    setup_exe: bool,
3348}
3349
3350fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
3351    let cargo_toml = path.join("Cargo.toml");
3352    if !cargo_toml.exists() {
3353        return None;
3354    }
3355    let cargo_text = fs::read_to_string(cargo_toml).ok()?;
3356    let version = [regex_line_capture(
3357        &cargo_text,
3358        r#"(?m)^version\s*=\s*"([^"]+)""#,
3359    )?]
3360    .concat();
3361    let dist_windows = path.join("dist").join("windows");
3362    let prefix = format!("Hematite-{}", version);
3363    Some(ReleaseArtifactState {
3364        version,
3365        portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
3366        portable_zip: dist_windows
3367            .join(format!("{}-portable.zip", prefix))
3368            .exists(),
3369        setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
3370    })
3371}
3372
3373fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
3374    let regex = regex::Regex::new(pattern).ok()?;
3375    let captures = regex.captures(text)?;
3376    captures.get(1).map(|m| m.as_str().to_string())
3377}
3378
3379fn bool_label(value: bool) -> &'static str {
3380    if value {
3381        "yes"
3382    } else {
3383        "no"
3384    }
3385}
3386
3387fn collect_toolchains() -> ToolchainReport {
3388    let checks = [
3389        ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
3390        ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
3391        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3392        ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
3393        ToolCheck::new(
3394            "npm",
3395            &[
3396                CommandProbe::new("npm", &["--version"]),
3397                CommandProbe::new("npm.cmd", &["--version"]),
3398            ],
3399        ),
3400        ToolCheck::new(
3401            "pnpm",
3402            &[
3403                CommandProbe::new("pnpm", &["--version"]),
3404                CommandProbe::new("pnpm.cmd", &["--version"]),
3405            ],
3406        ),
3407        ToolCheck::new(
3408            "python",
3409            &[
3410                CommandProbe::new("python", &["--version"]),
3411                CommandProbe::new("python3", &["--version"]),
3412                CommandProbe::new("py", &["-3", "--version"]),
3413                CommandProbe::new("py", &["--version"]),
3414            ],
3415        ),
3416        ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
3417        ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
3418        ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
3419        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3420    ];
3421
3422    let mut found = Vec::new();
3423    let mut missing = Vec::new();
3424
3425    for check in checks {
3426        match check.detect() {
3427            Some(version) => found.push((check.label.to_string(), version)),
3428            None => missing.push(check.label.to_string()),
3429        }
3430    }
3431
3432    ToolchainReport { found, missing }
3433}
3434
3435fn collect_package_managers() -> PackageManagerReport {
3436    let checks = [
3437        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3438        ToolCheck::new(
3439            "npm",
3440            &[
3441                CommandProbe::new("npm", &["--version"]),
3442                CommandProbe::new("npm.cmd", &["--version"]),
3443            ],
3444        ),
3445        ToolCheck::new(
3446            "pnpm",
3447            &[
3448                CommandProbe::new("pnpm", &["--version"]),
3449                CommandProbe::new("pnpm.cmd", &["--version"]),
3450            ],
3451        ),
3452        ToolCheck::new(
3453            "pip",
3454            &[
3455                CommandProbe::new("python", &["-m", "pip", "--version"]),
3456                CommandProbe::new("python3", &["-m", "pip", "--version"]),
3457                CommandProbe::new("py", &["-3", "-m", "pip", "--version"]),
3458                CommandProbe::new("py", &["-m", "pip", "--version"]),
3459                CommandProbe::new("pip", &["--version"]),
3460            ],
3461        ),
3462        ToolCheck::new("pipx", &[CommandProbe::new("pipx", &["--version"])]),
3463        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3464        ToolCheck::new("winget", &[CommandProbe::new("winget", &["--version"])]),
3465        ToolCheck::new(
3466            "choco",
3467            &[
3468                CommandProbe::new("choco", &["--version"]),
3469                CommandProbe::new("choco.exe", &["--version"]),
3470            ],
3471        ),
3472        ToolCheck::new("scoop", &[CommandProbe::new("scoop", &["--version"])]),
3473    ];
3474
3475    let mut found = Vec::new();
3476    for check in checks {
3477        match check.detect() {
3478            Some(version) => found.push((check.label.to_string(), version)),
3479            None => {}
3480        }
3481    }
3482
3483    PackageManagerReport { found }
3484}
3485
3486#[derive(Clone)]
3487struct ToolCheck {
3488    label: &'static str,
3489    probes: Vec<CommandProbe>,
3490}
3491
3492impl ToolCheck {
3493    fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
3494        Self {
3495            label,
3496            probes: probes.to_vec(),
3497        }
3498    }
3499
3500    fn detect(&self) -> Option<String> {
3501        for probe in &self.probes {
3502            if let Some(output) = capture_first_line(probe.program, probe.args) {
3503                return Some(output);
3504            }
3505        }
3506        None
3507    }
3508}
3509
3510#[derive(Clone, Copy)]
3511struct CommandProbe {
3512    program: &'static str,
3513    args: &'static [&'static str],
3514}
3515
3516impl CommandProbe {
3517    const fn new(program: &'static str, args: &'static [&'static str]) -> Self {
3518        Self { program, args }
3519    }
3520}
3521
3522fn build_env_doctor_findings(
3523    toolchains: &ToolchainReport,
3524    package_managers: &PackageManagerReport,
3525    path_stats: &PathAnalysis,
3526) -> Vec<String> {
3527    let found_tools = toolchains
3528        .found
3529        .iter()
3530        .map(|(label, _)| label.as_str())
3531        .collect::<HashSet<_>>();
3532    let found_managers = package_managers
3533        .found
3534        .iter()
3535        .map(|(label, _)| label.as_str())
3536        .collect::<HashSet<_>>();
3537
3538    let mut findings = Vec::new();
3539
3540    if path_stats.duplicate_entries.len() > 0 {
3541        findings.push(format!(
3542            "PATH contains {} duplicate entries. That is usually harmless but worth cleaning up.",
3543            path_stats.duplicate_entries.len()
3544        ));
3545    }
3546    if path_stats.missing_entries.len() > 0 {
3547        findings.push(format!(
3548            "PATH contains {} entries that do not exist on disk.",
3549            path_stats.missing_entries.len()
3550        ));
3551    }
3552    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
3553        findings.push(
3554            "Rust is present but Cargo was not detected. That is an incomplete Rust toolchain."
3555                .to_string(),
3556        );
3557    }
3558    if found_tools.contains("node")
3559        && !found_managers.contains("npm")
3560        && !found_managers.contains("pnpm")
3561    {
3562        findings.push(
3563            "Node is present but no JavaScript package manager was detected (npm or pnpm)."
3564                .to_string(),
3565        );
3566    }
3567    if found_tools.contains("python")
3568        && !found_managers.contains("pip")
3569        && !found_managers.contains("uv")
3570        && !found_managers.contains("pipx")
3571    {
3572        findings.push(
3573            "Python is present but no Python package manager was detected (pip, uv, or pipx)."
3574                .to_string(),
3575        );
3576    }
3577    let windows_manager_count = ["winget", "choco", "scoop"]
3578        .iter()
3579        .filter(|label| found_managers.contains(**label))
3580        .count();
3581    if windows_manager_count > 1 {
3582        findings.push(
3583            "Multiple Windows package managers are installed. That is workable, but it can create overlap in update paths."
3584                .to_string(),
3585        );
3586    }
3587    if findings.is_empty() && !found_managers.is_empty() {
3588        findings.push(
3589            "Core package-manager coverage looks healthy for a normal developer workstation."
3590                .to_string(),
3591        );
3592    }
3593
3594    findings
3595}
3596
3597fn capture_first_line(program: &str, args: &[&str]) -> Option<String> {
3598    let output = std::process::Command::new(program)
3599        .args(args)
3600        .output()
3601        .ok()?;
3602    if !output.status.success() {
3603        return None;
3604    }
3605
3606    let stdout = if output.stdout.is_empty() {
3607        String::from_utf8_lossy(&output.stderr).into_owned()
3608    } else {
3609        String::from_utf8_lossy(&output.stdout).into_owned()
3610    };
3611
3612    stdout
3613        .lines()
3614        .map(str::trim)
3615        .find(|line| !line.is_empty())
3616        .map(|line| line.to_string())
3617}
3618
3619fn human_bytes(bytes: u64) -> String {
3620    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
3621    let mut value = bytes as f64;
3622    let mut unit_index = 0usize;
3623
3624    while value >= 1024.0 && unit_index < UNITS.len() - 1 {
3625        value /= 1024.0;
3626        unit_index += 1;
3627    }
3628
3629    if unit_index == 0 {
3630        format!("{} {}", bytes, UNITS[unit_index])
3631    } else {
3632        format!("{value:.1} {}", UNITS[unit_index])
3633    }
3634}
3635
3636#[cfg(target_os = "windows")]
3637fn parse_windows_ipconfig_all(text: &str) -> Vec<NetworkAdapter> {
3638    let mut adapters = Vec::new();
3639    let mut current: Option<NetworkAdapter> = None;
3640    let mut pending_dns = false;
3641
3642    for raw_line in text.lines() {
3643        let line = raw_line.trim_end();
3644        let trimmed = line.trim();
3645        if trimmed.is_empty() {
3646            pending_dns = false;
3647            continue;
3648        }
3649
3650        if !line.starts_with(' ') && trimmed.ends_with(':') && trimmed.contains("adapter") {
3651            if let Some(adapter) = current.take() {
3652                adapters.push(adapter);
3653            }
3654            current = Some(NetworkAdapter {
3655                name: trimmed.trim_end_matches(':').to_string(),
3656                ..NetworkAdapter::default()
3657            });
3658            pending_dns = false;
3659            continue;
3660        }
3661
3662        let Some(adapter) = current.as_mut() else {
3663            continue;
3664        };
3665
3666        if trimmed.contains("Media State") && trimmed.contains("disconnected") {
3667            adapter.disconnected = true;
3668        }
3669
3670        if let Some(value) = value_after_colon(trimmed) {
3671            let normalized = normalize_ipconfig_value(value);
3672            if trimmed.starts_with("IPv4 Address") && !normalized.is_empty() {
3673                adapter.ipv4.push(normalized);
3674                pending_dns = false;
3675            } else if trimmed.starts_with("IPv6 Address")
3676                || trimmed.starts_with("Temporary IPv6 Address")
3677                || trimmed.starts_with("Link-local IPv6 Address")
3678            {
3679                if !normalized.is_empty() {
3680                    adapter.ipv6.push(normalized);
3681                }
3682                pending_dns = false;
3683            } else if trimmed.starts_with("Default Gateway") {
3684                if !normalized.is_empty() {
3685                    adapter.gateways.push(normalized);
3686                }
3687                pending_dns = false;
3688            } else if trimmed.starts_with("DNS Servers") {
3689                if !normalized.is_empty() {
3690                    adapter.dns_servers.push(normalized);
3691                }
3692                pending_dns = true;
3693            } else {
3694                pending_dns = false;
3695            }
3696        } else if pending_dns {
3697            let normalized = normalize_ipconfig_value(trimmed);
3698            if !normalized.is_empty() {
3699                adapter.dns_servers.push(normalized);
3700            }
3701        }
3702    }
3703
3704    if let Some(adapter) = current.take() {
3705        adapters.push(adapter);
3706    }
3707
3708    for adapter in &mut adapters {
3709        dedup_vec(&mut adapter.ipv4);
3710        dedup_vec(&mut adapter.ipv6);
3711        dedup_vec(&mut adapter.gateways);
3712        dedup_vec(&mut adapter.dns_servers);
3713    }
3714
3715    adapters
3716}
3717
3718#[cfg(not(target_os = "windows"))]
3719fn parse_unix_ip_addr(text: &str) -> Vec<NetworkAdapter> {
3720    let mut adapters = std::collections::BTreeMap::<String, NetworkAdapter>::new();
3721
3722    for line in text.lines() {
3723        let cols: Vec<&str> = line.split_whitespace().collect();
3724        if cols.len() < 4 {
3725            continue;
3726        }
3727        let name = cols[1].trim_end_matches(':').to_string();
3728        let family = cols[2];
3729        let addr = cols[3].split('/').next().unwrap_or("").to_string();
3730        let entry = adapters
3731            .entry(name.clone())
3732            .or_insert_with(|| NetworkAdapter {
3733                name,
3734                ..NetworkAdapter::default()
3735            });
3736        match family {
3737            "inet" if !addr.is_empty() => entry.ipv4.push(addr),
3738            "inet6" if !addr.is_empty() => entry.ipv6.push(addr),
3739            _ => {}
3740        }
3741    }
3742
3743    adapters.into_values().collect()
3744}
3745
3746#[cfg(not(target_os = "windows"))]
3747fn apply_unix_default_routes(adapters: &mut [NetworkAdapter], text: &str) {
3748    for line in text.lines() {
3749        let cols: Vec<&str> = line.split_whitespace().collect();
3750        if cols.len() < 5 {
3751            continue;
3752        }
3753        let gateway = cols
3754            .windows(2)
3755            .find(|pair| pair[0] == "via")
3756            .map(|pair| pair[1].to_string());
3757        let dev = cols
3758            .windows(2)
3759            .find(|pair| pair[0] == "dev")
3760            .map(|pair| pair[1]);
3761        if let (Some(gateway), Some(dev)) = (gateway, dev) {
3762            if let Some(adapter) = adapters.iter_mut().find(|adapter| adapter.name == dev) {
3763                adapter.gateways.push(gateway);
3764            }
3765        }
3766    }
3767
3768    for adapter in adapters {
3769        dedup_vec(&mut adapter.gateways);
3770    }
3771}
3772
3773#[cfg(not(target_os = "windows"))]
3774fn apply_unix_dns_servers(adapters: &mut [NetworkAdapter]) {
3775    let Ok(text) = fs::read_to_string("/etc/resolv.conf") else {
3776        return;
3777    };
3778    let mut dns_servers = text
3779        .lines()
3780        .filter_map(|line| line.strip_prefix("nameserver "))
3781        .map(str::trim)
3782        .filter(|value| !value.is_empty())
3783        .map(|value| value.to_string())
3784        .collect::<Vec<_>>();
3785    dedup_vec(&mut dns_servers);
3786    if dns_servers.is_empty() {
3787        return;
3788    }
3789    for adapter in adapters.iter_mut().filter(|adapter| adapter.is_active()) {
3790        adapter.dns_servers = dns_servers.clone();
3791    }
3792}
3793
3794#[cfg(target_os = "windows")]
3795fn value_after_colon(line: &str) -> Option<&str> {
3796    line.split_once(':').map(|(_, value)| value.trim())
3797}
3798
3799#[cfg(target_os = "windows")]
3800fn normalize_ipconfig_value(value: &str) -> String {
3801    value
3802        .trim()
3803        .trim_end_matches("(Preferred)")
3804        .trim_end_matches("(Deprecated)")
3805        .trim()
3806        .trim_matches(['(', ')'])
3807        .trim()
3808        .to_string()
3809}
3810
3811#[cfg(target_os = "windows")]
3812fn is_noise_lan_neighbor(ip: &str, mac: &str) -> bool {
3813    let mac_upper = mac.to_ascii_uppercase();
3814    if mac_upper == "FF-FF-FF-FF-FF-FF" || mac_upper.starts_with("01-00-5E-") {
3815        return true;
3816    }
3817
3818    ip == "255.255.255.255"
3819        || ip.starts_with("224.")
3820        || ip.starts_with("225.")
3821        || ip.starts_with("226.")
3822        || ip.starts_with("227.")
3823        || ip.starts_with("228.")
3824        || ip.starts_with("229.")
3825        || ip.starts_with("230.")
3826        || ip.starts_with("231.")
3827        || ip.starts_with("232.")
3828        || ip.starts_with("233.")
3829        || ip.starts_with("234.")
3830        || ip.starts_with("235.")
3831        || ip.starts_with("236.")
3832        || ip.starts_with("237.")
3833        || ip.starts_with("238.")
3834        || ip.starts_with("239.")
3835}
3836
3837fn dedup_vec(values: &mut Vec<String>) {
3838    let mut seen = HashSet::new();
3839    values.retain(|value| seen.insert(value.clone()));
3840}
3841
3842#[cfg(target_os = "windows")]
3843fn parse_lan_neighbors(text: &str) -> Vec<(String, String, String, String)> {
3844    let trimmed = text.trim();
3845    if trimmed.is_empty() {
3846        return Vec::new();
3847    }
3848
3849    let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
3850        return Vec::new();
3851    };
3852    let entries = match value {
3853        Value::Array(items) => items,
3854        other => vec![other],
3855    };
3856
3857    let mut neighbors = Vec::new();
3858    for entry in entries {
3859        let ip = entry
3860            .get("IPAddress")
3861            .and_then(|v| v.as_str())
3862            .unwrap_or("")
3863            .to_string();
3864        if ip.is_empty() {
3865            continue;
3866        }
3867        let mac = entry
3868            .get("LinkLayerAddress")
3869            .and_then(|v| v.as_str())
3870            .unwrap_or("unknown")
3871            .to_string();
3872        let state = entry
3873            .get("State")
3874            .and_then(|v| v.as_str())
3875            .unwrap_or("unknown")
3876            .to_string();
3877        let iface = entry
3878            .get("InterfaceAlias")
3879            .and_then(|v| v.as_str())
3880            .unwrap_or("unknown")
3881            .to_string();
3882        if is_noise_lan_neighbor(&ip, &mac) {
3883            continue;
3884        }
3885        neighbors.push((ip, mac, state, iface));
3886    }
3887
3888    neighbors
3889}
3890
3891#[cfg(target_os = "windows")]
3892fn parse_windows_services_json(text: &str) -> Result<Vec<ServiceEntry>, String> {
3893    let trimmed = text.trim();
3894    if trimmed.is_empty() {
3895        return Ok(Vec::new());
3896    }
3897
3898    let value: Value = serde_json::from_str(trimmed)
3899        .map_err(|e| format!("Failed to parse PowerShell service JSON: {e}"))?;
3900    let entries = match value {
3901        Value::Array(items) => items,
3902        other => vec![other],
3903    };
3904
3905    let mut services = Vec::new();
3906    for entry in entries {
3907        let Some(name) = entry.get("Name").and_then(|v| v.as_str()) else {
3908            continue;
3909        };
3910        services.push(ServiceEntry {
3911            name: name.to_string(),
3912            status: entry
3913                .get("State")
3914                .and_then(|v| v.as_str())
3915                .unwrap_or("unknown")
3916                .to_string(),
3917            startup: entry
3918                .get("StartMode")
3919                .and_then(|v| v.as_str())
3920                .map(|v| v.to_string()),
3921            display_name: entry
3922                .get("DisplayName")
3923                .and_then(|v| v.as_str())
3924                .map(|v| v.to_string()),
3925            start_name: entry
3926                .get("StartName")
3927                .and_then(|v| v.as_str())
3928                .map(|v| v.to_string()),
3929        });
3930    }
3931
3932    Ok(services)
3933}
3934
3935#[cfg(target_os = "windows")]
3936fn windows_json_entries(node: Option<&Value>) -> Vec<Value> {
3937    match node.cloned() {
3938        Some(Value::Array(items)) => items,
3939        Some(other) => vec![other],
3940        None => Vec::new(),
3941    }
3942}
3943
3944#[cfg(target_os = "windows")]
3945fn parse_windows_pnp_devices(node: Option<&Value>) -> Vec<WindowsPnpDevice> {
3946    windows_json_entries(node)
3947        .into_iter()
3948        .filter_map(|entry| {
3949            let name = entry
3950                .get("FriendlyName")
3951                .and_then(|v| v.as_str())
3952                .or_else(|| entry.get("Name").and_then(|v| v.as_str()))
3953                .unwrap_or("")
3954                .trim()
3955                .to_string();
3956            if name.is_empty() {
3957                return None;
3958            }
3959            Some(WindowsPnpDevice {
3960                name,
3961                status: entry
3962                    .get("Status")
3963                    .and_then(|v| v.as_str())
3964                    .unwrap_or("Unknown")
3965                    .trim()
3966                    .to_string(),
3967                problem: entry.get("Problem").and_then(|v| v.as_u64()).or_else(|| {
3968                    entry
3969                        .get("Problem")
3970                        .and_then(|v| v.as_i64())
3971                        .map(|v| v as u64)
3972                }),
3973                class_name: entry
3974                    .get("Class")
3975                    .and_then(|v| v.as_str())
3976                    .map(|v| v.trim().to_string()),
3977                instance_id: entry
3978                    .get("InstanceId")
3979                    .and_then(|v| v.as_str())
3980                    .map(|v| v.trim().to_string()),
3981            })
3982        })
3983        .collect()
3984}
3985
3986#[cfg(target_os = "windows")]
3987fn parse_windows_sound_devices(node: Option<&Value>) -> Vec<WindowsSoundDevice> {
3988    windows_json_entries(node)
3989        .into_iter()
3990        .filter_map(|entry| {
3991            let name = entry
3992                .get("Name")
3993                .and_then(|v| v.as_str())
3994                .unwrap_or("")
3995                .trim()
3996                .to_string();
3997            if name.is_empty() {
3998                return None;
3999            }
4000            Some(WindowsSoundDevice {
4001                name,
4002                status: entry
4003                    .get("Status")
4004                    .and_then(|v| v.as_str())
4005                    .unwrap_or("Unknown")
4006                    .trim()
4007                    .to_string(),
4008                manufacturer: entry
4009                    .get("Manufacturer")
4010                    .and_then(|v| v.as_str())
4011                    .map(|v| v.trim().to_string()),
4012            })
4013        })
4014        .collect()
4015}
4016
4017#[cfg(target_os = "windows")]
4018fn windows_device_has_issue(device: &WindowsPnpDevice) -> bool {
4019    !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
4020        || device.problem.unwrap_or(0) != 0
4021}
4022
4023#[cfg(target_os = "windows")]
4024fn windows_sound_device_has_issue(device: &WindowsSoundDevice) -> bool {
4025    !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
4026}
4027
4028#[cfg(target_os = "windows")]
4029fn is_microphone_like_name(name: &str) -> bool {
4030    let lower = name.to_ascii_lowercase();
4031    lower.contains("microphone")
4032        || lower.contains("mic")
4033        || lower.contains("input")
4034        || lower.contains("array")
4035        || lower.contains("capture")
4036        || lower.contains("record")
4037}
4038
4039#[cfg(target_os = "windows")]
4040fn is_bluetooth_like_name(name: &str) -> bool {
4041    let lower = name.to_ascii_lowercase();
4042    lower.contains("bluetooth") || lower.contains("hands-free") || lower.contains("a2dp")
4043}
4044
4045#[cfg(target_os = "windows")]
4046fn service_is_running(service: &ServiceEntry) -> bool {
4047    service.status.eq_ignore_ascii_case("running") || service.status.eq_ignore_ascii_case("active")
4048}
4049
4050#[cfg(not(target_os = "windows"))]
4051fn parse_unix_services(status_text: &str, startup_text: &str) -> Vec<ServiceEntry> {
4052    let mut startup_modes = std::collections::HashMap::<String, String>::new();
4053    for line in startup_text.lines() {
4054        let cols: Vec<&str> = line.split_whitespace().collect();
4055        if cols.len() < 2 {
4056            continue;
4057        }
4058        startup_modes.insert(cols[0].to_string(), cols[1].to_string());
4059    }
4060
4061    let mut services = Vec::new();
4062    for line in status_text.lines() {
4063        let cols: Vec<&str> = line.split_whitespace().collect();
4064        if cols.len() < 4 {
4065            continue;
4066        }
4067        let unit = cols[0];
4068        let load = cols[1];
4069        let active = cols[2];
4070        let sub = cols[3];
4071        let description = if cols.len() > 4 {
4072            Some(cols[4..].join(" "))
4073        } else {
4074            None
4075        };
4076        services.push(ServiceEntry {
4077            name: unit.to_string(),
4078            status: format!("{}/{}", active, sub),
4079            startup: startup_modes
4080                .get(unit)
4081                .cloned()
4082                .or_else(|| Some(load.to_string())),
4083            display_name: description,
4084            start_name: None,
4085        });
4086    }
4087
4088    services
4089}
4090
4091// ── health_report ─────────────────────────────────────────────────────────────
4092
4093/// Synthesized system health report — runs multiple checks and returns a
4094/// plain-English tiered verdict suitable for both developers and non-technical
4095/// users who just want to know if their machine is okay.
4096fn inspect_health_report() -> Result<String, String> {
4097    let mut needs_fix: Vec<String> = Vec::new();
4098    let mut watch: Vec<String> = Vec::new();
4099    let mut good: Vec<String> = Vec::new();
4100    let mut tips: Vec<String> = Vec::new();
4101
4102    health_check_disk(&mut needs_fix, &mut watch, &mut good);
4103    health_check_memory(&mut watch, &mut good);
4104    health_check_network(&mut needs_fix, &mut watch, &mut good);
4105    health_check_pending_reboot(&mut watch, &mut good);
4106    health_check_services(&mut needs_fix, &mut watch, &mut good);
4107    health_check_thermal(&mut watch, &mut good);
4108    health_check_tools(&mut watch, &mut good, &mut tips);
4109    health_check_recent_errors(&mut watch, &mut tips);
4110
4111    let overall = if !needs_fix.is_empty() {
4112        "ACTION REQUIRED"
4113    } else if !watch.is_empty() {
4114        "WORTH A LOOK"
4115    } else {
4116        "ALL GOOD"
4117    };
4118
4119    let mut out = format!("System Health Report — {overall}\n\n");
4120
4121    if !needs_fix.is_empty() {
4122        out.push_str("Needs fixing:\n");
4123        for item in &needs_fix {
4124            out.push_str(&format!("  [!] {item}\n"));
4125        }
4126        out.push('\n');
4127    }
4128    if !watch.is_empty() {
4129        out.push_str("Worth watching:\n");
4130        for item in &watch {
4131            out.push_str(&format!("  [-] {item}\n"));
4132        }
4133        out.push('\n');
4134    }
4135    if !good.is_empty() {
4136        out.push_str("Looking good:\n");
4137        for item in &good {
4138            out.push_str(&format!("  [+] {item}\n"));
4139        }
4140        out.push('\n');
4141    }
4142    if !tips.is_empty() {
4143        out.push_str("To dig deeper:\n");
4144        for tip in &tips {
4145            out.push_str(&format!("  {tip}\n"));
4146        }
4147    }
4148
4149    Ok(out.trim_end().to_string())
4150}
4151
4152fn health_check_disk(needs_fix: &mut Vec<String>, watch: &mut Vec<String>, good: &mut Vec<String>) {
4153    #[cfg(target_os = "windows")]
4154    {
4155        let script = r#"try {
4156    $d = Get-PSDrive C -ErrorAction Stop
4157    "$($d.Free)|$($d.Used)"
4158} catch { "ERR" }"#;
4159        if let Ok(out) = Command::new("powershell")
4160            .args(["-NoProfile", "-Command", script])
4161            .output()
4162        {
4163            let text = String::from_utf8_lossy(&out.stdout);
4164            let text = text.trim();
4165            if !text.starts_with("ERR") {
4166                let parts: Vec<&str> = text.split('|').collect();
4167                if parts.len() == 2 {
4168                    let free_bytes: u64 = parts[0].trim().parse().unwrap_or(0);
4169                    let used_bytes: u64 = parts[1].trim().parse().unwrap_or(0);
4170                    let total = free_bytes + used_bytes;
4171                    let free_gb = free_bytes / 1_073_741_824;
4172                    let pct_free = if total > 0 {
4173                        (free_bytes as f64 / total as f64 * 100.0) as u64
4174                    } else {
4175                        0
4176                    };
4177                    let msg = format!("Disk: {free_gb} GB free on C: ({pct_free}% available)");
4178                    if free_gb < 5 {
4179                        needs_fix.push(format!(
4180                            "{msg} — very low. Free up space or your system may slow down or stop working."
4181                        ));
4182                    } else if free_gb < 15 {
4183                        watch.push(format!("{msg} — getting low, consider cleaning up."));
4184                    } else {
4185                        good.push(msg);
4186                    }
4187                    return;
4188                }
4189            }
4190        }
4191        watch.push("Disk: could not read free space from C: drive.".to_string());
4192    }
4193
4194    #[cfg(not(target_os = "windows"))]
4195    {
4196        if let Ok(out) = Command::new("df").args(["-BG", "/"]).output() {
4197            let text = String::from_utf8_lossy(&out.stdout);
4198            for line in text.lines().skip(1) {
4199                let cols: Vec<&str> = line.split_whitespace().collect();
4200                if cols.len() >= 5 {
4201                    let avail_str = cols[3].trim_end_matches('G');
4202                    let use_pct = cols[4].trim_end_matches('%');
4203                    let avail_gb: u64 = avail_str.parse().unwrap_or(0);
4204                    let used_pct: u64 = use_pct.parse().unwrap_or(0);
4205                    let msg = format!("Disk: {avail_gb} GB free on / ({used_pct}% used)");
4206                    if avail_gb < 5 {
4207                        needs_fix.push(format!(
4208                            "{msg} — very low. Free up space to prevent system issues."
4209                        ));
4210                    } else if avail_gb < 15 {
4211                        watch.push(format!("{msg} — getting low."));
4212                    } else {
4213                        good.push(msg);
4214                    }
4215                    return;
4216                }
4217            }
4218        }
4219        watch.push("Disk: could not determine free space.".to_string());
4220    }
4221}
4222
4223fn health_check_memory(watch: &mut Vec<String>, good: &mut Vec<String>) {
4224    #[cfg(target_os = "windows")]
4225    {
4226        let script = r#"try {
4227    $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
4228    "$($os.FreePhysicalMemory)|$($os.TotalVisibleMemorySize)"
4229} catch { "ERR" }"#;
4230        if let Ok(out) = Command::new("powershell")
4231            .args(["-NoProfile", "-Command", script])
4232            .output()
4233        {
4234            let text = String::from_utf8_lossy(&out.stdout);
4235            let text = text.trim();
4236            if !text.starts_with("ERR") {
4237                let parts: Vec<&str> = text.split('|').collect();
4238                if parts.len() == 2 {
4239                    let free_kb: u64 = parts[0].trim().parse().unwrap_or(0);
4240                    let total_kb: u64 = parts[1].trim().parse().unwrap_or(0);
4241                    if total_kb > 0 {
4242                        let free_gb = free_kb / 1_048_576;
4243                        let total_gb = total_kb / 1_048_576;
4244                        let free_pct = free_kb * 100 / total_kb;
4245                        let msg = format!(
4246                            "RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)"
4247                        );
4248                        if free_pct < 10 {
4249                            watch.push(format!(
4250                                "{msg} — very low. Close unused apps to free up memory."
4251                            ));
4252                        } else if free_pct < 25 {
4253                            watch.push(format!("{msg} — running a bit low."));
4254                        } else {
4255                            good.push(msg);
4256                        }
4257                        return;
4258                    }
4259                }
4260            }
4261        }
4262    }
4263
4264    #[cfg(not(target_os = "windows"))]
4265    {
4266        if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4267            let mut total_kb = 0u64;
4268            let mut avail_kb = 0u64;
4269            for line in content.lines() {
4270                if line.starts_with("MemTotal:") {
4271                    total_kb = line
4272                        .split_whitespace()
4273                        .nth(1)
4274                        .and_then(|v| v.parse().ok())
4275                        .unwrap_or(0);
4276                } else if line.starts_with("MemAvailable:") {
4277                    avail_kb = line
4278                        .split_whitespace()
4279                        .nth(1)
4280                        .and_then(|v| v.parse().ok())
4281                        .unwrap_or(0);
4282                }
4283            }
4284            if total_kb > 0 {
4285                let free_gb = avail_kb / 1_048_576;
4286                let total_gb = total_kb / 1_048_576;
4287                let free_pct = avail_kb * 100 / total_kb;
4288                let msg =
4289                    format!("RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)");
4290                if free_pct < 10 {
4291                    watch.push(format!("{msg} — very low. Close unused apps."));
4292                } else if free_pct < 25 {
4293                    watch.push(format!("{msg} — running a bit low."));
4294                } else {
4295                    good.push(msg);
4296                }
4297            }
4298        }
4299    }
4300}
4301
4302/// Try running `cmd --arg` via PATH first, then via a known install-path fallback.
4303/// Prevents false "not installed" reports when the process PATH omits tool directories
4304/// (e.g. ~/.cargo/bin missing from a shortcut-launched or headless session).
4305fn probe_tool(cmd: &str, arg: &str) -> bool {
4306    if Command::new(cmd)
4307        .arg(arg)
4308        .stdout(std::process::Stdio::null())
4309        .stderr(std::process::Stdio::null())
4310        .status()
4311        .map(|s| s.success())
4312        .unwrap_or(false)
4313    {
4314        return true;
4315    }
4316    // Fallback: well-known Windows install locations for tools that live outside system32.
4317    #[cfg(windows)]
4318    {
4319        let home = std::env::var("USERPROFILE").unwrap_or_default();
4320        let fallback: Option<String> = match cmd {
4321            "cargo" | "rustc" | "rustup" => Some(format!(r"{}\\.cargo\\bin\\{}.exe", home, cmd)),
4322            "node" => Some(r"C:\Program Files\nodejs\node.exe".to_string()),
4323            "npm" => Some(format!(r"C:\Program Files\nodejs\npm.cmd")),
4324            _ => None,
4325        };
4326        if let Some(path) = fallback {
4327            return Command::new(&path)
4328                .arg(arg)
4329                .stdout(std::process::Stdio::null())
4330                .stderr(std::process::Stdio::null())
4331                .status()
4332                .map(|s| s.success())
4333                .unwrap_or(false);
4334        }
4335    }
4336    false
4337}
4338
4339fn health_check_tools(watch: &mut Vec<String>, good: &mut Vec<String>, tips: &mut Vec<String>) {
4340    let tool_checks: &[(&str, &str, &str)] = &[
4341        ("git", "--version", "Git"),
4342        ("cargo", "--version", "Rust / Cargo"),
4343        ("node", "--version", "Node.js"),
4344        ("python", "--version", "Python"),
4345        ("python3", "--version", "Python 3"),
4346        ("npm", "--version", "npm"),
4347    ];
4348
4349    let mut found: Vec<String> = Vec::new();
4350    let mut missing: Vec<String> = Vec::new();
4351    let mut python_found = false;
4352
4353    for (cmd, arg, label) in tool_checks {
4354        if cmd.starts_with("python") && python_found {
4355            continue;
4356        }
4357        let ok = probe_tool(cmd, arg);
4358        if ok {
4359            found.push((*label).to_string());
4360            if cmd.starts_with("python") {
4361                python_found = true;
4362            }
4363        } else if !cmd.starts_with("python") || !python_found {
4364            missing.push((*label).to_string());
4365        }
4366    }
4367
4368    if !found.is_empty() {
4369        good.push(format!("Dev tools found: {}", found.join(", ")));
4370    }
4371    if !missing.is_empty() {
4372        watch.push(format!(
4373            "Not installed (or not on PATH): {} — only matters if you need them",
4374            missing.join(", ")
4375        ));
4376        tips.push(
4377            "Run inspect_host(topic=\"toolchains\") for exact version details on all dev tools."
4378                .to_string(),
4379        );
4380    }
4381}
4382
4383fn health_check_recent_errors(watch: &mut Vec<String>, tips: &mut Vec<String>) {
4384    #[cfg(target_os = "windows")]
4385    {
4386        let script = r#"try {
4387    $cutoff = (Get-Date).AddHours(-24)
4388    $count = (Get-WinEvent -FilterHashtable @{LogName='Application','System'; Level=1,2,3; StartTime=$cutoff} -MaxEvents 200 -ErrorAction SilentlyContinue | Measure-Object).Count
4389    $count
4390} catch { "0" }"#;
4391        if let Ok(out) = Command::new("powershell")
4392            .args(["-NoProfile", "-Command", script])
4393            .output()
4394        {
4395            let text = String::from_utf8_lossy(&out.stdout);
4396            let count: u64 = text.trim().parse().unwrap_or(0);
4397            if count > 0 {
4398                watch.push(format!(
4399                    "{count} critical/error event{} in Windows event logs in the last 24 hours.",
4400                    if count == 1 { "" } else { "s" }
4401                ));
4402                tips.push(
4403                    "Run inspect_host(topic=\"log_check\") to see the actual error messages."
4404                        .to_string(),
4405                );
4406            }
4407        }
4408    }
4409
4410    #[cfg(not(target_os = "windows"))]
4411    {
4412        if let Ok(out) = Command::new("journalctl")
4413            .args(["-p", "3", "-n", "1", "--no-pager", "--quiet"])
4414            .output()
4415        {
4416            let text = String::from_utf8_lossy(&out.stdout);
4417            if !text.trim().is_empty() {
4418                watch.push("Critical/error entries found in the system journal.".to_string());
4419                tips.push(
4420                    "Run inspect_host(topic=\"log_check\") to see recent errors.".to_string(),
4421                );
4422            }
4423        }
4424    }
4425}
4426
4427fn health_check_network(
4428    needs_fix: &mut Vec<String>,
4429    watch: &mut Vec<String>,
4430    good: &mut Vec<String>,
4431) {
4432    #[cfg(target_os = "windows")]
4433    {
4434        // Use .NET Ping directly — PS5.1 compatible, 2-second timeout.
4435        let script = r#"try {
4436    $ping = New-Object System.Net.NetworkInformation.Ping
4437    $r = $ping.Send("1.1.1.1", 2000)
4438    if ($r.Status -eq 'Success') { "OK|$($r.RoundtripTime)" } else { "FAIL" }
4439} catch { "FAIL" }"#;
4440        if let Ok(out) = Command::new("powershell")
4441            .args(["-NoProfile", "-Command", script])
4442            .output()
4443        {
4444            let text = String::from_utf8_lossy(&out.stdout);
4445            let text = text.trim();
4446            if text.starts_with("OK") {
4447                let latency = text.split('|').nth(1).unwrap_or("?");
4448                let latency_ms: u64 = latency.parse().unwrap_or(0);
4449                let msg = format!("Internet connectivity: reachable ({}ms RTT)", latency_ms);
4450                if latency_ms > 300 {
4451                    watch.push(format!("{msg} — high latency, may indicate network issue."));
4452                } else {
4453                    good.push(msg);
4454                }
4455            } else {
4456                needs_fix.push(
4457                    "Internet connectivity: unreachable — could not ping 1.1.1.1. \
4458                     Check adapter, gateway, or DNS."
4459                        .to_string(),
4460                );
4461            }
4462            return;
4463        }
4464        watch.push("Network: could not run connectivity check.".to_string());
4465    }
4466
4467    #[cfg(not(target_os = "windows"))]
4468    {
4469        let _ = watch;
4470        let ok = Command::new("ping")
4471            .args(["-c", "1", "-W", "2", "1.1.1.1"])
4472            .stdout(std::process::Stdio::null())
4473            .stderr(std::process::Stdio::null())
4474            .status()
4475            .map(|s| s.success())
4476            .unwrap_or(false);
4477        if ok {
4478            good.push("Internet connectivity: reachable.".to_string());
4479        } else {
4480            needs_fix.push("Internet connectivity: unreachable — cannot ping 1.1.1.1.".to_string());
4481        }
4482    }
4483}
4484
4485fn health_check_pending_reboot(watch: &mut Vec<String>, good: &mut Vec<String>) {
4486    #[cfg(target_os = "windows")]
4487    {
4488        let script = r#"try {
4489    $pending = $false
4490    $reasons = @()
4491    if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending' -ErrorAction SilentlyContinue) {
4492        $pending = $true; $reasons += 'CBS/component update'
4493    }
4494    if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired' -ErrorAction SilentlyContinue) {
4495        $pending = $true; $reasons += 'Windows Update'
4496    }
4497    $pfr = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue)
4498    if ($pfr -and $pfr.PendingFileRenameOperations) {
4499        $pending = $true; $reasons += 'file rename ops'
4500    }
4501    if ($pending) { "PENDING|$($reasons -join ',')" } else { "OK" }
4502} catch { "OK" }"#;
4503        if let Ok(out) = Command::new("powershell")
4504            .args(["-NoProfile", "-Command", script])
4505            .output()
4506        {
4507            let text = String::from_utf8_lossy(&out.stdout);
4508            let text = text.trim();
4509            if text.starts_with("PENDING") {
4510                let reasons = text.split('|').nth(1).unwrap_or("unknown reason");
4511                watch.push(format!(
4512                    "Pending reboot required ({reasons}) — restart when convenient to apply changes."
4513                ));
4514            } else {
4515                good.push("No pending reboot.".to_string());
4516            }
4517        }
4518    }
4519
4520    #[cfg(not(target_os = "windows"))]
4521    {
4522        // Linux: check if a kernel update is pending (requires reboot to take effect)
4523        if std::path::Path::new("/var/run/reboot-required").exists() {
4524            watch.push(
4525                "Pending reboot required — a kernel or package update needs a restart.".to_string(),
4526            );
4527        } else {
4528            good.push("No pending reboot.".to_string());
4529        }
4530    }
4531}
4532
4533fn health_check_services(
4534    needs_fix: &mut Vec<String>,
4535    watch: &mut Vec<String>,
4536    good: &mut Vec<String>,
4537) {
4538    #[cfg(not(target_os = "windows"))]
4539    let _ = (&needs_fix, &good);
4540    #[cfg(target_os = "windows")]
4541    let _ = &watch;
4542
4543    #[cfg(target_os = "windows")]
4544    {
4545        // Only checks services whose being stopped indicates a real system problem.
4546        let script = r#"try {
4547    $names = @('EventLog','WinDefend','Dnscache')
4548    $stopped = @()
4549    foreach ($n in $names) {
4550        $s = Get-Service $n -ErrorAction SilentlyContinue
4551        if ($s -and $s.Status -ne 'Running') { $stopped += $n }
4552    }
4553    if ($stopped.Count -gt 0) { "STOPPED|$($stopped -join ',')" } else { "OK" }
4554} catch { "OK" }"#;
4555        if let Ok(out) = Command::new("powershell")
4556            .args(["-NoProfile", "-Command", script])
4557            .output()
4558        {
4559            let text = String::from_utf8_lossy(&out.stdout);
4560            let text = text.trim();
4561            if text.starts_with("STOPPED") {
4562                let names = text.split('|').nth(1).unwrap_or("unknown");
4563                needs_fix.push(format!(
4564                    "Critical service(s) not running: {names} — these should always be active."
4565                ));
4566            } else {
4567                good.push("Core services (Event Log, Defender, DNS) all running.".to_string());
4568            }
4569        }
4570    }
4571
4572    #[cfg(not(target_os = "windows"))]
4573    {
4574        // Linux: check systemd failed units
4575        if let Ok(out) = Command::new("systemctl")
4576            .args(["--failed", "--no-legend", "--plain"])
4577            .output()
4578        {
4579            let text = String::from_utf8_lossy(&out.stdout);
4580            let failed: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
4581            if !failed.is_empty() {
4582                watch.push(format!(
4583                    "{} failed systemd unit(s): {}",
4584                    failed.len(),
4585                    failed.join(", ")
4586                ));
4587            } else {
4588                good.push("No failed systemd units.".to_string());
4589            }
4590        }
4591    }
4592}
4593
4594fn health_check_thermal(watch: &mut Vec<String>, good: &mut Vec<String>) {
4595    #[cfg(target_os = "windows")]
4596    {
4597        // WMI thermal zones — best-effort, silently skip if unavailable or requires elevation.
4598        let script = r#"try {
4599    $zones = Get-WmiObject -Namespace "root/wmi" -Class MSAcpi_ThermalZoneTemperature -ErrorAction Stop
4600    $temps = $zones | ForEach-Object { [math]::Round(($_.CurrentTemperature - 2732) / 10, 1) }
4601    $max = ($temps | Measure-Object -Maximum).Maximum
4602    "$max"
4603} catch { "NA" }"#;
4604        if let Ok(out) = Command::new("powershell")
4605            .args(["-NoProfile", "-Command", script])
4606            .output()
4607        {
4608            let text = String::from_utf8_lossy(&out.stdout);
4609            let text = text.trim();
4610            if text != "NA" && !text.is_empty() {
4611                if let Ok(temp) = text.parse::<f64>() {
4612                    let msg = format!("CPU thermal: {temp:.0}°C peak zone temperature");
4613                    if temp >= 90.0 {
4614                        watch.push(format!("{msg} — very high, check cooling and airflow."));
4615                    } else if temp >= 75.0 {
4616                        watch.push(format!(
4617                            "{msg} — elevated under load, monitor for throttling."
4618                        ));
4619                    } else {
4620                        good.push(format!("{msg} — normal."));
4621                    }
4622                }
4623            }
4624            // If NA or unparseable, skip silently — thermal WMI often needs admin.
4625        }
4626    }
4627
4628    #[cfg(not(target_os = "windows"))]
4629    {
4630        // Linux: read first available hwmon temp input
4631        let paths = [
4632            "/sys/class/thermal/thermal_zone0/temp",
4633            "/sys/class/hwmon/hwmon0/temp1_input",
4634        ];
4635        for path in &paths {
4636            if let Ok(content) = std::fs::read_to_string(path) {
4637                if let Ok(raw) = content.trim().parse::<u64>() {
4638                    let temp_c = raw / 1000;
4639                    let msg = format!("CPU thermal: {temp_c}°C");
4640                    if temp_c >= 90 {
4641                        watch.push(format!("{msg} — very high, check cooling."));
4642                    } else if temp_c >= 75 {
4643                        watch.push(format!("{msg} — elevated under load."));
4644                    } else {
4645                        good.push(format!("{msg} — normal."));
4646                    }
4647                    return;
4648                }
4649            }
4650        }
4651    }
4652}
4653
4654// ── log_check ─────────────────────────────────────────────────────────────────
4655
4656fn inspect_log_check(lookback_hours: Option<u32>, max_entries: usize) -> Result<String, String> {
4657    let mut out = String::from("Host inspection: log_check\n\n");
4658
4659    #[cfg(target_os = "windows")]
4660    {
4661        // Pull recent critical/error events from Windows Application and System logs.
4662        let hours = lookback_hours.unwrap_or(24);
4663        out.push_str(&format!(
4664            "Checking System/Application logs from the last {} hours...\n\n",
4665            hours
4666        ));
4667
4668        let n = max_entries.clamp(1, 50);
4669        let script = format!(
4670            r#"try {{
4671    $events = Get-WinEvent -FilterHashtable @{{LogName='Application','System'; Level=1,2,3; StartTime=(Get-Date).AddHours(-{hours})}} -MaxEvents 100 -ErrorAction SilentlyContinue
4672    if (-not $events) {{ "NO_EVENTS"; exit }}
4673    $events | Select-Object -First {n} | ForEach-Object {{
4674        $line = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + '|' + $_.LevelDisplayName + '|' + $_.ProviderName + '|' + (($_.Message -split '[\r\n]')[0].Trim())
4675        $line
4676    }}
4677}} catch {{ "ERROR:" + $_.Exception.Message }}"#,
4678            hours = hours,
4679            n = n
4680        );
4681        let output = Command::new("powershell")
4682            .args(["-NoProfile", "-Command", &script])
4683            .output()
4684            .map_err(|e| format!("log_check: failed to run PowerShell: {e}"))?;
4685
4686        let raw = String::from_utf8_lossy(&output.stdout);
4687        let text = raw.trim();
4688
4689        if text.is_empty() || text == "NO_EVENTS" {
4690            out.push_str("No critical or error events found in Application/System logs.\n");
4691            return Ok(out.trim_end().to_string());
4692        }
4693        if text.starts_with("ERROR:") {
4694            out.push_str(&format!("Warning: event log query returned: {text}\n"));
4695            return Ok(out.trim_end().to_string());
4696        }
4697
4698        let mut count = 0usize;
4699        for line in text.lines() {
4700            let parts: Vec<&str> = line.splitn(4, '|').collect();
4701            if parts.len() == 4 {
4702                let (time, level, source, msg) = (parts[0], parts[1], parts[2], parts[3]);
4703                out.push_str(&format!("[{time}] [{level}] {source}: {msg}\n"));
4704                count += 1;
4705            }
4706        }
4707        out.push_str(&format!(
4708            "\nEvents shown: {count} (critical/error from Application + System logs)\n"
4709        ));
4710    }
4711
4712    #[cfg(not(target_os = "windows"))]
4713    {
4714        let _ = lookback_hours;
4715        // Use journalctl on Linux/macOS if available.
4716        let n = max_entries.clamp(1, 50).to_string();
4717        let output = Command::new("journalctl")
4718            .args(["-p", "3", "-n", &n, "--no-pager", "--output=short-precise"])
4719            .output();
4720
4721        match output {
4722            Ok(o) if o.status.success() => {
4723                let text = String::from_utf8_lossy(&o.stdout);
4724                let trimmed = text.trim();
4725                if trimmed.is_empty() || trimmed.contains("No entries") {
4726                    out.push_str("No critical or error entries found in the system journal.\n");
4727                } else {
4728                    out.push_str(trimmed);
4729                    out.push('\n');
4730                    out.push_str("\n(source: journalctl -p 3 = critical/alert/emergency/error)\n");
4731                }
4732            }
4733            _ => {
4734                // Fallback: check /var/log/syslog or /var/log/messages
4735                let log_paths = ["/var/log/syslog", "/var/log/messages"];
4736                let mut found = false;
4737                for log_path in &log_paths {
4738                    if let Ok(content) = std::fs::read_to_string(log_path) {
4739                        let lines: Vec<&str> = content.lines().collect();
4740                        let tail: Vec<&str> = lines
4741                            .iter()
4742                            .rev()
4743                            .filter(|l| {
4744                                let l_lower = l.to_ascii_lowercase();
4745                                l_lower.contains("error") || l_lower.contains("crit")
4746                            })
4747                            .take(max_entries)
4748                            .copied()
4749                            .collect::<Vec<_>>()
4750                            .into_iter()
4751                            .rev()
4752                            .collect();
4753                        if !tail.is_empty() {
4754                            out.push_str(&format!("Source: {log_path}\n"));
4755                            for l in &tail {
4756                                out.push_str(l);
4757                                out.push('\n');
4758                            }
4759                            found = true;
4760                            break;
4761                        }
4762                    }
4763                }
4764                if !found {
4765                    out.push_str(
4766                        "journalctl not found and no readable syslog detected on this system.\n",
4767                    );
4768                }
4769            }
4770        }
4771    }
4772
4773    Ok(out.trim_end().to_string())
4774}
4775
4776// ── startup_items ─────────────────────────────────────────────────────────────
4777
4778fn inspect_startup_items(max_entries: usize) -> Result<String, String> {
4779    let mut out = String::from("Host inspection: startup_items\n\n");
4780
4781    #[cfg(target_os = "windows")]
4782    {
4783        // Query both HKLM and HKCU Run keys.
4784        let script = r#"
4785$hives = @(
4786    @{Hive='HKLM'; Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4787    @{Hive='HKCU'; Path='HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4788    @{Hive='HKLM (32-bit)'; Path='HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'}
4789)
4790foreach ($h in $hives) {
4791    try {
4792        $props = Get-ItemProperty -Path $h.Path -ErrorAction Stop
4793        $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
4794            "$($h.Hive)|$($_.Name)|$($_.Value)"
4795        }
4796    } catch {}
4797}
4798"#;
4799        let output = Command::new("powershell")
4800            .args(["-NoProfile", "-Command", script])
4801            .output()
4802            .map_err(|e| format!("startup_items: failed to run PowerShell: {e}"))?;
4803
4804        let raw = String::from_utf8_lossy(&output.stdout);
4805        let text = raw.trim();
4806
4807        let entries: Vec<(String, String, String)> = text
4808            .lines()
4809            .filter_map(|l| {
4810                let parts: Vec<&str> = l.splitn(3, '|').collect();
4811                if parts.len() == 3 {
4812                    Some((
4813                        parts[0].to_string(),
4814                        parts[1].to_string(),
4815                        parts[2].to_string(),
4816                    ))
4817                } else {
4818                    None
4819                }
4820            })
4821            .take(max_entries)
4822            .collect();
4823
4824        if entries.is_empty() {
4825            out.push_str("No startup entries found in the Windows Run registry keys.\n");
4826        } else {
4827            out.push_str("Registry run keys (programs that start with Windows):\n\n");
4828            let mut last_hive = String::new();
4829            for (hive, name, value) in &entries {
4830                if *hive != last_hive {
4831                    out.push_str(&format!("[{}]\n", hive));
4832                    last_hive = hive.clone();
4833                }
4834                // Truncate very long values (paths with many args)
4835                let display = if value.len() > 100 {
4836                    format!("{}…", &value[..100])
4837                } else {
4838                    value.clone()
4839                };
4840                out.push_str(&format!("  {name}: {display}\n"));
4841            }
4842            out.push_str(&format!("\nTotal startup entries: {}\n", entries.len()));
4843        }
4844
4845        // 3. Unified Startup Command check (Task Manager style)
4846        let unified_script = r#"Get-CimInstance Win32_StartupCommand | ForEach-Object { "  $($_.Name): $($_.Command) ($($_.Location))" }"#;
4847        if let Ok(unified_out) = Command::new("powershell")
4848            .args(["-NoProfile", "-Command", unified_script])
4849            .output()
4850        {
4851            let unified_text = String::from_utf8_lossy(&unified_out.stdout);
4852            let trimmed = unified_text.trim();
4853            if !trimmed.is_empty() {
4854                out.push_str("\n=== Unified Startup Commands (WMI) ===\n");
4855                out.push_str(trimmed);
4856                out.push('\n');
4857            }
4858        }
4859    }
4860
4861    #[cfg(not(target_os = "windows"))]
4862    {
4863        // On Linux: systemd enabled services + cron @reboot entries.
4864        let output = Command::new("systemctl")
4865            .args([
4866                "list-unit-files",
4867                "--type=service",
4868                "--state=enabled",
4869                "--no-legend",
4870                "--no-pager",
4871                "--plain",
4872            ])
4873            .output();
4874
4875        match output {
4876            Ok(o) if o.status.success() => {
4877                let text = String::from_utf8_lossy(&o.stdout);
4878                let services: Vec<&str> = text
4879                    .lines()
4880                    .filter(|l| !l.trim().is_empty())
4881                    .take(max_entries)
4882                    .collect();
4883                if services.is_empty() {
4884                    out.push_str("No enabled systemd services found.\n");
4885                } else {
4886                    out.push_str("Enabled systemd services (run at boot):\n\n");
4887                    for s in &services {
4888                        out.push_str(&format!("  {s}\n"));
4889                    }
4890                    out.push_str(&format!(
4891                        "\nShowing {} of enabled services.\n",
4892                        services.len()
4893                    ));
4894                }
4895            }
4896            _ => {
4897                out.push_str(
4898                    "systemctl not found on this system. Cannot enumerate startup services.\n",
4899                );
4900            }
4901        }
4902
4903        // Check @reboot cron entries.
4904        if let Ok(cron_out) = Command::new("crontab").args(["-l"]).output() {
4905            let cron_text = String::from_utf8_lossy(&cron_out.stdout);
4906            let reboot_entries: Vec<&str> = cron_text
4907                .lines()
4908                .filter(|l| l.trim_start().starts_with("@reboot"))
4909                .collect();
4910            if !reboot_entries.is_empty() {
4911                out.push_str("\nCron @reboot entries:\n");
4912                for e in reboot_entries {
4913                    out.push_str(&format!("  {e}\n"));
4914                }
4915            }
4916        }
4917    }
4918
4919    Ok(out.trim_end().to_string())
4920}
4921
4922fn inspect_os_config() -> Result<String, String> {
4923    let mut out = String::from("Host inspection: OS Configuration\n\n");
4924
4925    #[cfg(target_os = "windows")]
4926    {
4927        // Power Plan
4928        if let Ok(power_out) = Command::new("powercfg").args(["/getactivescheme"]).output() {
4929            let power_str = String::from_utf8_lossy(&power_out.stdout);
4930            out.push_str("=== Power Plan ===\n");
4931            out.push_str(power_str.trim());
4932            out.push_str("\n\n");
4933        }
4934
4935        // Firewall Status
4936        let fw_script =
4937            "Get-NetFirewallProfile | Format-Table -Property Name, Enabled -AutoSize | Out-String";
4938        if let Ok(fw_out) = Command::new("powershell")
4939            .args(["-NoProfile", "-Command", fw_script])
4940            .output()
4941        {
4942            let fw_str = String::from_utf8_lossy(&fw_out.stdout);
4943            out.push_str("=== Firewall Profiles ===\n");
4944            out.push_str(fw_str.trim());
4945            out.push_str("\n\n");
4946        }
4947
4948        // System Uptime
4949        let uptime_script =
4950            "(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString()";
4951        if let Ok(uptime_out) = Command::new("powershell")
4952            .args(["-NoProfile", "-Command", uptime_script])
4953            .output()
4954        {
4955            let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4956            out.push_str("=== System Uptime (Last Boot) ===\n");
4957            out.push_str(uptime_str.trim());
4958            out.push_str("\n\n");
4959        }
4960    }
4961
4962    #[cfg(not(target_os = "windows"))]
4963    {
4964        // Uptime
4965        if let Ok(uptime_out) = Command::new("uptime").args(["-p"]).output() {
4966            let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4967            out.push_str("=== System Uptime ===\n");
4968            out.push_str(uptime_str.trim());
4969            out.push_str("\n\n");
4970        }
4971
4972        // Firewall (ufw status if available)
4973        if let Ok(ufw_out) = Command::new("ufw").arg("status").output() {
4974            let ufw_str = String::from_utf8_lossy(&ufw_out.stdout);
4975            if !ufw_str.trim().is_empty() {
4976                out.push_str("=== Firewall (UFW) ===\n");
4977                out.push_str(ufw_str.trim());
4978                out.push_str("\n\n");
4979            }
4980        }
4981    }
4982    Ok(out.trim_end().to_string())
4983}
4984
4985pub async fn resolve_host_issue(args: &Value) -> Result<String, String> {
4986    let action = args
4987        .get("action")
4988        .and_then(|v| v.as_str())
4989        .ok_or_else(|| "Missing required argument: 'action'".to_string())?;
4990
4991    let target = args
4992        .get("target")
4993        .and_then(|v| v.as_str())
4994        .unwrap_or("")
4995        .trim();
4996
4997    if target.is_empty() && action != "clear_temp" {
4998        return Err("Missing required argument: 'target' for this action".to_string());
4999    }
5000
5001    match action {
5002        "install_package" => {
5003            #[cfg(target_os = "windows")]
5004            {
5005                let cmd = format!("winget install --id {} -e --accept-package-agreements --accept-source-agreements", target);
5006                match Command::new("powershell")
5007                    .args(["-NoProfile", "-Command", &cmd])
5008                    .output()
5009                {
5010                    Ok(out) => Ok(format!(
5011                        "Executed remediation (winget install):\n{}",
5012                        String::from_utf8_lossy(&out.stdout)
5013                    )),
5014                    Err(e) => Err(format!("Failed to run winget: {}", e)),
5015                }
5016            }
5017            #[cfg(not(target_os = "windows"))]
5018            {
5019                Err(
5020                    "install_package via wrapper is only supported on Windows currently (winget)"
5021                        .to_string(),
5022                )
5023            }
5024        }
5025        "restart_service" => {
5026            #[cfg(target_os = "windows")]
5027            {
5028                let cmd = format!("Restart-Service -Name {} -Force", target);
5029                match Command::new("powershell")
5030                    .args(["-NoProfile", "-Command", &cmd])
5031                    .output()
5032                {
5033                    Ok(out) => {
5034                        let err_str = String::from_utf8_lossy(&out.stderr);
5035                        if !err_str.is_empty() {
5036                            return Err(format!("Error restarting service:\n{}", err_str));
5037                        }
5038                        Ok(format!("Successfully restarted service: {}", target))
5039                    }
5040                    Err(e) => Err(format!("Failed to restart service: {}", e)),
5041                }
5042            }
5043            #[cfg(not(target_os = "windows"))]
5044            {
5045                Err(
5046                    "restart_service via wrapper is only supported on Windows currently"
5047                        .to_string(),
5048                )
5049            }
5050        }
5051        "clear_temp" => {
5052            #[cfg(target_os = "windows")]
5053            {
5054                let cmd = "Remove-Item -Path \"$env:TEMP\\*\" -Recurse -Force -ErrorAction SilentlyContinue";
5055                match Command::new("powershell")
5056                    .args(["-NoProfile", "-Command", cmd])
5057                    .output()
5058                {
5059                    Ok(_) => Ok("Successfully cleared temporary files".to_string()),
5060                    Err(e) => Err(format!("Failed to clear temp: {}", e)),
5061                }
5062            }
5063            #[cfg(not(target_os = "windows"))]
5064            {
5065                Err("clear_temp via wrapper is only supported on Windows currently".to_string())
5066            }
5067        }
5068        other => Err(format!("Unknown remediation action: {}", other)),
5069    }
5070}
5071
5072// ── storage ───────────────────────────────────────────────────────────────────
5073
5074fn inspect_storage(max_entries: usize) -> Result<String, String> {
5075    let mut out = String::from("Host inspection: storage\n\n");
5076    let _ = max_entries; // used by non-Windows branch
5077
5078    // ── Drive overview ────────────────────────────────────────────────────────
5079    out.push_str("Drives:\n");
5080
5081    #[cfg(target_os = "windows")]
5082    {
5083        let script = r#"Get-PSDrive -PSProvider 'FileSystem' | ForEach-Object {
5084    $free = $_.Free
5085    $used = $_.Used
5086    if ($free -eq $null) { $free = 0 }
5087    if ($used -eq $null) { $used = 0 }
5088    $total = $free + $used
5089    "$($_.Name)|$free|$used|$total"
5090}"#;
5091        match Command::new("powershell")
5092            .args(["-NoProfile", "-Command", script])
5093            .output()
5094        {
5095            Ok(o) => {
5096                let text = String::from_utf8_lossy(&o.stdout);
5097                let mut drive_count = 0usize;
5098                for line in text.lines() {
5099                    let parts: Vec<&str> = line.trim().split('|').collect();
5100                    if parts.len() == 4 {
5101                        let name = parts[0];
5102                        let free: u64 = parts[1].parse().unwrap_or(0);
5103                        let total: u64 = parts[3].parse().unwrap_or(0);
5104                        if total == 0 {
5105                            continue;
5106                        }
5107                        let free_gb = free / 1_073_741_824;
5108                        let total_gb = total / 1_073_741_824;
5109                        let used_pct = ((total - free) as f64 / total as f64 * 100.0) as u64;
5110                        let bar_len = 20usize;
5111                        let filled = (used_pct as usize * bar_len / 100).min(bar_len);
5112                        let bar: String = "#".repeat(filled) + &".".repeat(bar_len - filled);
5113                        let warn = if free_gb < 5 {
5114                            " [!] CRITICALLY LOW"
5115                        } else if free_gb < 15 {
5116                            " [-] LOW"
5117                        } else {
5118                            ""
5119                        };
5120                        out.push_str(&format!(
5121                            "  {name}:  [{bar}] {used_pct}% used — {free_gb} GB free of {total_gb} GB{warn}\n"
5122                        ));
5123                        drive_count += 1;
5124                    }
5125                }
5126                if drive_count == 0 {
5127                    out.push_str("  (could not enumerate drives)\n");
5128                }
5129            }
5130            Err(e) => out.push_str(&format!("  (drive scan failed: {e})\n")),
5131        }
5132
5133        // ── Real-time Performance (Latency) ──────────────────────────────────
5134        let latency_script = "Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter \"Name='_Total'\" | Select-Object -ExpandProperty AvgDiskQueueLength";
5135        match Command::new("powershell")
5136            .args(["-NoProfile", "-Command", latency_script])
5137            .output()
5138        {
5139            Ok(o) => {
5140                out.push_str("\nReal-time Disk Intensity:\n");
5141                let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5142                if !text.is_empty() {
5143                    out.push_str(&format!("  Average Disk Queue Length: {text}\n"));
5144                    if let Ok(q) = text.parse::<f64>() {
5145                        if q > 2.0 {
5146                            out.push_str(
5147                                "  [!] WARNING: High disk latency detected (Queue Length > 2.0)\n",
5148                            );
5149                        } else {
5150                            out.push_str("  [~] Disk latency is within healthy bounds.\n");
5151                        }
5152                    }
5153                } else {
5154                    out.push_str("  Average Disk Queue Length: unavailable\n");
5155                }
5156            }
5157            Err(_) => {
5158                out.push_str("\nReal-time Disk Intensity:\n");
5159                out.push_str("  Average Disk Queue Length: unavailable\n");
5160            }
5161        }
5162    }
5163
5164    #[cfg(not(target_os = "windows"))]
5165    {
5166        match Command::new("df")
5167            .args(["-h", "--output=target,size,avail,pcent"])
5168            .output()
5169        {
5170            Ok(o) => {
5171                let text = String::from_utf8_lossy(&o.stdout);
5172                let mut count = 0usize;
5173                for line in text.lines().skip(1) {
5174                    let cols: Vec<&str> = line.split_whitespace().collect();
5175                    if cols.len() >= 4 && !cols[0].starts_with("tmpfs") {
5176                        out.push_str(&format!(
5177                            "  {}  size: {}  avail: {}  used: {}\n",
5178                            cols[0], cols[1], cols[2], cols[3]
5179                        ));
5180                        count += 1;
5181                        if count >= max_entries {
5182                            break;
5183                        }
5184                    }
5185                }
5186            }
5187            Err(e) => out.push_str(&format!("  (df failed: {e})\n")),
5188        }
5189    }
5190
5191    // ── Large developer cache directories ─────────────────────────────────────
5192    out.push_str("\nLarge developer cache directories (if present):\n");
5193
5194    #[cfg(target_os = "windows")]
5195    {
5196        let home = std::env::var("USERPROFILE").unwrap_or_default();
5197        let check_dirs: &[(&str, &str)] = &[
5198            ("Temp", r"AppData\Local\Temp"),
5199            ("npm cache", r"AppData\Roaming\npm-cache"),
5200            ("Cargo registry", r".cargo\registry"),
5201            ("Cargo git", r".cargo\git"),
5202            ("pip cache", r"AppData\Local\pip\cache"),
5203            ("Yarn cache", r"AppData\Local\Yarn\Cache"),
5204            (".rustup toolchains", r".rustup\toolchains"),
5205            ("node_modules (home)", r"node_modules"),
5206        ];
5207
5208        let mut found_any = false;
5209        for (label, rel) in check_dirs {
5210            let full = format!(r"{}\{}", home, rel);
5211            let path = std::path::Path::new(&full);
5212            if path.exists() {
5213                // Quick size estimate via PowerShell (non-blocking cap at 5s)
5214                let size_script = format!(
5215                    r#"try {{ $s = (Get-ChildItem -Path '{}' -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum; [math]::Round($s/1MB,0) }} catch {{ '?' }}"#,
5216                    full.replace('\'', "''")
5217                );
5218                let size_mb = Command::new("powershell")
5219                    .args(["-NoProfile", "-Command", &size_script])
5220                    .output()
5221                    .ok()
5222                    .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
5223                    .unwrap_or_else(|| "?".to_string());
5224                out.push_str(&format!("  {label}: {size_mb} MB  ({full})\n"));
5225                found_any = true;
5226            }
5227        }
5228        if !found_any {
5229            out.push_str("  (none of the common cache directories found)\n");
5230        }
5231
5232        out.push_str("\nTip: to reclaim space, run inspect_host(topic=\"fix_plan\", issue=\"free up disk space\")\n");
5233    }
5234
5235    #[cfg(not(target_os = "windows"))]
5236    {
5237        let home = std::env::var("HOME").unwrap_or_default();
5238        let check_dirs: &[(&str, &str)] = &[
5239            ("npm cache", ".npm"),
5240            ("Cargo registry", ".cargo/registry"),
5241            ("pip cache", ".cache/pip"),
5242            (".rustup toolchains", ".rustup/toolchains"),
5243            ("Yarn cache", ".cache/yarn"),
5244        ];
5245        let mut found_any = false;
5246        for (label, rel) in check_dirs {
5247            let full = format!("{}/{}", home, rel);
5248            if std::path::Path::new(&full).exists() {
5249                let size = Command::new("du")
5250                    .args(["-sh", &full])
5251                    .output()
5252                    .ok()
5253                    .map(|o| {
5254                        let s = String::from_utf8_lossy(&o.stdout);
5255                        s.split_whitespace().next().unwrap_or("?").to_string()
5256                    })
5257                    .unwrap_or_else(|| "?".to_string());
5258                out.push_str(&format!("  {label}: {size}  ({full})\n"));
5259                found_any = true;
5260            }
5261        }
5262        if !found_any {
5263            out.push_str("  (none of the common cache directories found)\n");
5264        }
5265    }
5266
5267    Ok(out.trim_end().to_string())
5268}
5269
5270// ── hardware ──────────────────────────────────────────────────────────────────
5271
5272fn inspect_hardware() -> Result<String, String> {
5273    let mut out = String::from("Host inspection: hardware\n\n");
5274
5275    #[cfg(target_os = "windows")]
5276    {
5277        // CPU
5278        let cpu_script = r#"Get-CimInstance Win32_Processor | ForEach-Object {
5279    "$($_.Name.Trim())|$($_.NumberOfCores)|$($_.NumberOfLogicalProcessors)|$([math]::Round($_.MaxClockSpeed/1000,1))"
5280} | Select-Object -First 1"#;
5281        if let Ok(o) = Command::new("powershell")
5282            .args(["-NoProfile", "-Command", cpu_script])
5283            .output()
5284        {
5285            let text = String::from_utf8_lossy(&o.stdout);
5286            let text = text.trim();
5287            let parts: Vec<&str> = text.split('|').collect();
5288            if parts.len() == 4 {
5289                out.push_str(&format!(
5290                    "CPU: {}\n  {} physical cores, {} logical processors, {:.1} GHz\n\n",
5291                    parts[0],
5292                    parts[1],
5293                    parts[2],
5294                    parts[3].parse::<f32>().unwrap_or(0.0)
5295                ));
5296            } else {
5297                out.push_str(&format!("CPU: {text}\n\n"));
5298            }
5299        }
5300
5301        // RAM (total installed + speed)
5302        let ram_script = r#"$sticks = Get-CimInstance Win32_PhysicalMemory
5303$total = ($sticks | Measure-Object Capacity -Sum).Sum / 1GB
5304$speed = ($sticks | Select-Object -First 1).Speed
5305"$([math]::Round($total,0)) GB @ $($speed) MHz ($($sticks.Count) stick(s))""#;
5306        if let Ok(o) = Command::new("powershell")
5307            .args(["-NoProfile", "-Command", ram_script])
5308            .output()
5309        {
5310            let text = String::from_utf8_lossy(&o.stdout);
5311            out.push_str(&format!("RAM: {}\n\n", text.trim().trim_matches('"')));
5312        }
5313
5314        // GPU(s)
5315        let gpu_script = r#"Get-CimInstance Win32_VideoController | ForEach-Object {
5316    "$($_.Name)|$($_.DriverVersion)|$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
5317}"#;
5318        if let Ok(o) = Command::new("powershell")
5319            .args(["-NoProfile", "-Command", gpu_script])
5320            .output()
5321        {
5322            let text = String::from_utf8_lossy(&o.stdout);
5323            let lines: Vec<&str> = text.lines().collect();
5324            if !lines.is_empty() {
5325                out.push_str("GPU(s):\n");
5326                for line in lines.iter().filter(|l| !l.trim().is_empty()) {
5327                    let parts: Vec<&str> = line.trim().split('|').collect();
5328                    if parts.len() == 3 {
5329                        let res = if parts[2] == "x" || parts[2].starts_with('0') {
5330                            String::new()
5331                        } else {
5332                            format!(" — {}@display", parts[2])
5333                        };
5334                        out.push_str(&format!(
5335                            "  {}\n    Driver: {}{}\n",
5336                            parts[0], parts[1], res
5337                        ));
5338                    } else {
5339                        out.push_str(&format!("  {}\n", line.trim()));
5340                    }
5341                }
5342                out.push('\n');
5343            }
5344        }
5345
5346        // Motherboard + BIOS + Virtualization
5347        let mb_script = r#"$mb = Get-CimInstance Win32_BaseBoard
5348$bios = Get-CimInstance Win32_BIOS
5349$cs = Get-CimInstance Win32_ComputerSystem
5350$proc = Get-CimInstance Win32_Processor | Select-Object -First 1
5351$virt = "Hypervisor: $($cs.HypervisorPresent)|SLAT: $($proc.SecondLevelAddressTranslationExtensions)"
5352"$($mb.Manufacturer.Trim()) $($mb.Product.Trim())|BIOS: $($bios.Manufacturer.Trim()) $($bios.SMBIOSBIOSVersion.Trim()) ($($bios.ReleaseDate))|$virt""#;
5353        if let Ok(o) = Command::new("powershell")
5354            .args(["-NoProfile", "-Command", mb_script])
5355            .output()
5356        {
5357            let text = String::from_utf8_lossy(&o.stdout);
5358            let text = text.trim().trim_matches('"');
5359            let parts: Vec<&str> = text.split('|').collect();
5360            if parts.len() == 4 {
5361                out.push_str(&format!(
5362                    "Motherboard: {}\n{}\nVirtualization: {}, {}\n\n",
5363                    parts[0].trim(),
5364                    parts[1].trim(),
5365                    parts[2].trim(),
5366                    parts[3].trim()
5367                ));
5368            }
5369        }
5370
5371        // Display(s)
5372        let disp_script = r#"Get-CimInstance Win32_DesktopMonitor | Where-Object {$_.ScreenWidth -gt 0} | ForEach-Object {
5373    "$($_.Name)|$($_.ScreenWidth)x$($_.ScreenHeight)"
5374}"#;
5375        if let Ok(o) = Command::new("powershell")
5376            .args(["-NoProfile", "-Command", disp_script])
5377            .output()
5378        {
5379            let text = String::from_utf8_lossy(&o.stdout);
5380            let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
5381            if !lines.is_empty() {
5382                out.push_str("Display(s):\n");
5383                for line in &lines {
5384                    let parts: Vec<&str> = line.trim().split('|').collect();
5385                    if parts.len() == 2 {
5386                        out.push_str(&format!("  {} — {}\n", parts[0].trim(), parts[1]));
5387                    }
5388                }
5389            }
5390        }
5391    }
5392
5393    #[cfg(not(target_os = "windows"))]
5394    {
5395        // CPU via /proc/cpuinfo
5396        if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
5397            let model = content
5398                .lines()
5399                .find(|l| l.starts_with("model name"))
5400                .and_then(|l| l.split(':').nth(1))
5401                .map(str::trim)
5402                .unwrap_or("unknown");
5403            let cores = content
5404                .lines()
5405                .filter(|l| l.starts_with("processor"))
5406                .count();
5407            out.push_str(&format!("CPU: {model}\n  {cores} logical processors\n\n"));
5408        }
5409
5410        // RAM
5411        if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
5412            let total_kb: u64 = content
5413                .lines()
5414                .find(|l| l.starts_with("MemTotal:"))
5415                .and_then(|l| l.split_whitespace().nth(1))
5416                .and_then(|v| v.parse().ok())
5417                .unwrap_or(0);
5418            let total_gb = total_kb / 1_048_576;
5419            out.push_str(&format!("RAM: {total_gb} GB total\n\n"));
5420        }
5421
5422        // GPU via lspci
5423        if let Ok(o) = Command::new("lspci").args(["-vmm"]).output() {
5424            let text = String::from_utf8_lossy(&o.stdout);
5425            let gpu_lines: Vec<&str> = text
5426                .lines()
5427                .filter(|l| l.contains("VGA") || l.contains("Display") || l.contains("3D"))
5428                .collect();
5429            if !gpu_lines.is_empty() {
5430                out.push_str("GPU(s):\n");
5431                for l in gpu_lines {
5432                    out.push_str(&format!("  {l}\n"));
5433                }
5434                out.push('\n');
5435            }
5436        }
5437
5438        // DMI/BIOS info
5439        if let Ok(o) = Command::new("dmidecode")
5440            .args(["-t", "baseboard", "-t", "bios"])
5441            .output()
5442        {
5443            let text = String::from_utf8_lossy(&o.stdout);
5444            out.push_str("Motherboard/BIOS:\n");
5445            for line in text
5446                .lines()
5447                .filter(|l| {
5448                    l.contains("Manufacturer:")
5449                        || l.contains("Product Name:")
5450                        || l.contains("Version:")
5451                })
5452                .take(6)
5453            {
5454                out.push_str(&format!("  {}\n", line.trim()));
5455            }
5456        }
5457    }
5458
5459    Ok(out.trim_end().to_string())
5460}
5461
5462// ── updates ───────────────────────────────────────────────────────────────────
5463
5464fn inspect_updates() -> Result<String, String> {
5465    let mut out = String::from("Host inspection: updates\n\n");
5466
5467    #[cfg(target_os = "windows")]
5468    {
5469        // Last installed update via COM
5470        let script = r#"
5471try {
5472    $sess = New-Object -ComObject Microsoft.Update.Session
5473    $searcher = $sess.CreateUpdateSearcher()
5474    $count = $searcher.GetTotalHistoryCount()
5475    if ($count -gt 0) {
5476        $latest = $searcher.QueryHistory(0, 1) | Select-Object -First 1
5477        $latest.Date.ToString("yyyy-MM-dd HH:mm") + "|LAST_INSTALL"
5478    } else { "NONE|LAST_INSTALL" }
5479} catch { "ERROR:" + $_.Exception.Message + "|LAST_INSTALL" }
5480"#;
5481        if let Ok(o) = Command::new("powershell")
5482            .args(["-NoProfile", "-Command", script])
5483            .output()
5484        {
5485            let raw = String::from_utf8_lossy(&o.stdout);
5486            let text = raw.trim();
5487            if text.starts_with("ERROR:") {
5488                out.push_str("Last update install: (unable to query)\n");
5489            } else if text.contains("NONE") {
5490                out.push_str("Last update install: No update history found\n");
5491            } else {
5492                let date = text.replace("|LAST_INSTALL", "");
5493                out.push_str(&format!("Last update install: {date}\n"));
5494            }
5495        }
5496
5497        // Pending updates count
5498        let pending_script = r#"
5499try {
5500    $sess = New-Object -ComObject Microsoft.Update.Session
5501    $searcher = $sess.CreateUpdateSearcher()
5502    $results = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
5503    $results.Updates.Count.ToString() + "|PENDING"
5504} catch { "ERROR:" + $_.Exception.Message + "|PENDING" }
5505"#;
5506        if let Ok(o) = Command::new("powershell")
5507            .args(["-NoProfile", "-Command", pending_script])
5508            .output()
5509        {
5510            let raw = String::from_utf8_lossy(&o.stdout);
5511            let text = raw.trim();
5512            if text.starts_with("ERROR:") {
5513                out.push_str("Pending updates: (unable to query via COM — try opening Windows Update manually)\n");
5514            } else {
5515                let count: i64 = text.replace("|PENDING", "").trim().parse().unwrap_or(-1);
5516                if count == 0 {
5517                    out.push_str("Pending updates: Up to date — no updates waiting\n");
5518                } else if count > 0 {
5519                    out.push_str(&format!("Pending updates: {count} update(s) available\n"));
5520                    out.push_str(
5521                        "  → Open Windows Update (Settings > Windows Update) to install\n",
5522                    );
5523                }
5524            }
5525        }
5526
5527        // Windows Update service state
5528        let svc_script = r#"
5529$svc = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
5530if ($svc) { $svc.Status.ToString() } else { "NOT_FOUND" }
5531"#;
5532        if let Ok(o) = Command::new("powershell")
5533            .args(["-NoProfile", "-Command", svc_script])
5534            .output()
5535        {
5536            let raw = String::from_utf8_lossy(&o.stdout);
5537            let status = raw.trim();
5538            out.push_str(&format!("Windows Update service: {status}\n"));
5539        }
5540    }
5541
5542    #[cfg(not(target_os = "windows"))]
5543    {
5544        let apt_out = Command::new("apt").args(["list", "--upgradable"]).output();
5545        let mut found = false;
5546        if let Ok(o) = apt_out {
5547            let text = String::from_utf8_lossy(&o.stdout);
5548            let lines: Vec<&str> = text
5549                .lines()
5550                .filter(|l| l.contains('/') && !l.contains("Listing"))
5551                .collect();
5552            if !lines.is_empty() {
5553                out.push_str(&format!(
5554                    "{} package(s) can be upgraded (apt)\n",
5555                    lines.len()
5556                ));
5557                out.push_str("  → Run: sudo apt upgrade\n");
5558                found = true;
5559            }
5560        }
5561        if !found {
5562            if let Ok(o) = Command::new("dnf")
5563                .args(["check-update", "--quiet"])
5564                .output()
5565            {
5566                let text = String::from_utf8_lossy(&o.stdout);
5567                let count = text
5568                    .lines()
5569                    .filter(|l| !l.is_empty() && !l.starts_with('!'))
5570                    .count();
5571                if count > 0 {
5572                    out.push_str(&format!("{count} package(s) can be upgraded (dnf)\n"));
5573                    out.push_str("  → Run: sudo dnf upgrade\n");
5574                } else {
5575                    out.push_str("System is up to date.\n");
5576                }
5577            } else {
5578                out.push_str("Could not query package manager for updates.\n");
5579            }
5580        }
5581    }
5582
5583    Ok(out.trim_end().to_string())
5584}
5585
5586// ── security ──────────────────────────────────────────────────────────────────
5587
5588fn inspect_security() -> Result<String, String> {
5589    let mut out = String::from("Host inspection: security\n\n");
5590
5591    #[cfg(target_os = "windows")]
5592    {
5593        // Windows Defender status
5594        let defender_script = r#"
5595try {
5596    $status = Get-MpComputerStatus -ErrorAction Stop
5597    "RTP:" + $status.RealTimeProtectionEnabled + "|SCAN:" + $status.QuickScanEndTime.ToString("yyyy-MM-dd HH:mm") + "|VER:" + $status.AntivirusSignatureVersion + "|AGE:" + $status.AntivirusSignatureAge
5598} catch { "ERROR:" + $_.Exception.Message }
5599"#;
5600        if let Ok(o) = Command::new("powershell")
5601            .args(["-NoProfile", "-Command", defender_script])
5602            .output()
5603        {
5604            let raw = String::from_utf8_lossy(&o.stdout);
5605            let text = raw.trim();
5606            if text.starts_with("ERROR:") {
5607                out.push_str(&format!("Windows Defender: unable to query — {text}\n"));
5608            } else {
5609                let get = |key: &str| -> String {
5610                    text.split('|')
5611                        .find(|s| s.starts_with(key))
5612                        .and_then(|s| s.splitn(2, ':').nth(1))
5613                        .unwrap_or("unknown")
5614                        .to_string()
5615                };
5616                let rtp = get("RTP");
5617                let last_scan = {
5618                    // SCAN field has a colon in the time, so grab everything after "SCAN:"
5619                    text.split('|')
5620                        .find(|s| s.starts_with("SCAN:"))
5621                        .and_then(|s| s.get(5..))
5622                        .unwrap_or("unknown")
5623                        .to_string()
5624                };
5625                let def_ver = get("VER");
5626                let age_days: i64 = get("AGE").parse().unwrap_or(-1);
5627
5628                let rtp_label = if rtp == "True" {
5629                    "ENABLED"
5630                } else {
5631                    "DISABLED [!]"
5632                };
5633                out.push_str(&format!(
5634                    "Windows Defender real-time protection: {rtp_label}\n"
5635                ));
5636                out.push_str(&format!("Last quick scan: {last_scan}\n"));
5637                out.push_str(&format!("Signature version: {def_ver}\n"));
5638                if age_days >= 0 {
5639                    let freshness = if age_days == 0 {
5640                        "up to date".to_string()
5641                    } else if age_days <= 3 {
5642                        format!("{age_days} day(s) old — OK")
5643                    } else if age_days <= 7 {
5644                        format!("{age_days} day(s) old — consider updating")
5645                    } else {
5646                        format!("{age_days} day(s) old — [!] STALE, run Windows Update")
5647                    };
5648                    out.push_str(&format!("Signature age: {freshness}\n"));
5649                }
5650                if rtp != "True" {
5651                    out.push_str(
5652                        "\n[!] Real-time protection is OFF — your PC is not actively protected.\n",
5653                    );
5654                    out.push_str(
5655                        "    → Open Windows Security > Virus & threat protection to re-enable.\n",
5656                    );
5657                }
5658            }
5659        }
5660
5661        out.push('\n');
5662
5663        // Windows Firewall state
5664        let fw_script = r#"
5665try {
5666    Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object { $_.Name + ":" + $_.Enabled }
5667} catch { "ERROR:" + $_.Exception.Message }
5668"#;
5669        if let Ok(o) = Command::new("powershell")
5670            .args(["-NoProfile", "-Command", fw_script])
5671            .output()
5672        {
5673            let raw = String::from_utf8_lossy(&o.stdout);
5674            let text = raw.trim();
5675            if !text.starts_with("ERROR:") && !text.is_empty() {
5676                out.push_str("Windows Firewall:\n");
5677                for line in text.lines() {
5678                    if let Some((name, enabled)) = line.split_once(':') {
5679                        let state = if enabled.trim() == "True" {
5680                            "ON"
5681                        } else {
5682                            "OFF [!]"
5683                        };
5684                        out.push_str(&format!("  {name}: {state}\n"));
5685                    }
5686                }
5687                out.push('\n');
5688            }
5689        }
5690
5691        // Windows activation status
5692        let act_script = r#"
5693try {
5694    $lic = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%' and LicenseStatus=1" -ErrorAction Stop | Select-Object -First 1
5695    if ($lic) { "ACTIVATED" } else { "NOT_ACTIVATED" }
5696} catch { "UNKNOWN" }
5697"#;
5698        if let Ok(o) = Command::new("powershell")
5699            .args(["-NoProfile", "-Command", act_script])
5700            .output()
5701        {
5702            let raw = String::from_utf8_lossy(&o.stdout);
5703            match raw.trim() {
5704                "ACTIVATED" => out.push_str("Windows activation: Activated\n"),
5705                "NOT_ACTIVATED" => out.push_str("Windows activation: [!] NOT ACTIVATED\n"),
5706                _ => out.push_str("Windows activation: Unable to determine\n"),
5707            }
5708        }
5709
5710        // UAC state
5711        let uac_script = r#"
5712$val = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue
5713if ($val -eq 1) { "ON" } else { "OFF" }
5714"#;
5715        if let Ok(o) = Command::new("powershell")
5716            .args(["-NoProfile", "-Command", uac_script])
5717            .output()
5718        {
5719            let raw = String::from_utf8_lossy(&o.stdout);
5720            let state = raw.trim();
5721            let label = if state == "ON" {
5722                "Enabled"
5723            } else {
5724                "DISABLED [!] — recommended to re-enable via secpol.msc"
5725            };
5726            out.push_str(&format!("UAC (User Account Control): {label}\n"));
5727        }
5728    }
5729
5730    #[cfg(not(target_os = "windows"))]
5731    {
5732        if let Ok(o) = Command::new("ufw").arg("status").output() {
5733            let text = String::from_utf8_lossy(&o.stdout);
5734            out.push_str(&format!(
5735                "UFW: {}\n",
5736                text.lines().next().unwrap_or("unknown")
5737            ));
5738        }
5739        if let Ok(cfg) = std::fs::read_to_string("/etc/selinux/config") {
5740            if let Some(line) = cfg.lines().find(|l| l.starts_with("SELINUX=")) {
5741                out.push_str(&format!("{line}\n"));
5742            }
5743        }
5744    }
5745
5746    Ok(out.trim_end().to_string())
5747}
5748
5749// ── pending_reboot ────────────────────────────────────────────────────────────
5750
5751fn inspect_pending_reboot() -> Result<String, String> {
5752    let mut out = String::from("Host inspection: pending_reboot\n\n");
5753
5754    #[cfg(target_os = "windows")]
5755    {
5756        let script = r#"
5757$reasons = @()
5758if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
5759    $reasons += "Windows Update requires a restart"
5760}
5761if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
5762    $reasons += "Windows component install/update requires a restart"
5763}
5764$pfro = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
5765if ($pfro -and $pfro.PendingFileRenameOperations) {
5766    $reasons += "Pending file rename operations (driver or system file replacement)"
5767}
5768if ($reasons.Count -eq 0) { "NO_REBOOT_NEEDED" } else { $reasons -join "|REASON|" }
5769"#;
5770        let output = Command::new("powershell")
5771            .args(["-NoProfile", "-Command", script])
5772            .output()
5773            .map_err(|e| format!("pending_reboot: {e}"))?;
5774
5775        let raw = String::from_utf8_lossy(&output.stdout);
5776        let text = raw.trim();
5777
5778        if text == "NO_REBOOT_NEEDED" {
5779            out.push_str("No restart required — system is up to date and stable.\n");
5780        } else if text.is_empty() {
5781            out.push_str("Could not determine reboot status.\n");
5782        } else {
5783            out.push_str("[!] A system restart is pending:\n\n");
5784            for reason in text.split("|REASON|") {
5785                out.push_str(&format!("  • {}\n", reason.trim()));
5786            }
5787            out.push_str("\nRecommendation: Save your work and restart when convenient.\n");
5788        }
5789    }
5790
5791    #[cfg(not(target_os = "windows"))]
5792    {
5793        if std::path::Path::new("/var/run/reboot-required").exists() {
5794            out.push_str("[!] A restart is required (see /var/run/reboot-required)\n");
5795            if let Ok(pkgs) = std::fs::read_to_string("/var/run/reboot-required.pkgs") {
5796                out.push_str("Packages requiring restart:\n");
5797                for p in pkgs.lines().take(10) {
5798                    out.push_str(&format!("  • {p}\n"));
5799                }
5800            }
5801        } else {
5802            out.push_str("No restart required.\n");
5803        }
5804    }
5805
5806    Ok(out.trim_end().to_string())
5807}
5808
5809// ── disk_health ───────────────────────────────────────────────────────────────
5810
5811fn inspect_disk_health() -> Result<String, String> {
5812    let mut out = String::from("Host inspection: disk_health\n\n");
5813
5814    #[cfg(target_os = "windows")]
5815    {
5816        let script = r#"
5817try {
5818    $disks = Get-PhysicalDisk -ErrorAction Stop
5819    foreach ($d in $disks) {
5820        $size_gb = [math]::Round($d.Size / 1GB, 0)
5821        $d.FriendlyName + "|" + $d.MediaType + "|" + $size_gb + "GB|" + $d.HealthStatus + "|" + $d.OperationalStatus
5822    }
5823} catch { "ERROR:" + $_.Exception.Message }
5824"#;
5825        let output = Command::new("powershell")
5826            .args(["-NoProfile", "-Command", script])
5827            .output()
5828            .map_err(|e| format!("disk_health: {e}"))?;
5829
5830        let raw = String::from_utf8_lossy(&output.stdout);
5831        let text = raw.trim();
5832
5833        if text.starts_with("ERROR:") {
5834            out.push_str(&format!("Unable to query disk health: {text}\n"));
5835            out.push_str("This may require running as administrator.\n");
5836        } else if text.is_empty() {
5837            out.push_str("No physical disks found.\n");
5838        } else {
5839            out.push_str("Physical Drive Health:\n\n");
5840            for line in text.lines() {
5841                let parts: Vec<&str> = line.splitn(5, '|').collect();
5842                if parts.len() >= 4 {
5843                    let name = parts[0];
5844                    let media = parts[1];
5845                    let size = parts[2];
5846                    let health = parts[3];
5847                    let op_status = parts.get(4).unwrap_or(&"");
5848                    let health_label = match health.trim() {
5849                        "Healthy" => "OK",
5850                        "Warning" => "[!] WARNING",
5851                        "Unhealthy" => "[!!] UNHEALTHY — BACK UP YOUR DATA NOW",
5852                        other => other,
5853                    };
5854                    out.push_str(&format!("  {name}\n"));
5855                    out.push_str(&format!("    Type: {media} | Size: {size}\n"));
5856                    out.push_str(&format!("    Health: {health_label}\n"));
5857                    if !op_status.is_empty() {
5858                        out.push_str(&format!("    Status: {op_status}\n"));
5859                    }
5860                    out.push('\n');
5861                }
5862            }
5863        }
5864
5865        // SMART failure prediction (best-effort, may need admin)
5866        let smart_script = r#"
5867try {
5868    Get-WmiObject -Class MSStorageDriver_FailurePredictStatus -Namespace root\wmi -ErrorAction Stop |
5869        ForEach-Object { $_.InstanceName + "|" + $_.PredictFailure }
5870} catch { "" }
5871"#;
5872        if let Ok(o) = Command::new("powershell")
5873            .args(["-NoProfile", "-Command", smart_script])
5874            .output()
5875        {
5876            let raw2 = String::from_utf8_lossy(&o.stdout);
5877            let text2 = raw2.trim();
5878            if !text2.is_empty() {
5879                let failures: Vec<&str> = text2.lines().filter(|l| l.contains("|True")).collect();
5880                if failures.is_empty() {
5881                    out.push_str("SMART failure prediction: No failures predicted\n");
5882                } else {
5883                    out.push_str("[!!] SMART failure predicted on one or more drives:\n");
5884                    for f in failures {
5885                        let name = f.split('|').next().unwrap_or(f);
5886                        out.push_str(&format!("  • {name}\n"));
5887                    }
5888                    out.push_str(
5889                        "\nBack up your data immediately and replace the failing drive.\n",
5890                    );
5891                }
5892            }
5893        }
5894    }
5895
5896    #[cfg(not(target_os = "windows"))]
5897    {
5898        if let Ok(o) = Command::new("lsblk")
5899            .args(["-d", "-o", "NAME,SIZE,TYPE,ROTA,MODEL"])
5900            .output()
5901        {
5902            let text = String::from_utf8_lossy(&o.stdout);
5903            out.push_str("Block devices:\n");
5904            out.push_str(text.trim());
5905            out.push('\n');
5906        }
5907        if let Ok(scan) = Command::new("smartctl").args(["--scan"]).output() {
5908            let devices = String::from_utf8_lossy(&scan.stdout);
5909            for dev_line in devices.lines().take(4) {
5910                let dev = dev_line.split_whitespace().next().unwrap_or("");
5911                if dev.is_empty() {
5912                    continue;
5913                }
5914                if let Ok(o) = Command::new("smartctl").args(["-H", dev]).output() {
5915                    let health = String::from_utf8_lossy(&o.stdout);
5916                    if let Some(line) = health.lines().find(|l| l.contains("SMART overall-health"))
5917                    {
5918                        out.push_str(&format!("{dev}: {}\n", line.trim()));
5919                    }
5920                }
5921            }
5922        } else {
5923            out.push_str("(install smartmontools for SMART health data)\n");
5924        }
5925    }
5926
5927    Ok(out.trim_end().to_string())
5928}
5929
5930// ── battery ───────────────────────────────────────────────────────────────────
5931
5932fn inspect_battery() -> Result<String, String> {
5933    let mut out = String::from("Host inspection: battery\n\n");
5934
5935    #[cfg(target_os = "windows")]
5936    {
5937        let script = r#"
5938try {
5939    $bats = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
5940    if (-not $bats) { "NO_BATTERY"; exit }
5941    
5942    # Modern Battery Health (Cycle count + Capacity health)
5943    $static = Get-CimInstance -Namespace root/WMI -ClassName BatteryStaticData -ErrorAction SilentlyContinue
5944    $full = Get-CimInstance -Namespace root/WMI -ClassName BatteryFullCapacity -ErrorAction SilentlyContinue 
5945    $status = Get-CimInstance -Namespace root/WMI -ClassName BatteryStatus -ErrorAction SilentlyContinue
5946
5947    foreach ($b in $bats) {
5948        $state = switch ($b.BatteryStatus) {
5949            1 { "Discharging" }
5950            2 { "AC Power (Fully Charged)" }
5951            3 { "AC Power (Charging)" }
5952            default { "Status $($b.BatteryStatus)" }
5953        }
5954        
5955        $cycles = if ($status) { $status.CycleCount } else { "unknown" }
5956        $health = if ($static -and $full) {
5957             [math]::Round(($full.FullChargeCapacity / $static.DesignCapacity) * 100, 1)
5958        } else { "unknown" }
5959
5960        $b.Name + "|" + $b.EstimatedChargeRemaining + "|" + $state + "|" + $cycles + "|" + $health
5961    }
5962} catch { "ERROR:" + $_.Exception.Message }
5963"#;
5964        let output = Command::new("powershell")
5965            .args(["-NoProfile", "-Command", script])
5966            .output()
5967            .map_err(|e| format!("battery: {e}"))?;
5968
5969        let raw = String::from_utf8_lossy(&output.stdout);
5970        let text = raw.trim();
5971
5972        if text == "NO_BATTERY" {
5973            out.push_str("No battery detected — desktop or AC-only system.\n");
5974            return Ok(out.trim_end().to_string());
5975        }
5976        if text.starts_with("ERROR:") {
5977            out.push_str(&format!("Unable to query battery: {text}\n"));
5978            return Ok(out.trim_end().to_string());
5979        }
5980
5981        for line in text.lines() {
5982            let parts: Vec<&str> = line.split('|').collect();
5983            if parts.len() == 5 {
5984                let name = parts[0];
5985                let charge: i64 = parts[1].parse().unwrap_or(-1);
5986                let state = parts[2];
5987                let cycles = parts[3];
5988                let health = parts[4];
5989
5990                out.push_str(&format!("Battery: {name}\n"));
5991                if charge >= 0 {
5992                    let bar_filled = (charge as usize * 20) / 100;
5993                    out.push_str(&format!(
5994                        "  Charge: [{}{}] {}%\n",
5995                        "#".repeat(bar_filled),
5996                        ".".repeat(20 - bar_filled),
5997                        charge
5998                    ));
5999                }
6000                out.push_str(&format!("  Status: {state}\n"));
6001                out.push_str(&format!("  Cycles: {cycles}\n"));
6002                out.push_str(&format!(
6003                    "  Health: {health}% (Actual vs Design Capacity)\n\n"
6004                ));
6005            }
6006        }
6007    }
6008
6009    #[cfg(not(target_os = "windows"))]
6010    {
6011        let power_path = std::path::Path::new("/sys/class/power_supply");
6012        let mut found = false;
6013        if power_path.exists() {
6014            if let Ok(entries) = std::fs::read_dir(power_path) {
6015                for entry in entries.flatten() {
6016                    let p = entry.path();
6017                    if let Ok(t) = std::fs::read_to_string(p.join("type")) {
6018                        if t.trim() == "Battery" {
6019                            found = true;
6020                            let name = p
6021                                .file_name()
6022                                .unwrap_or_default()
6023                                .to_string_lossy()
6024                                .to_string();
6025                            out.push_str(&format!("Battery: {name}\n"));
6026                            let read = |f: &str| {
6027                                std::fs::read_to_string(p.join(f))
6028                                    .ok()
6029                                    .map(|s| s.trim().to_string())
6030                            };
6031                            if let Some(cap) = read("capacity") {
6032                                out.push_str(&format!("  Charge: {cap}%\n"));
6033                            }
6034                            if let Some(status) = read("status") {
6035                                out.push_str(&format!("  Status: {status}\n"));
6036                            }
6037                            if let (Some(full), Some(design)) =
6038                                (read("energy_full"), read("energy_full_design"))
6039                            {
6040                                if let (Ok(f), Ok(d)) = (full.parse::<f64>(), design.parse::<f64>())
6041                                {
6042                                    if d > 0.0 {
6043                                        out.push_str(&format!(
6044                                            "  Wear level: {:.1}% of design capacity\n",
6045                                            (f / d) * 100.0
6046                                        ));
6047                                    }
6048                                }
6049                            }
6050                        }
6051                    }
6052                }
6053            }
6054        }
6055        if !found {
6056            out.push_str("No battery found.\n");
6057        }
6058    }
6059
6060    Ok(out.trim_end().to_string())
6061}
6062
6063// ── recent_crashes ────────────────────────────────────────────────────────────
6064
6065fn inspect_recent_crashes(max_entries: usize) -> Result<String, String> {
6066    let mut out = String::from("Host inspection: recent_crashes\n\n");
6067    let n = max_entries.clamp(1, 30);
6068
6069    #[cfg(target_os = "windows")]
6070    {
6071        // BSODs / unexpected shutdowns (EventID 41 = kernel power, 1001 = BugCheck)
6072        let bsod_script = format!(
6073            r#"
6074try {{
6075    $events = Get-WinEvent -FilterHashtable @{{LogName='System'; Id=41,1001}} -MaxEvents {n} -ErrorAction SilentlyContinue
6076    if ($events) {{
6077        $events | ForEach-Object {{
6078            $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + $_.Id + "|" + (($_.Message -split "[\r\n]")[0].Trim())
6079        }}
6080    }} else {{ "NO_BSOD" }}
6081}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6082        );
6083
6084        if let Ok(o) = Command::new("powershell")
6085            .args(["-NoProfile", "-Command", &bsod_script])
6086            .output()
6087        {
6088            let raw = String::from_utf8_lossy(&o.stdout);
6089            let text = raw.trim();
6090            if text == "NO_BSOD" {
6091                out.push_str("System crashes (BSOD/kernel): None in recent history\n");
6092            } else if text.starts_with("ERROR:") {
6093                out.push_str("System crashes: unable to query\n");
6094            } else {
6095                out.push_str("System crashes / unexpected shutdowns:\n");
6096                for line in text.lines() {
6097                    let parts: Vec<&str> = line.splitn(3, '|').collect();
6098                    if parts.len() >= 3 {
6099                        let time = parts[0];
6100                        let id = parts[1];
6101                        let msg = parts[2];
6102                        let label = if id == "41" {
6103                            "Unexpected shutdown"
6104                        } else {
6105                            "BSOD (BugCheck)"
6106                        };
6107                        out.push_str(&format!("  [{time}] {label}: {msg}\n"));
6108                    }
6109                }
6110                out.push('\n');
6111            }
6112        }
6113
6114        // Application crashes (EventID 1000 = app crash, 1002 = app hang)
6115        let app_script = format!(
6116            r#"
6117try {{
6118    $crashes = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue
6119    if ($crashes) {{
6120        $crashes | ForEach-Object {{
6121            $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + (($_.Message -split "[\r\n]")[0].Trim())
6122        }}
6123    }} else {{ "NO_CRASHES" }}
6124}} catch {{ "ERROR_APP:" + $_.Exception.Message }}"#
6125        );
6126
6127        if let Ok(o) = Command::new("powershell")
6128            .args(["-NoProfile", "-Command", &app_script])
6129            .output()
6130        {
6131            let raw = String::from_utf8_lossy(&o.stdout);
6132            let text = raw.trim();
6133            if text == "NO_CRASHES" {
6134                out.push_str("Application crashes: None in recent history\n");
6135            } else if text.starts_with("ERROR_APP:") {
6136                out.push_str("Application crashes: unable to query\n");
6137            } else {
6138                out.push_str("Application crashes:\n");
6139                for line in text.lines().take(n) {
6140                    let parts: Vec<&str> = line.splitn(2, '|').collect();
6141                    if parts.len() >= 2 {
6142                        out.push_str(&format!("  [{}] {}\n", parts[0], parts[1]));
6143                    }
6144                }
6145            }
6146        }
6147    }
6148
6149    #[cfg(not(target_os = "windows"))]
6150    {
6151        let n_str = n.to_string();
6152        if let Ok(o) = Command::new("journalctl")
6153            .args(["-k", "--no-pager", "-n", &n_str, "-p", "0..2"])
6154            .output()
6155        {
6156            let text = String::from_utf8_lossy(&o.stdout);
6157            let trimmed = text.trim();
6158            if trimmed.is_empty() || trimmed.contains("No entries") {
6159                out.push_str("No kernel panics or critical crashes found.\n");
6160            } else {
6161                out.push_str("Kernel critical events:\n");
6162                out.push_str(trimmed);
6163                out.push('\n');
6164            }
6165        }
6166        if let Ok(o) = Command::new("coredumpctl")
6167            .args(["list", "--no-pager"])
6168            .output()
6169        {
6170            let text = String::from_utf8_lossy(&o.stdout);
6171            let count = text
6172                .lines()
6173                .filter(|l| !l.trim().is_empty() && !l.starts_with("TIME"))
6174                .count();
6175            if count > 0 {
6176                out.push_str(&format!(
6177                    "\nCore dumps on file: {count}\n  → Run: coredumpctl list\n"
6178                ));
6179            }
6180        }
6181    }
6182
6183    Ok(out.trim_end().to_string())
6184}
6185
6186// ── scheduled_tasks ───────────────────────────────────────────────────────────
6187
6188fn inspect_scheduled_tasks(max_entries: usize) -> Result<String, String> {
6189    let mut out = String::from("Host inspection: scheduled_tasks\n\n");
6190    let n = max_entries.clamp(1, 30);
6191
6192    #[cfg(target_os = "windows")]
6193    {
6194        let script = format!(
6195            r#"
6196try {{
6197    $tasks = Get-ScheduledTask -ErrorAction Stop |
6198        Where-Object {{ $_.State -ne 'Disabled' }} |
6199        ForEach-Object {{
6200            $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
6201            $lastRun = if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {{
6202                $info.LastRunTime.ToString("yyyy-MM-dd HH:mm")
6203            }} else {{ "never" }}
6204            $res = if ($info) {{ "0x{{0:x}}" -f $info.LastTaskResult }} else {{ "---" }}
6205            $exec = ($_.Actions | Select-Object -First 1).Execute
6206            if (-not $exec) {{ $exec = "(no exec)" }}
6207            $_.TaskName + "|" + $_.TaskPath + "|" + $_.State + "|" + $lastRun + "|" + $res + "|" + $exec
6208        }}
6209    $tasks | Select-Object -First {n}
6210}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6211        );
6212
6213        let output = Command::new("powershell")
6214            .args(["-NoProfile", "-Command", &script])
6215            .output()
6216            .map_err(|e| format!("scheduled_tasks: {e}"))?;
6217
6218        let raw = String::from_utf8_lossy(&output.stdout);
6219        let text = raw.trim();
6220
6221        if text.starts_with("ERROR:") {
6222            out.push_str(&format!("Unable to query scheduled tasks: {text}\n"));
6223        } else if text.is_empty() {
6224            out.push_str("No active scheduled tasks found.\n");
6225        } else {
6226            out.push_str(&format!("Active scheduled tasks (up to {n}):\n\n"));
6227            for line in text.lines() {
6228                let parts: Vec<&str> = line.splitn(6, '|').collect();
6229                if parts.len() >= 5 {
6230                    let name = parts[0];
6231                    let path = parts[1];
6232                    let state = parts[2];
6233                    let last = parts[3];
6234                    let res = parts[4];
6235                    let exec = parts.get(5).unwrap_or(&"").trim();
6236                    let display_path = path.trim_matches('\\');
6237                    let display_path = if display_path.is_empty() {
6238                        "Root"
6239                    } else {
6240                        display_path
6241                    };
6242                    out.push_str(&format!("  {name} [{display_path}]\n"));
6243                    out.push_str(&format!(
6244                        "    State: {state} | Last run: {last} | Result: {res}\n"
6245                    ));
6246                    if !exec.is_empty() && exec != "(no exec)" {
6247                        let short = if exec.len() > 80 { &exec[..80] } else { exec };
6248                        out.push_str(&format!("    Runs: {short}\n"));
6249                    }
6250                }
6251            }
6252        }
6253    }
6254
6255    #[cfg(not(target_os = "windows"))]
6256    {
6257        if let Ok(o) = Command::new("systemctl")
6258            .args(["list-timers", "--no-pager", "--all"])
6259            .output()
6260        {
6261            let text = String::from_utf8_lossy(&o.stdout);
6262            out.push_str("Systemd timers:\n");
6263            for l in text
6264                .lines()
6265                .filter(|l| {
6266                    !l.trim().is_empty() && !l.starts_with("NEXT") && !l.starts_with("timers")
6267                })
6268                .take(n)
6269            {
6270                out.push_str(&format!("  {l}\n"));
6271            }
6272            out.push('\n');
6273        }
6274        if let Ok(o) = Command::new("crontab").arg("-l").output() {
6275            let text = String::from_utf8_lossy(&o.stdout);
6276            let jobs: Vec<&str> = text
6277                .lines()
6278                .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
6279                .collect();
6280            if !jobs.is_empty() {
6281                out.push_str("User crontab:\n");
6282                for j in jobs.iter().take(n) {
6283                    out.push_str(&format!("  {j}\n"));
6284                }
6285            }
6286        }
6287    }
6288
6289    Ok(out.trim_end().to_string())
6290}
6291
6292// ── dev_conflicts ─────────────────────────────────────────────────────────────
6293
6294fn inspect_dev_conflicts() -> Result<String, String> {
6295    let mut out = String::from("Host inspection: dev_conflicts\n\n");
6296    let mut conflicts: Vec<String> = Vec::new();
6297    let mut notes: Vec<String> = Vec::new();
6298
6299    // ── Node.js / version managers ────────────────────────────────────────────
6300    {
6301        let node_ver = Command::new("node")
6302            .arg("--version")
6303            .output()
6304            .ok()
6305            .and_then(|o| String::from_utf8(o.stdout).ok())
6306            .map(|s| s.trim().to_string());
6307        let nvm_active = Command::new("nvm")
6308            .arg("current")
6309            .output()
6310            .ok()
6311            .and_then(|o| String::from_utf8(o.stdout).ok())
6312            .map(|s| s.trim().to_string())
6313            .filter(|s| !s.is_empty() && !s.contains("none") && !s.contains("No current"));
6314        let fnm_active = Command::new("fnm")
6315            .arg("current")
6316            .output()
6317            .ok()
6318            .and_then(|o| String::from_utf8(o.stdout).ok())
6319            .map(|s| s.trim().to_string())
6320            .filter(|s| !s.is_empty() && !s.contains("none"));
6321        let volta_active = Command::new("volta")
6322            .args(["which", "node"])
6323            .output()
6324            .ok()
6325            .and_then(|o| String::from_utf8(o.stdout).ok())
6326            .map(|s| s.trim().to_string())
6327            .filter(|s| !s.is_empty());
6328
6329        out.push_str("Node.js:\n");
6330        if let Some(ref v) = node_ver {
6331            out.push_str(&format!("  Active: {v}\n"));
6332        } else {
6333            out.push_str("  Not installed\n");
6334        }
6335        let managers: Vec<&str> = [
6336            nvm_active.as_deref(),
6337            fnm_active.as_deref(),
6338            volta_active.as_deref(),
6339        ]
6340        .iter()
6341        .filter_map(|x| *x)
6342        .collect();
6343        if managers.len() > 1 {
6344            conflicts.push(format!(
6345                "Multiple Node.js version managers detected (nvm/fnm/volta). Only one should be active to avoid PATH conflicts."
6346            ));
6347        } else if !managers.is_empty() {
6348            out.push_str(&format!("  Version manager: {}\n", managers[0]));
6349        }
6350        out.push('\n');
6351    }
6352
6353    // ── Python ────────────────────────────────────────────────────────────────
6354    {
6355        let py3 = Command::new("python3")
6356            .arg("--version")
6357            .output()
6358            .ok()
6359            .and_then(|o| {
6360                let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6361                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6362                let v = if stdout.is_empty() { stderr } else { stdout };
6363                if v.is_empty() {
6364                    None
6365                } else {
6366                    Some(v)
6367                }
6368            });
6369        let py = Command::new("python")
6370            .arg("--version")
6371            .output()
6372            .ok()
6373            .and_then(|o| {
6374                let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6375                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6376                let v = if stdout.is_empty() { stderr } else { stdout };
6377                if v.is_empty() {
6378                    None
6379                } else {
6380                    Some(v)
6381                }
6382            });
6383        let pyenv = Command::new("pyenv")
6384            .arg("version")
6385            .output()
6386            .ok()
6387            .and_then(|o| String::from_utf8(o.stdout).ok())
6388            .map(|s| s.trim().to_string())
6389            .filter(|s| !s.is_empty());
6390        let conda_env = std::env::var("CONDA_DEFAULT_ENV").ok();
6391
6392        out.push_str("Python:\n");
6393        match (&py3, &py) {
6394            (Some(v3), Some(v)) if v3 != v => {
6395                out.push_str(&format!("  python3: {v3}\n  python:  {v}\n"));
6396                if v.contains("2.") {
6397                    conflicts.push(
6398                        "python and python3 point to different major versions (2.x vs 3.x). Scripts using 'python' may break unexpectedly.".to_string()
6399                    );
6400                } else {
6401                    notes.push(
6402                        "python and python3 resolve to different minor versions.".to_string(),
6403                    );
6404                }
6405            }
6406            (Some(v3), None) => out.push_str(&format!("  python3: {v3}\n")),
6407            (None, Some(v)) => out.push_str(&format!("  python: {v}\n")),
6408            (Some(v3), Some(_)) => out.push_str(&format!("  {v3}\n")),
6409            (None, None) => out.push_str("  Not installed\n"),
6410        }
6411        if let Some(ref pe) = pyenv {
6412            out.push_str(&format!("  pyenv: {pe}\n"));
6413        }
6414        if let Some(env) = conda_env {
6415            if env == "base" {
6416                notes.push("Conda base environment is active — may shadow system Python. Run 'conda deactivate' if unexpected.".to_string());
6417            } else {
6418                out.push_str(&format!("  conda env: {env}\n"));
6419            }
6420        }
6421        out.push('\n');
6422    }
6423
6424    // ── Rust / Cargo ──────────────────────────────────────────────────────────
6425    {
6426        let toolchain = Command::new("rustup")
6427            .args(["show", "active-toolchain"])
6428            .output()
6429            .ok()
6430            .and_then(|o| String::from_utf8(o.stdout).ok())
6431            .map(|s| s.trim().to_string())
6432            .filter(|s| !s.is_empty());
6433        let cargo_ver = Command::new("cargo")
6434            .arg("--version")
6435            .output()
6436            .ok()
6437            .and_then(|o| String::from_utf8(o.stdout).ok())
6438            .map(|s| s.trim().to_string());
6439        let rustc_ver = Command::new("rustc")
6440            .arg("--version")
6441            .output()
6442            .ok()
6443            .and_then(|o| String::from_utf8(o.stdout).ok())
6444            .map(|s| s.trim().to_string());
6445
6446        out.push_str("Rust:\n");
6447        if let Some(ref t) = toolchain {
6448            out.push_str(&format!("  Active toolchain: {t}\n"));
6449        }
6450        if let Some(ref c) = cargo_ver {
6451            out.push_str(&format!("  {c}\n"));
6452        }
6453        if let Some(ref r) = rustc_ver {
6454            out.push_str(&format!("  {r}\n"));
6455        }
6456        if cargo_ver.is_none() && rustc_ver.is_none() {
6457            out.push_str("  Not installed\n");
6458        }
6459
6460        // Detect system rust that might shadow rustup
6461        #[cfg(not(target_os = "windows"))]
6462        if let Ok(o) = Command::new("which").arg("rustc").output() {
6463            let path = String::from_utf8_lossy(&o.stdout).trim().to_string();
6464            if !path.is_empty() && !path.contains(".cargo") && !path.contains("rustup") {
6465                conflicts.push(format!(
6466                    "rustc found at non-rustup path '{path}' — may conflict with rustup-managed toolchain"
6467                ));
6468            }
6469        }
6470        out.push('\n');
6471    }
6472
6473    // ── Git ───────────────────────────────────────────────────────────────────
6474    {
6475        let git_ver = Command::new("git")
6476            .arg("--version")
6477            .output()
6478            .ok()
6479            .and_then(|o| String::from_utf8(o.stdout).ok())
6480            .map(|s| s.trim().to_string());
6481        out.push_str("Git:\n");
6482        if let Some(ref v) = git_ver {
6483            out.push_str(&format!("  {v}\n"));
6484            let email = Command::new("git")
6485                .args(["config", "--global", "user.email"])
6486                .output()
6487                .ok()
6488                .and_then(|o| String::from_utf8(o.stdout).ok())
6489                .map(|s| s.trim().to_string());
6490            if let Some(ref e) = email {
6491                if e.is_empty() {
6492                    notes.push("Git user.email is not configured globally — commits may fail or use wrong identity.".to_string());
6493                } else {
6494                    out.push_str(&format!("  user.email: {e}\n"));
6495                }
6496            }
6497            let gpg_sign = Command::new("git")
6498                .args(["config", "--global", "commit.gpgsign"])
6499                .output()
6500                .ok()
6501                .and_then(|o| String::from_utf8(o.stdout).ok())
6502                .map(|s| s.trim().to_string());
6503            if gpg_sign.as_deref() == Some("true") {
6504                let key = Command::new("git")
6505                    .args(["config", "--global", "user.signingkey"])
6506                    .output()
6507                    .ok()
6508                    .and_then(|o| String::from_utf8(o.stdout).ok())
6509                    .map(|s| s.trim().to_string());
6510                if key.as_deref().map(|k| k.is_empty()).unwrap_or(true) {
6511                    conflicts.push("Git commit signing is enabled but no signing key is configured — commits will fail.".to_string());
6512                }
6513            }
6514        } else {
6515            out.push_str("  Not installed\n");
6516        }
6517        out.push('\n');
6518    }
6519
6520    // ── PATH duplicates ───────────────────────────────────────────────────────
6521    {
6522        let path_env = std::env::var("PATH").unwrap_or_default();
6523        let sep = if cfg!(windows) { ';' } else { ':' };
6524        let mut seen = HashSet::new();
6525        let mut dupes: Vec<String> = Vec::new();
6526        for p in path_env.split(sep) {
6527            let norm = p.trim().to_lowercase();
6528            if !norm.is_empty() && !seen.insert(norm) {
6529                dupes.push(p.to_string());
6530            }
6531        }
6532        if !dupes.is_empty() {
6533            let shown: Vec<&str> = dupes.iter().take(3).map(|s| s.as_str()).collect();
6534            notes.push(format!(
6535                "Duplicate PATH entries: {} {}",
6536                shown.join(", "),
6537                if dupes.len() > 3 {
6538                    format!("+{} more", dupes.len() - 3)
6539                } else {
6540                    String::new()
6541                }
6542            ));
6543        }
6544    }
6545
6546    // ── Summary ───────────────────────────────────────────────────────────────
6547    if conflicts.is_empty() && notes.is_empty() {
6548        out.push_str("No conflicts detected — dev environment looks clean.\n");
6549    } else {
6550        if !conflicts.is_empty() {
6551            out.push_str("CONFLICTS:\n");
6552            for c in &conflicts {
6553                out.push_str(&format!("  [!] {c}\n"));
6554            }
6555            out.push('\n');
6556        }
6557        if !notes.is_empty() {
6558            out.push_str("NOTES:\n");
6559            for n in &notes {
6560                out.push_str(&format!("  [-] {n}\n"));
6561            }
6562        }
6563    }
6564
6565    Ok(out.trim_end().to_string())
6566}
6567
6568// ── connectivity ──────────────────────────────────────────────────────────────
6569
6570fn inspect_connectivity() -> Result<String, String> {
6571    let mut out = String::from("Host inspection: connectivity\n\n");
6572
6573    #[cfg(target_os = "windows")]
6574    {
6575        let inet_script = r#"
6576try {
6577    $r = Test-NetConnection -ComputerName 8.8.8.8 -Port 53 -InformationLevel Quiet -WarningAction SilentlyContinue
6578    if ($r) { "REACHABLE" } else { "UNREACHABLE" }
6579} catch { "ERROR:" + $_.Exception.Message }
6580"#;
6581        if let Ok(o) = Command::new("powershell")
6582            .args(["-NoProfile", "-Command", inet_script])
6583            .output()
6584        {
6585            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6586            match text.as_str() {
6587                "REACHABLE" => out.push_str("Internet: reachable\n"),
6588                "UNREACHABLE" => out.push_str("Internet: unreachable [!]\n"),
6589                _ => out.push_str(&format!(
6590                    "Internet: {}\n",
6591                    text.trim_start_matches("ERROR:").trim()
6592                )),
6593            }
6594        }
6595
6596        let dns_script = r#"
6597try {
6598    Resolve-DnsName -Name "dns.google" -Type A -ErrorAction Stop | Out-Null
6599    "DNS:ok"
6600} catch { "DNS:fail:" + $_.Exception.Message }
6601"#;
6602        if let Ok(o) = Command::new("powershell")
6603            .args(["-NoProfile", "-Command", dns_script])
6604            .output()
6605        {
6606            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6607            if text == "DNS:ok" {
6608                out.push_str("DNS: resolving correctly\n");
6609            } else {
6610                let detail = text.trim_start_matches("DNS:fail:").trim();
6611                out.push_str(&format!("DNS: failed — {}\n", detail));
6612            }
6613        }
6614
6615        let gw_script = r#"
6616(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop
6617"#;
6618        if let Ok(o) = Command::new("powershell")
6619            .args(["-NoProfile", "-Command", gw_script])
6620            .output()
6621        {
6622            let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
6623            if !gw.is_empty() && gw != "0.0.0.0" {
6624                out.push_str(&format!("Default gateway: {}\n", gw));
6625            }
6626        }
6627    }
6628
6629    #[cfg(not(target_os = "windows"))]
6630    {
6631        let reachable = Command::new("ping")
6632            .args(["-c", "1", "-W", "2", "8.8.8.8"])
6633            .output()
6634            .map(|o| o.status.success())
6635            .unwrap_or(false);
6636        out.push_str(if reachable {
6637            "Internet: reachable\n"
6638        } else {
6639            "Internet: unreachable\n"
6640        });
6641        let dns_ok = Command::new("getent")
6642            .args(["hosts", "dns.google"])
6643            .output()
6644            .map(|o| o.status.success())
6645            .unwrap_or(false);
6646        out.push_str(if dns_ok {
6647            "DNS: resolving correctly\n"
6648        } else {
6649            "DNS: failed\n"
6650        });
6651        if let Ok(o) = Command::new("ip")
6652            .args(["route", "show", "default"])
6653            .output()
6654        {
6655            let text = String::from_utf8_lossy(&o.stdout);
6656            if let Some(line) = text.lines().next() {
6657                out.push_str(&format!("Default gateway: {}\n", line.trim()));
6658            }
6659        }
6660    }
6661
6662    Ok(out.trim_end().to_string())
6663}
6664
6665// ── wifi ──────────────────────────────────────────────────────────────────────
6666
6667fn inspect_wifi() -> Result<String, String> {
6668    let mut out = String::from("Host inspection: wifi\n\n");
6669
6670    #[cfg(target_os = "windows")]
6671    {
6672        let output = Command::new("netsh")
6673            .args(["wlan", "show", "interfaces"])
6674            .output()
6675            .map_err(|e| format!("wifi: {e}"))?;
6676        let text = String::from_utf8_lossy(&output.stdout).to_string();
6677
6678        if text.contains("There is no wireless interface") || text.trim().is_empty() {
6679            out.push_str("No wireless interface detected on this machine.\n");
6680            return Ok(out.trim_end().to_string());
6681        }
6682
6683        let fields = [
6684            ("SSID", "SSID"),
6685            ("State", "State"),
6686            ("Signal", "Signal"),
6687            ("Radio type", "Radio type"),
6688            ("Channel", "Channel"),
6689            ("Receive rate (Mbps)", "Download speed (Mbps)"),
6690            ("Transmit rate (Mbps)", "Upload speed (Mbps)"),
6691            ("Authentication", "Authentication"),
6692            ("Network type", "Network type"),
6693        ];
6694
6695        let mut any = false;
6696        for line in text.lines() {
6697            let trimmed = line.trim();
6698            for (key, label) in &fields {
6699                if trimmed.starts_with(key) && trimmed.contains(':') {
6700                    let val = trimmed.splitn(2, ':').nth(1).unwrap_or("").trim();
6701                    if !val.is_empty() {
6702                        out.push_str(&format!("  {label}: {val}\n"));
6703                        any = true;
6704                    }
6705                }
6706            }
6707        }
6708        if !any {
6709            out.push_str("  (Wi-Fi adapter disconnected or no active connection)\n");
6710        }
6711    }
6712
6713    #[cfg(not(target_os = "windows"))]
6714    {
6715        if let Ok(o) = Command::new("nmcli")
6716            .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
6717            .output()
6718        {
6719            let text = String::from_utf8_lossy(&o.stdout).to_string();
6720            let lines: Vec<&str> = text.lines().filter(|l| l.contains(":wifi:")).collect();
6721            if lines.is_empty() {
6722                out.push_str("No Wi-Fi devices found.\n");
6723            } else {
6724                for l in lines {
6725                    out.push_str(&format!("  {l}\n"));
6726                }
6727            }
6728        } else if let Ok(o) = Command::new("iwconfig").output() {
6729            let text = String::from_utf8_lossy(&o.stdout).to_string();
6730            if !text.trim().is_empty() {
6731                out.push_str(text.trim());
6732                out.push('\n');
6733            }
6734        } else {
6735            out.push_str("No wireless tool available (install nmcli or wireless-tools).\n");
6736        }
6737    }
6738
6739    Ok(out.trim_end().to_string())
6740}
6741
6742// ── connections ───────────────────────────────────────────────────────────────
6743
6744fn inspect_connections(max_entries: usize) -> Result<String, String> {
6745    let mut out = String::from("Host inspection: connections\n\n");
6746    let n = max_entries.clamp(1, 25);
6747
6748    #[cfg(target_os = "windows")]
6749    {
6750        let script = format!(
6751            r#"
6752try {{
6753    $procs = @{{}}
6754    Get-Process -ErrorAction SilentlyContinue | ForEach-Object {{ $procs[$_.Id] = $_.Name }}
6755    $all = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
6756        Sort-Object OwningProcess
6757    "TOTAL:" + $all.Count
6758    $all | Select-Object -First {n} | ForEach-Object {{
6759        $pname = if ($procs.ContainsKey($_.OwningProcess)) {{ $procs[$_.OwningProcess] }} else {{ "unknown" }}
6760        $pname + "|" + $_.OwningProcess + "|" + $_.LocalAddress + ":" + $_.LocalPort + "|" + $_.RemoteAddress + ":" + $_.RemotePort
6761    }}
6762}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6763        );
6764
6765        let output = Command::new("powershell")
6766            .args(["-NoProfile", "-Command", &script])
6767            .output()
6768            .map_err(|e| format!("connections: {e}"))?;
6769
6770        let raw = String::from_utf8_lossy(&output.stdout);
6771        let text = raw.trim();
6772
6773        if text.starts_with("ERROR:") {
6774            out.push_str(&format!("Unable to query connections: {text}\n"));
6775        } else {
6776            let mut total = 0usize;
6777            let mut rows = Vec::new();
6778            for line in text.lines() {
6779                if let Some(rest) = line.strip_prefix("TOTAL:") {
6780                    total = rest.trim().parse().unwrap_or(0);
6781                } else {
6782                    rows.push(line);
6783                }
6784            }
6785            out.push_str(&format!("Established TCP connections: {total}\n\n"));
6786            for row in &rows {
6787                let parts: Vec<&str> = row.splitn(4, '|').collect();
6788                if parts.len() == 4 {
6789                    out.push_str(&format!(
6790                        "  {:<15} (pid {:<5}) | {} → {}\n",
6791                        parts[0], parts[1], parts[2], parts[3]
6792                    ));
6793                }
6794            }
6795            if total > n {
6796                out.push_str(&format!(
6797                    "\n  ... {} more connections not shown\n",
6798                    total.saturating_sub(n)
6799                ));
6800            }
6801        }
6802    }
6803
6804    #[cfg(not(target_os = "windows"))]
6805    {
6806        if let Ok(o) = Command::new("ss")
6807            .args(["-tnp", "state", "established"])
6808            .output()
6809        {
6810            let text = String::from_utf8_lossy(&o.stdout);
6811            let lines: Vec<&str> = text
6812                .lines()
6813                .skip(1)
6814                .filter(|l| !l.trim().is_empty())
6815                .collect();
6816            out.push_str(&format!("Established TCP connections: {}\n\n", lines.len()));
6817            for line in lines.iter().take(n) {
6818                out.push_str(&format!("  {}\n", line.trim()));
6819            }
6820            if lines.len() > n {
6821                out.push_str(&format!("\n  ... {} more not shown\n", lines.len() - n));
6822            }
6823        } else {
6824            out.push_str("ss not available — install iproute2\n");
6825        }
6826    }
6827
6828    Ok(out.trim_end().to_string())
6829}
6830
6831// ── vpn ───────────────────────────────────────────────────────────────────────
6832
6833fn inspect_vpn() -> Result<String, String> {
6834    let mut out = String::from("Host inspection: vpn\n\n");
6835
6836    #[cfg(target_os = "windows")]
6837    {
6838        let script = r#"
6839try {
6840    $vpn = Get-NetAdapter -ErrorAction Stop | Where-Object {
6841        $_.InterfaceDescription -match 'VPN|TAP|WireGuard|OpenVPN|Cisco|Palo Alto|GlobalProtect|Juniper|Pulse|NordVPN|ExpressVPN|Mullvad|ProtonVPN' -or
6842        $_.Name -match 'VPN|TAP|WireGuard|tun|ppp|wg\d'
6843    }
6844    if ($vpn) {
6845        foreach ($a in $vpn) {
6846            $a.Name + "|" + $a.InterfaceDescription + "|" + $a.Status + "|" + $a.MediaConnectionState
6847        }
6848    } else { "NONE" }
6849} catch { "ERROR:" + $_.Exception.Message }
6850"#;
6851        let output = Command::new("powershell")
6852            .args(["-NoProfile", "-Command", script])
6853            .output()
6854            .map_err(|e| format!("vpn: {e}"))?;
6855
6856        let raw = String::from_utf8_lossy(&output.stdout);
6857        let text = raw.trim();
6858
6859        if text == "NONE" {
6860            out.push_str("No VPN adapters detected — no active VPN connection found.\n");
6861        } else if text.starts_with("ERROR:") {
6862            out.push_str(&format!("Unable to query adapters: {text}\n"));
6863        } else {
6864            out.push_str("VPN adapters:\n\n");
6865            for line in text.lines() {
6866                let parts: Vec<&str> = line.splitn(4, '|').collect();
6867                if parts.len() >= 3 {
6868                    let name = parts[0];
6869                    let desc = parts[1];
6870                    let status = parts[2];
6871                    let media = parts.get(3).unwrap_or(&"unknown");
6872                    let label = if status.trim() == "Up" {
6873                        "CONNECTED"
6874                    } else {
6875                        "disconnected"
6876                    };
6877                    out.push_str(&format!(
6878                        "  {name} [{label}]\n    {desc}\n    Status: {status} | Media: {media}\n\n"
6879                    ));
6880                }
6881            }
6882        }
6883
6884        // Windows built-in VPN connections
6885        let ras_script = r#"
6886try {
6887    $c = Get-VpnConnection -ErrorAction Stop
6888    if ($c) { foreach ($v in $c) { $v.Name + "|" + $v.ConnectionStatus + "|" + $v.ServerAddress } }
6889    else { "NO_RAS" }
6890} catch { "NO_RAS" }
6891"#;
6892        if let Ok(o) = Command::new("powershell")
6893            .args(["-NoProfile", "-Command", ras_script])
6894            .output()
6895        {
6896            let t = String::from_utf8_lossy(&o.stdout).trim().to_string();
6897            if t != "NO_RAS" && !t.is_empty() {
6898                out.push_str("Windows VPN connections:\n");
6899                for line in t.lines() {
6900                    let parts: Vec<&str> = line.splitn(3, '|').collect();
6901                    if parts.len() >= 2 {
6902                        let name = parts[0];
6903                        let status = parts[1];
6904                        let server = parts.get(2).unwrap_or(&"");
6905                        out.push_str(&format!("  {name} → {server} [{status}]\n"));
6906                    }
6907                }
6908            }
6909        }
6910    }
6911
6912    #[cfg(not(target_os = "windows"))]
6913    {
6914        if let Ok(o) = Command::new("ip").args(["link", "show"]).output() {
6915            let text = String::from_utf8_lossy(&o.stdout);
6916            let vpn_ifaces: Vec<&str> = text
6917                .lines()
6918                .filter(|l| {
6919                    l.contains("tun") || l.contains("tap") || l.contains(" wg") || l.contains("ppp")
6920                })
6921                .collect();
6922            if vpn_ifaces.is_empty() {
6923                out.push_str("No VPN interfaces (tun/tap/wg/ppp) detected.\n");
6924            } else {
6925                out.push_str(&format!("VPN-like interfaces ({}):\n", vpn_ifaces.len()));
6926                for l in vpn_ifaces {
6927                    out.push_str(&format!("  {}\n", l.trim()));
6928                }
6929            }
6930        }
6931    }
6932
6933    Ok(out.trim_end().to_string())
6934}
6935
6936// ── proxy ─────────────────────────────────────────────────────────────────────
6937
6938fn inspect_proxy() -> Result<String, String> {
6939    let mut out = String::from("Host inspection: proxy\n\n");
6940
6941    #[cfg(target_os = "windows")]
6942    {
6943        let script = r#"
6944$ie = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
6945if ($ie) {
6946    "ENABLE:" + $ie.ProxyEnable + "|SERVER:" + $ie.ProxyServer + "|OVERRIDE:" + $ie.ProxyOverride
6947} else { "NONE" }
6948"#;
6949        if let Ok(o) = Command::new("powershell")
6950            .args(["-NoProfile", "-Command", script])
6951            .output()
6952        {
6953            let raw = String::from_utf8_lossy(&o.stdout);
6954            let text = raw.trim();
6955            if text != "NONE" && !text.is_empty() {
6956                let get = |key: &str| -> &str {
6957                    text.split('|')
6958                        .find(|s| s.starts_with(key))
6959                        .and_then(|s| s.splitn(2, ':').nth(1))
6960                        .unwrap_or("")
6961                };
6962                let enabled = get("ENABLE");
6963                let server = get("SERVER");
6964                let overrides = get("OVERRIDE");
6965                out.push_str("WinINET / IE proxy:\n");
6966                out.push_str(&format!(
6967                    "  Enabled: {}\n",
6968                    if enabled == "1" { "yes" } else { "no" }
6969                ));
6970                if !server.is_empty() && server != "None" {
6971                    out.push_str(&format!("  Proxy server: {server}\n"));
6972                }
6973                if !overrides.is_empty() && overrides != "None" {
6974                    out.push_str(&format!("  Bypass list: {overrides}\n"));
6975                }
6976                out.push('\n');
6977            }
6978        }
6979
6980        if let Ok(o) = Command::new("netsh")
6981            .args(["winhttp", "show", "proxy"])
6982            .output()
6983        {
6984            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6985            out.push_str("WinHTTP proxy:\n");
6986            for line in text.lines() {
6987                let l = line.trim();
6988                if !l.is_empty() {
6989                    out.push_str(&format!("  {l}\n"));
6990                }
6991            }
6992            out.push('\n');
6993        }
6994
6995        let mut env_found = false;
6996        for var in &[
6997            "http_proxy",
6998            "https_proxy",
6999            "HTTP_PROXY",
7000            "HTTPS_PROXY",
7001            "no_proxy",
7002            "NO_PROXY",
7003        ] {
7004            if let Ok(val) = std::env::var(var) {
7005                if !env_found {
7006                    out.push_str("Environment proxy variables:\n");
7007                    env_found = true;
7008                }
7009                out.push_str(&format!("  {var}: {val}\n"));
7010            }
7011        }
7012        if !env_found {
7013            out.push_str("No proxy environment variables set.\n");
7014        }
7015    }
7016
7017    #[cfg(not(target_os = "windows"))]
7018    {
7019        let mut found = false;
7020        for var in &[
7021            "http_proxy",
7022            "https_proxy",
7023            "HTTP_PROXY",
7024            "HTTPS_PROXY",
7025            "no_proxy",
7026            "NO_PROXY",
7027            "ALL_PROXY",
7028            "all_proxy",
7029        ] {
7030            if let Ok(val) = std::env::var(var) {
7031                if !found {
7032                    out.push_str("Proxy environment variables:\n");
7033                    found = true;
7034                }
7035                out.push_str(&format!("  {var}: {val}\n"));
7036            }
7037        }
7038        if !found {
7039            out.push_str("No proxy environment variables set.\n");
7040        }
7041        if let Ok(content) = std::fs::read_to_string("/etc/environment") {
7042            let proxy_lines: Vec<&str> = content
7043                .lines()
7044                .filter(|l| l.to_lowercase().contains("proxy"))
7045                .collect();
7046            if !proxy_lines.is_empty() {
7047                out.push_str("\nSystem proxy (/etc/environment):\n");
7048                for l in proxy_lines {
7049                    out.push_str(&format!("  {l}\n"));
7050                }
7051            }
7052        }
7053    }
7054
7055    Ok(out.trim_end().to_string())
7056}
7057
7058// ── firewall_rules ────────────────────────────────────────────────────────────
7059
7060fn inspect_firewall_rules(max_entries: usize) -> Result<String, String> {
7061    let mut out = String::from("Host inspection: firewall_rules\n\n");
7062    let n = max_entries.clamp(1, 20);
7063
7064    #[cfg(target_os = "windows")]
7065    {
7066        let script = format!(
7067            r#"
7068try {{
7069    $rules = Get-NetFirewallRule -Enabled True -ErrorAction Stop |
7070        Where-Object {{
7071            $_.DisplayGroup -notmatch '^(@|Core Networking|Windows|File and Printer)' -and
7072            $_.Owner -eq $null
7073        }} | Select-Object -First {n} DisplayName, Direction, Action, Profile
7074    "TOTAL:" + $rules.Count
7075    $rules | ForEach-Object {{
7076        $dir = switch ($_.Direction) {{ 1 {{ "Inbound" }}; 2 {{ "Outbound" }}; default {{ "?" }} }}
7077        $act = switch ($_.Action) {{ 2 {{ "Allow" }}; 4 {{ "Block" }}; default {{ "?" }} }}
7078        $_.DisplayName + "|" + $dir + "|" + $act + "|" + $_.Profile
7079    }}
7080}} catch {{ "ERROR:" + $_.Exception.Message }}"#
7081        );
7082
7083        let output = Command::new("powershell")
7084            .args(["-NoProfile", "-Command", &script])
7085            .output()
7086            .map_err(|e| format!("firewall_rules: {e}"))?;
7087
7088        let raw = String::from_utf8_lossy(&output.stdout);
7089        let text = raw.trim();
7090
7091        if text.starts_with("ERROR:") {
7092            out.push_str(&format!(
7093                "Unable to query firewall rules: {}\n",
7094                text.trim_start_matches("ERROR:").trim()
7095            ));
7096            out.push_str("This query may require running as administrator.\n");
7097        } else if text.is_empty() {
7098            out.push_str("No non-default enabled firewall rules found.\n");
7099        } else {
7100            let mut total = 0usize;
7101            for line in text.lines() {
7102                if let Some(rest) = line.strip_prefix("TOTAL:") {
7103                    total = rest.trim().parse().unwrap_or(0);
7104                    out.push_str(&format!(
7105                        "Non-default enabled rules (showing up to {n}):\n\n"
7106                    ));
7107                } else {
7108                    let parts: Vec<&str> = line.splitn(4, '|').collect();
7109                    if parts.len() >= 3 {
7110                        let name = parts[0];
7111                        let dir = parts[1];
7112                        let action = parts[2];
7113                        let profile = parts.get(3).unwrap_or(&"Any");
7114                        let icon = if action == "Block" { "[!]" } else { "   " };
7115                        out.push_str(&format!(
7116                            "  {icon} [{dir}] {action}: {name} (profile: {profile})\n"
7117                        ));
7118                    }
7119                }
7120            }
7121            if total == 0 {
7122                out.push_str("No non-default enabled rules found.\n");
7123            }
7124        }
7125    }
7126
7127    #[cfg(not(target_os = "windows"))]
7128    {
7129        if let Ok(o) = Command::new("ufw").args(["status", "numbered"]).output() {
7130            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7131            if !text.is_empty() {
7132                out.push_str(&text);
7133                out.push('\n');
7134            }
7135        } else if let Ok(o) = Command::new("iptables")
7136            .args(["-L", "-n", "--line-numbers"])
7137            .output()
7138        {
7139            let text = String::from_utf8_lossy(&o.stdout);
7140            for l in text.lines().take(n * 2) {
7141                out.push_str(&format!("  {l}\n"));
7142            }
7143        } else {
7144            out.push_str("ufw and iptables not available or insufficient permissions.\n");
7145        }
7146    }
7147
7148    Ok(out.trim_end().to_string())
7149}
7150
7151// ── traceroute ────────────────────────────────────────────────────────────────
7152
7153fn inspect_traceroute(host: &str, max_entries: usize) -> Result<String, String> {
7154    let mut out = format!("Host inspection: traceroute\n\nTarget: {host}\n\n");
7155    let hops = max_entries.clamp(5, 30);
7156
7157    #[cfg(target_os = "windows")]
7158    {
7159        let output = Command::new("tracert")
7160            .args(["-d", "-h", &hops.to_string(), host])
7161            .output()
7162            .map_err(|e| format!("tracert: {e}"))?;
7163        let raw = String::from_utf8_lossy(&output.stdout);
7164        let mut hop_count = 0usize;
7165        for line in raw.lines() {
7166            let trimmed = line.trim();
7167            if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
7168                hop_count += 1;
7169                out.push_str(&format!("  {trimmed}\n"));
7170            } else if trimmed.starts_with("Tracing") || trimmed.starts_with("Trace complete") {
7171                out.push_str(&format!("{trimmed}\n"));
7172            }
7173        }
7174        if hop_count == 0 {
7175            out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
7176        }
7177    }
7178
7179    #[cfg(not(target_os = "windows"))]
7180    {
7181        let cmd = if std::path::Path::new("/usr/bin/traceroute").exists()
7182            || std::path::Path::new("/usr/sbin/traceroute").exists()
7183        {
7184            "traceroute"
7185        } else {
7186            "tracepath"
7187        };
7188        let output = Command::new(cmd)
7189            .args(["-m", &hops.to_string(), "-n", host])
7190            .output()
7191            .map_err(|e| format!("{cmd}: {e}"))?;
7192        let raw = String::from_utf8_lossy(&output.stdout);
7193        let mut hop_count = 0usize;
7194        for line in raw.lines().take(hops + 2) {
7195            let trimmed = line.trim();
7196            if !trimmed.is_empty() {
7197                hop_count += 1;
7198                out.push_str(&format!("  {trimmed}\n"));
7199            }
7200        }
7201        if hop_count == 0 {
7202            out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
7203        }
7204    }
7205
7206    Ok(out.trim_end().to_string())
7207}
7208
7209// ── dns_cache ─────────────────────────────────────────────────────────────────
7210
7211fn inspect_dns_cache(max_entries: usize) -> Result<String, String> {
7212    let mut out = String::from("Host inspection: dns_cache\n\n");
7213    let n = max_entries.clamp(10, 100);
7214
7215    #[cfg(target_os = "windows")]
7216    {
7217        let output = Command::new("powershell")
7218            .args([
7219                "-NoProfile",
7220                "-Command",
7221                "Get-DnsClientCache | Select-Object -First 200 Entry,RecordType,Data,TimeToLive | ConvertTo-Csv -NoTypeInformation",
7222            ])
7223            .output()
7224            .map_err(|e| format!("dns_cache: {e}"))?;
7225
7226        let raw = String::from_utf8_lossy(&output.stdout);
7227        let lines: Vec<&str> = raw.lines().skip(1).collect();
7228        let total = lines.len();
7229
7230        if total == 0 {
7231            out.push_str("DNS cache is empty or could not be read.\n");
7232        } else {
7233            out.push_str(&format!(
7234                "DNS cache entries (showing up to {n} of {total}):\n\n"
7235            ));
7236            let mut shown = 0usize;
7237            for line in lines.iter().take(n) {
7238                let cols: Vec<&str> = line.splitn(4, ',').collect();
7239                if cols.len() >= 3 {
7240                    let entry = cols[0].trim_matches('"');
7241                    let rtype = cols[1].trim_matches('"');
7242                    let data = cols[2].trim_matches('"');
7243                    let ttl = cols.get(3).map(|s| s.trim_matches('"')).unwrap_or("?");
7244                    out.push_str(&format!("  {entry:<45} {rtype:<6} {data}  (TTL {ttl}s)\n"));
7245                    shown += 1;
7246                }
7247            }
7248            if total > shown {
7249                out.push_str(&format!("\n  ... and {} more entries\n", total - shown));
7250            }
7251        }
7252    }
7253
7254    #[cfg(not(target_os = "windows"))]
7255    {
7256        if let Ok(o) = Command::new("resolvectl").args(["statistics"]).output() {
7257            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7258            if !text.is_empty() {
7259                out.push_str("systemd-resolved statistics:\n");
7260                for line in text.lines().take(n) {
7261                    out.push_str(&format!("  {line}\n"));
7262                }
7263                out.push('\n');
7264            }
7265        }
7266        if let Ok(o) = Command::new("dscacheutil")
7267            .args(["-cachedump", "-entries", "Host"])
7268            .output()
7269        {
7270            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7271            if !text.is_empty() {
7272                out.push_str("DNS cache (macOS dscacheutil):\n");
7273                for line in text.lines().take(n) {
7274                    out.push_str(&format!("  {line}\n"));
7275                }
7276            } else {
7277                out.push_str("DNS cache is empty or not accessible on this platform.\n");
7278            }
7279        } else {
7280            out.push_str(
7281                "DNS cache inspection not available (no resolvectl or dscacheutil found).\n",
7282            );
7283        }
7284    }
7285
7286    Ok(out.trim_end().to_string())
7287}
7288
7289// ── arp ───────────────────────────────────────────────────────────────────────
7290
7291fn inspect_arp() -> Result<String, String> {
7292    let mut out = String::from("Host inspection: arp\n\n");
7293
7294    #[cfg(target_os = "windows")]
7295    {
7296        let output = Command::new("arp")
7297            .args(["-a"])
7298            .output()
7299            .map_err(|e| format!("arp: {e}"))?;
7300        let raw = String::from_utf8_lossy(&output.stdout);
7301        let mut count = 0usize;
7302        for line in raw.lines() {
7303            let t = line.trim();
7304            if t.is_empty() {
7305                continue;
7306            }
7307            out.push_str(&format!("  {t}\n"));
7308            if t.contains("dynamic") || t.contains("static") {
7309                count += 1;
7310            }
7311        }
7312        out.push_str(&format!("\nTotal entries: {count}\n"));
7313    }
7314
7315    #[cfg(not(target_os = "windows"))]
7316    {
7317        if let Ok(o) = Command::new("arp").args(["-n"]).output() {
7318            let raw = String::from_utf8_lossy(&o.stdout);
7319            let mut count = 0usize;
7320            for line in raw.lines() {
7321                let t = line.trim();
7322                if !t.is_empty() {
7323                    out.push_str(&format!("  {t}\n"));
7324                    count += 1;
7325                }
7326            }
7327            out.push_str(&format!("\nTotal entries: {}\n", count.saturating_sub(1)));
7328        } else if let Ok(o) = Command::new("ip").args(["neigh"]).output() {
7329            let raw = String::from_utf8_lossy(&o.stdout);
7330            let mut count = 0usize;
7331            for line in raw.lines() {
7332                let t = line.trim();
7333                if !t.is_empty() {
7334                    out.push_str(&format!("  {t}\n"));
7335                    count += 1;
7336                }
7337            }
7338            out.push_str(&format!("\nTotal entries: {count}\n"));
7339        } else {
7340            out.push_str("arp and ip neigh not available.\n");
7341        }
7342    }
7343
7344    Ok(out.trim_end().to_string())
7345}
7346
7347// ── route_table ───────────────────────────────────────────────────────────────
7348
7349fn inspect_route_table(max_entries: usize) -> Result<String, String> {
7350    let mut out = String::from("Host inspection: route_table\n\n");
7351    let n = max_entries.clamp(10, 50);
7352
7353    #[cfg(target_os = "windows")]
7354    {
7355        let script = r#"
7356try {
7357    $routes = Get-NetRoute -ErrorAction Stop |
7358        Where-Object { $_.RouteMetric -lt 9000 } |
7359        Sort-Object RouteMetric |
7360        Select-Object DestinationPrefix, NextHop, RouteMetric, InterfaceAlias
7361    "TOTAL:" + $routes.Count
7362    $routes | ForEach-Object {
7363        $_.DestinationPrefix + "|" + $_.NextHop + "|" + $_.RouteMetric + "|" + $_.InterfaceAlias
7364    }
7365} catch { "ERROR:" + $_.Exception.Message }
7366"#;
7367        let output = Command::new("powershell")
7368            .args(["-NoProfile", "-Command", script])
7369            .output()
7370            .map_err(|e| format!("route_table: {e}"))?;
7371        let raw = String::from_utf8_lossy(&output.stdout);
7372        let text = raw.trim();
7373
7374        if text.starts_with("ERROR:") {
7375            out.push_str(&format!(
7376                "Unable to read route table: {}\n",
7377                text.trim_start_matches("ERROR:").trim()
7378            ));
7379        } else {
7380            let mut shown = 0usize;
7381            for line in text.lines() {
7382                if let Some(rest) = line.strip_prefix("TOTAL:") {
7383                    let total: usize = rest.trim().parse().unwrap_or(0);
7384                    out.push_str(&format!(
7385                        "Routing table (showing up to {n} of {total} routes):\n\n"
7386                    ));
7387                    out.push_str(&format!(
7388                        "  {:<22} {:<18} {:>8}  Interface\n",
7389                        "Destination", "Next Hop", "Metric"
7390                    ));
7391                    out.push_str(&format!("  {}\n", "-".repeat(70)));
7392                } else if shown < n {
7393                    let parts: Vec<&str> = line.splitn(4, '|').collect();
7394                    if parts.len() == 4 {
7395                        let dest = parts[0];
7396                        let hop =
7397                            if parts[1].is_empty() || parts[1] == "0.0.0.0" || parts[1] == "::" {
7398                                "on-link"
7399                            } else {
7400                                parts[1]
7401                            };
7402                        let metric = parts[2];
7403                        let iface = parts[3];
7404                        out.push_str(&format!("  {dest:<22} {hop:<18} {metric:>8}  {iface}\n"));
7405                        shown += 1;
7406                    }
7407                }
7408            }
7409        }
7410    }
7411
7412    #[cfg(not(target_os = "windows"))]
7413    {
7414        if let Ok(o) = Command::new("ip").args(["route", "show"]).output() {
7415            let raw = String::from_utf8_lossy(&o.stdout);
7416            let lines: Vec<&str> = raw.lines().collect();
7417            let total = lines.len();
7418            out.push_str(&format!(
7419                "Routing table (showing up to {n} of {total} routes):\n\n"
7420            ));
7421            for line in lines.iter().take(n) {
7422                out.push_str(&format!("  {line}\n"));
7423            }
7424            if total > n {
7425                out.push_str(&format!("\n  ... and {} more routes\n", total - n));
7426            }
7427        } else if let Ok(o) = Command::new("netstat").args(["-rn"]).output() {
7428            let raw = String::from_utf8_lossy(&o.stdout);
7429            for line in raw.lines().take(n) {
7430                out.push_str(&format!("  {line}\n"));
7431            }
7432        } else {
7433            out.push_str("ip route and netstat not available.\n");
7434        }
7435    }
7436
7437    Ok(out.trim_end().to_string())
7438}
7439
7440// ── env ───────────────────────────────────────────────────────────────────────
7441
7442fn inspect_env(max_entries: usize) -> Result<String, String> {
7443    let mut out = String::from("Host inspection: env\n\n");
7444    let n = max_entries.clamp(10, 50);
7445
7446    fn looks_like_secret(name: &str) -> bool {
7447        let n = name.to_uppercase();
7448        n.contains("KEY")
7449            || n.contains("SECRET")
7450            || n.contains("TOKEN")
7451            || n.contains("PASSWORD")
7452            || n.contains("PASSWD")
7453            || n.contains("CREDENTIAL")
7454            || n.contains("AUTH")
7455            || n.contains("CERT")
7456            || n.contains("PRIVATE")
7457    }
7458
7459    let known_dev_vars: &[&str] = &[
7460        "CARGO_HOME",
7461        "RUSTUP_HOME",
7462        "GOPATH",
7463        "GOROOT",
7464        "GOBIN",
7465        "JAVA_HOME",
7466        "ANDROID_HOME",
7467        "ANDROID_SDK_ROOT",
7468        "PYTHONPATH",
7469        "PYTHONHOME",
7470        "VIRTUAL_ENV",
7471        "CONDA_DEFAULT_ENV",
7472        "CONDA_PREFIX",
7473        "NODE_PATH",
7474        "NVM_DIR",
7475        "NVM_BIN",
7476        "PNPM_HOME",
7477        "DENO_INSTALL",
7478        "DENO_DIR",
7479        "DOTNET_ROOT",
7480        "NUGET_PACKAGES",
7481        "CMAKE_HOME",
7482        "VCPKG_ROOT",
7483        "AWS_PROFILE",
7484        "AWS_REGION",
7485        "AWS_DEFAULT_REGION",
7486        "GCP_PROJECT",
7487        "GOOGLE_CLOUD_PROJECT",
7488        "GOOGLE_APPLICATION_CREDENTIALS",
7489        "AZURE_SUBSCRIPTION_ID",
7490        "DATABASE_URL",
7491        "REDIS_URL",
7492        "MONGO_URI",
7493        "EDITOR",
7494        "VISUAL",
7495        "SHELL",
7496        "TERM",
7497        "XDG_CONFIG_HOME",
7498        "XDG_DATA_HOME",
7499        "XDG_CACHE_HOME",
7500        "HOME",
7501        "USERPROFILE",
7502        "APPDATA",
7503        "LOCALAPPDATA",
7504        "TEMP",
7505        "TMP",
7506        "COMPUTERNAME",
7507        "USERNAME",
7508        "USERDOMAIN",
7509        "PROCESSOR_ARCHITECTURE",
7510        "NUMBER_OF_PROCESSORS",
7511        "OS",
7512        "HOMEDRIVE",
7513        "HOMEPATH",
7514        "HTTP_PROXY",
7515        "HTTPS_PROXY",
7516        "NO_PROXY",
7517        "ALL_PROXY",
7518        "http_proxy",
7519        "https_proxy",
7520        "no_proxy",
7521        "DOCKER_HOST",
7522        "DOCKER_BUILDKIT",
7523        "COMPOSE_PROJECT_NAME",
7524        "KUBECONFIG",
7525        "KUBE_CONTEXT",
7526        "CI",
7527        "GITHUB_ACTIONS",
7528        "GITLAB_CI",
7529        "LMSTUDIO_HOME",
7530        "HEMATITE_URL",
7531    ];
7532
7533    let mut all_vars: Vec<(String, String)> = std::env::vars().collect();
7534    all_vars.sort_by(|a, b| a.0.cmp(&b.0));
7535    let total = all_vars.len();
7536
7537    let mut dev_found: Vec<String> = Vec::new();
7538    let mut secret_found: Vec<String> = Vec::new();
7539
7540    for (k, v) in &all_vars {
7541        if k == "PATH" {
7542            continue;
7543        }
7544        if looks_like_secret(k) {
7545            secret_found.push(format!("{k} = [SET, {} chars]", v.len()));
7546        } else {
7547            let k_upper = k.to_uppercase();
7548            let is_known = known_dev_vars
7549                .iter()
7550                .any(|kv| k_upper.as_str() == kv.to_uppercase().as_str());
7551            if is_known {
7552                let display = if v.len() > 120 {
7553                    format!("{k} = {}…", &v[..117])
7554                } else {
7555                    format!("{k} = {v}")
7556                };
7557                dev_found.push(display);
7558            }
7559        }
7560    }
7561
7562    out.push_str(&format!("Total environment variables: {total}\n\n"));
7563
7564    if let Ok(p) = std::env::var("PATH") {
7565        let sep = if cfg!(target_os = "windows") {
7566            ';'
7567        } else {
7568            ':'
7569        };
7570        let count = p.split(sep).count();
7571        out.push_str(&format!(
7572            "PATH: {count} entries (use topic=path for full audit)\n\n"
7573        ));
7574    }
7575
7576    if !secret_found.is_empty() {
7577        out.push_str(&format!(
7578            "=== Secret/credential variables ({} detected, values hidden) ===\n",
7579            secret_found.len()
7580        ));
7581        for s in secret_found.iter().take(n) {
7582            out.push_str(&format!("  {s}\n"));
7583        }
7584        out.push('\n');
7585    }
7586
7587    if !dev_found.is_empty() {
7588        out.push_str(&format!(
7589            "=== Developer & tool variables ({}) ===\n",
7590            dev_found.len()
7591        ));
7592        for d in dev_found.iter().take(n) {
7593            out.push_str(&format!("  {d}\n"));
7594        }
7595        out.push('\n');
7596    }
7597
7598    let other_count = all_vars
7599        .iter()
7600        .filter(|(k, _)| {
7601            k != "PATH"
7602                && !looks_like_secret(k)
7603                && !known_dev_vars
7604                    .iter()
7605                    .any(|kv| k.to_uppercase().as_str() == kv.to_uppercase().as_str())
7606        })
7607        .count();
7608    if other_count > 0 {
7609        out.push_str(&format!(
7610            "Other variables: {other_count} (use 'env' in shell to see all)\n"
7611        ));
7612    }
7613
7614    Ok(out.trim_end().to_string())
7615}
7616
7617// ── hosts_file ────────────────────────────────────────────────────────────────
7618
7619fn inspect_hosts_file() -> Result<String, String> {
7620    let mut out = String::from("Host inspection: hosts_file\n\n");
7621
7622    let hosts_path = if cfg!(target_os = "windows") {
7623        std::path::PathBuf::from(r"C:\Windows\System32\drivers\etc\hosts")
7624    } else {
7625        std::path::PathBuf::from("/etc/hosts")
7626    };
7627
7628    out.push_str(&format!("Path: {}\n\n", hosts_path.display()));
7629
7630    match fs::read_to_string(&hosts_path) {
7631        Ok(content) => {
7632            let mut active_entries: Vec<String> = Vec::new();
7633            let mut comment_lines = 0usize;
7634            let mut blank_lines = 0usize;
7635
7636            for line in content.lines() {
7637                let t = line.trim();
7638                if t.is_empty() {
7639                    blank_lines += 1;
7640                } else if t.starts_with('#') {
7641                    comment_lines += 1;
7642                } else {
7643                    active_entries.push(line.to_string());
7644                }
7645            }
7646
7647            out.push_str(&format!(
7648                "Active entries: {}  |  Comment lines: {}  |  Blank lines: {}\n\n",
7649                active_entries.len(),
7650                comment_lines,
7651                blank_lines
7652            ));
7653
7654            if active_entries.is_empty() {
7655                out.push_str(
7656                    "No active host entries (file contains only comments/blanks — standard default state).\n",
7657                );
7658            } else {
7659                out.push_str("=== Active entries ===\n");
7660                for entry in &active_entries {
7661                    out.push_str(&format!("  {entry}\n"));
7662                }
7663                out.push('\n');
7664
7665                let custom: Vec<&String> = active_entries
7666                    .iter()
7667                    .filter(|e| {
7668                        let t = e.trim_start();
7669                        !t.starts_with("127.") && !t.starts_with("::1") && !t.starts_with("0.0.0.0")
7670                    })
7671                    .collect();
7672                if !custom.is_empty() {
7673                    out.push_str(&format!(
7674                        "[!] Custom (non-loopback) entries: {}\n",
7675                        custom.len()
7676                    ));
7677                    for e in &custom {
7678                        out.push_str(&format!("  {e}\n"));
7679                    }
7680                } else {
7681                    out.push_str("All active entries are standard loopback or block entries.\n");
7682                }
7683            }
7684
7685            out.push_str("\n=== Full file ===\n");
7686            for line in content.lines() {
7687                out.push_str(&format!("  {line}\n"));
7688            }
7689        }
7690        Err(e) => {
7691            out.push_str(&format!("Could not read hosts file: {e}\n"));
7692            if cfg!(target_os = "windows") {
7693                out.push_str(
7694                    "On Windows, run Hematite as Administrator if permission is denied.\n",
7695                );
7696            }
7697        }
7698    }
7699
7700    Ok(out.trim_end().to_string())
7701}
7702
7703// ── docker ────────────────────────────────────────────────────────────────────
7704
7705struct AuditFinding {
7706    finding: String,
7707    impact: String,
7708    fix: String,
7709}
7710
7711#[cfg(target_os = "windows")]
7712#[derive(Debug, Clone)]
7713struct WindowsPnpDevice {
7714    name: String,
7715    status: String,
7716    problem: Option<u64>,
7717    class_name: Option<String>,
7718    instance_id: Option<String>,
7719}
7720
7721#[cfg(target_os = "windows")]
7722#[derive(Debug, Clone)]
7723struct WindowsSoundDevice {
7724    name: String,
7725    status: String,
7726    manufacturer: Option<String>,
7727}
7728
7729struct DockerMountAudit {
7730    mount_type: String,
7731    source: Option<String>,
7732    destination: String,
7733    name: Option<String>,
7734    read_write: Option<bool>,
7735    driver: Option<String>,
7736    exists_on_host: Option<bool>,
7737}
7738
7739struct DockerContainerAudit {
7740    name: String,
7741    image: String,
7742    status: String,
7743    mounts: Vec<DockerMountAudit>,
7744}
7745
7746struct DockerVolumeAudit {
7747    name: String,
7748    driver: String,
7749    mountpoint: Option<String>,
7750    scope: Option<String>,
7751}
7752
7753#[cfg(target_os = "windows")]
7754struct WslDistroAudit {
7755    name: String,
7756    state: String,
7757    version: String,
7758}
7759
7760#[cfg(target_os = "windows")]
7761struct WslRootUsage {
7762    total_kb: u64,
7763    used_kb: u64,
7764    avail_kb: u64,
7765    use_percent: String,
7766    mnt_c_present: Option<bool>,
7767}
7768
7769fn docker_engine_version() -> Result<String, String> {
7770    let version_output = Command::new("docker")
7771        .args(["version", "--format", "{{.Server.Version}}"])
7772        .output();
7773
7774    match version_output {
7775        Err(_) => Err(
7776            "Docker: not found on PATH.\nInstall Docker Desktop: https://www.docker.com/products/docker-desktop".to_string(),
7777        ),
7778        Ok(o) if !o.status.success() => {
7779            let stderr = String::from_utf8_lossy(&o.stderr);
7780            if stderr.contains("cannot connect")
7781                || stderr.contains("Is the docker daemon running")
7782                || stderr.contains("pipe")
7783                || stderr.contains("socket")
7784            {
7785                Err(
7786                    "Docker: installed but daemon is NOT running.\nStart Docker Desktop or run: sudo systemctl start docker".to_string(),
7787                )
7788            } else {
7789                Err(format!("Docker: error - {}", stderr.trim()))
7790            }
7791        }
7792        Ok(o) => Ok(String::from_utf8_lossy(&o.stdout).trim().to_string()),
7793    }
7794}
7795
7796fn parse_docker_mounts(raw: &str) -> Vec<DockerMountAudit> {
7797    let Ok(value) = serde_json::from_str::<Value>(raw.trim()) else {
7798        return Vec::new();
7799    };
7800    let Value::Array(entries) = value else {
7801        return Vec::new();
7802    };
7803
7804    let mut mounts = Vec::new();
7805    for entry in entries {
7806        let mount_type = entry
7807            .get("Type")
7808            .and_then(|v| v.as_str())
7809            .unwrap_or("unknown")
7810            .to_string();
7811        let source = entry
7812            .get("Source")
7813            .and_then(|v| v.as_str())
7814            .map(|v| v.to_string());
7815        let destination = entry
7816            .get("Destination")
7817            .and_then(|v| v.as_str())
7818            .unwrap_or("?")
7819            .to_string();
7820        let name = entry
7821            .get("Name")
7822            .and_then(|v| v.as_str())
7823            .map(|v| v.to_string());
7824        let read_write = entry.get("RW").and_then(|v| v.as_bool());
7825        let driver = entry
7826            .get("Driver")
7827            .and_then(|v| v.as_str())
7828            .map(|v| v.to_string());
7829        let exists_on_host = if mount_type == "bind" {
7830            source.as_deref().map(|path| Path::new(path).exists())
7831        } else {
7832            None
7833        };
7834        mounts.push(DockerMountAudit {
7835            mount_type,
7836            source,
7837            destination,
7838            name,
7839            read_write,
7840            driver,
7841            exists_on_host,
7842        });
7843    }
7844
7845    mounts
7846}
7847
7848fn inspect_docker_volume(name: &str) -> DockerVolumeAudit {
7849    let mut audit = DockerVolumeAudit {
7850        name: name.to_string(),
7851        driver: "unknown".to_string(),
7852        mountpoint: None,
7853        scope: None,
7854    };
7855
7856    if let Ok(output) = Command::new("docker")
7857        .args(["volume", "inspect", name, "--format", "{{json .}}"])
7858        .output()
7859    {
7860        if output.status.success() {
7861            if let Ok(value) =
7862                serde_json::from_str::<Value>(String::from_utf8_lossy(&output.stdout).trim())
7863            {
7864                audit.driver = value
7865                    .get("Driver")
7866                    .and_then(|v| v.as_str())
7867                    .unwrap_or("unknown")
7868                    .to_string();
7869                audit.mountpoint = value
7870                    .get("Mountpoint")
7871                    .and_then(|v| v.as_str())
7872                    .map(|v| v.to_string());
7873                audit.scope = value
7874                    .get("Scope")
7875                    .and_then(|v| v.as_str())
7876                    .map(|v| v.to_string());
7877            }
7878        }
7879    }
7880
7881    audit
7882}
7883
7884#[cfg(target_os = "windows")]
7885fn docker_desktop_disk_image() -> Option<(PathBuf, u64)> {
7886    let local_app_data = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)?;
7887    for file_name in ["docker_data.vhdx", "ext4.vhdx"] {
7888        let path = local_app_data
7889            .join("Docker")
7890            .join("wsl")
7891            .join("disk")
7892            .join(file_name);
7893        if let Ok(metadata) = fs::metadata(&path) {
7894            return Some((path, metadata.len()));
7895        }
7896    }
7897    None
7898}
7899
7900#[cfg(target_os = "windows")]
7901fn clean_wsl_text(raw: &[u8]) -> String {
7902    String::from_utf8_lossy(raw)
7903        .chars()
7904        .filter(|c| *c != '\0')
7905        .collect()
7906}
7907
7908#[cfg(target_os = "windows")]
7909fn parse_wsl_distros(raw: &str) -> Vec<WslDistroAudit> {
7910    let mut distros = Vec::new();
7911    for line in raw.lines() {
7912        let trimmed = line.trim();
7913        if trimmed.is_empty()
7914            || trimmed.to_uppercase().starts_with("NAME")
7915            || trimmed.starts_with("---")
7916        {
7917            continue;
7918        }
7919        let normalized = trimmed.trim_start_matches('*').trim();
7920        let cols: Vec<&str> = normalized.split_whitespace().collect();
7921        if cols.len() < 3 {
7922            continue;
7923        }
7924        let version = cols[cols.len() - 1].to_string();
7925        let state = cols[cols.len() - 2].to_string();
7926        let name = cols[..cols.len() - 2].join(" ");
7927        if !name.is_empty() {
7928            distros.push(WslDistroAudit {
7929                name,
7930                state,
7931                version,
7932            });
7933        }
7934    }
7935    distros
7936}
7937
7938#[cfg(target_os = "windows")]
7939fn wsl_root_usage(distro_name: &str) -> Option<WslRootUsage> {
7940    let output = Command::new("wsl")
7941        .args([
7942            "-d",
7943            distro_name,
7944            "--",
7945            "sh",
7946            "-lc",
7947            "df -k / 2>/dev/null | tail -n 1; if [ -d /mnt/c ]; then echo __MNTC__:ok; else echo __MNTC__:missing; fi",
7948        ])
7949        .output()
7950        .ok()?;
7951    if !output.status.success() {
7952        return None;
7953    }
7954
7955    let text = clean_wsl_text(&output.stdout);
7956    let mut total_kb = 0;
7957    let mut used_kb = 0;
7958    let mut avail_kb = 0;
7959    let mut use_percent = String::from("unknown");
7960    let mut mnt_c_present = None;
7961
7962    for line in text.lines() {
7963        let trimmed = line.trim();
7964        if trimmed.starts_with("__MNTC__:") {
7965            mnt_c_present = Some(trimmed.ends_with("ok"));
7966            continue;
7967        }
7968        let cols: Vec<&str> = trimmed.split_whitespace().collect();
7969        if cols.len() >= 6 {
7970            total_kb = cols[1].parse::<u64>().unwrap_or(0);
7971            used_kb = cols[2].parse::<u64>().unwrap_or(0);
7972            avail_kb = cols[3].parse::<u64>().unwrap_or(0);
7973            use_percent = cols[4].to_string();
7974        }
7975    }
7976
7977    Some(WslRootUsage {
7978        total_kb,
7979        used_kb,
7980        avail_kb,
7981        use_percent,
7982        mnt_c_present,
7983    })
7984}
7985
7986#[cfg(target_os = "windows")]
7987fn collect_wsl_vhdx_files() -> Vec<(PathBuf, u64)> {
7988    let mut vhds = Vec::new();
7989    let Some(local_app_data) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) else {
7990        return vhds;
7991    };
7992    let packages_dir = local_app_data.join("Packages");
7993    let Ok(entries) = fs::read_dir(packages_dir) else {
7994        return vhds;
7995    };
7996
7997    for entry in entries.flatten() {
7998        let path = entry.path().join("LocalState").join("ext4.vhdx");
7999        if let Ok(metadata) = fs::metadata(&path) {
8000            vhds.push((path, metadata.len()));
8001        }
8002    }
8003    vhds.sort_by(|a, b| b.1.cmp(&a.1));
8004    vhds
8005}
8006
8007fn inspect_docker(max_entries: usize) -> Result<String, String> {
8008    let mut out = String::from("Host inspection: docker\n\n");
8009    let n = max_entries.clamp(5, 25);
8010
8011    let version_output = Command::new("docker")
8012        .args(["version", "--format", "{{.Server.Version}}"])
8013        .output();
8014
8015    match version_output {
8016        Err(_) => {
8017            out.push_str("Docker: not found on PATH.\n");
8018            out.push_str(
8019                "Install Docker Desktop: https://www.docker.com/products/docker-desktop\n",
8020            );
8021            return Ok(out.trim_end().to_string());
8022        }
8023        Ok(o) if !o.status.success() => {
8024            let stderr = String::from_utf8_lossy(&o.stderr);
8025            if stderr.contains("cannot connect")
8026                || stderr.contains("Is the docker daemon running")
8027                || stderr.contains("pipe")
8028                || stderr.contains("socket")
8029            {
8030                out.push_str("Docker: installed but daemon is NOT running.\n");
8031                out.push_str("Start Docker Desktop or run: sudo systemctl start docker\n");
8032            } else {
8033                out.push_str(&format!("Docker: error — {}\n", stderr.trim()));
8034            }
8035            return Ok(out.trim_end().to_string());
8036        }
8037        Ok(o) => {
8038            let version = String::from_utf8_lossy(&o.stdout).trim().to_string();
8039            out.push_str(&format!("Docker Engine: {version}\n"));
8040        }
8041    }
8042
8043    if let Ok(o) = Command::new("docker")
8044        .args([
8045            "info",
8046            "--format",
8047            "Containers: {{.Containers}} (running: {{.ContainersRunning}}, stopped: {{.ContainersStopped}})\nImages: {{.Images}}\nStorage driver: {{.Driver}}\nOS/Arch: {{.OSType}}/{{.Architecture}}\nCPUs: {{.NCPU}}",
8048        ])
8049        .output()
8050    {
8051        let info = String::from_utf8_lossy(&o.stdout);
8052        for line in info.lines() {
8053            let t = line.trim();
8054            if !t.is_empty() {
8055                out.push_str(&format!("  {t}\n"));
8056            }
8057        }
8058        out.push('\n');
8059    }
8060
8061    if let Ok(o) = Command::new("docker")
8062        .args([
8063            "ps",
8064            "--format",
8065            "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
8066        ])
8067        .output()
8068    {
8069        let raw = String::from_utf8_lossy(&o.stdout);
8070        let lines: Vec<&str> = raw.lines().collect();
8071        if lines.len() <= 1 {
8072            out.push_str("Running containers: none\n\n");
8073        } else {
8074            out.push_str(&format!(
8075                "=== Running containers ({}) ===\n",
8076                lines.len().saturating_sub(1)
8077            ));
8078            for line in lines.iter().take(n + 1) {
8079                out.push_str(&format!("  {line}\n"));
8080            }
8081            if lines.len() > n + 1 {
8082                out.push_str(&format!("  ... and {} more\n", lines.len() - n - 1));
8083            }
8084            out.push('\n');
8085        }
8086    }
8087
8088    if let Ok(o) = Command::new("docker")
8089        .args([
8090            "images",
8091            "--format",
8092            "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}",
8093        ])
8094        .output()
8095    {
8096        let raw = String::from_utf8_lossy(&o.stdout);
8097        let lines: Vec<&str> = raw.lines().collect();
8098        if lines.len() > 1 {
8099            out.push_str(&format!(
8100                "=== Local images ({}) ===\n",
8101                lines.len().saturating_sub(1)
8102            ));
8103            for line in lines.iter().take(n + 1) {
8104                out.push_str(&format!("  {line}\n"));
8105            }
8106            if lines.len() > n + 1 {
8107                out.push_str(&format!("  ... and {} more\n", lines.len() - n - 1));
8108            }
8109            out.push('\n');
8110        }
8111    }
8112
8113    if let Ok(o) = Command::new("docker")
8114        .args([
8115            "compose",
8116            "ls",
8117            "--format",
8118            "table {{.Name}}\t{{.Status}}\t{{.ConfigFiles}}",
8119        ])
8120        .output()
8121    {
8122        let raw = String::from_utf8_lossy(&o.stdout);
8123        let lines: Vec<&str> = raw.lines().collect();
8124        if lines.len() > 1 {
8125            out.push_str(&format!(
8126                "=== Compose projects ({}) ===\n",
8127                lines.len().saturating_sub(1)
8128            ));
8129            for line in lines.iter().take(n + 1) {
8130                out.push_str(&format!("  {line}\n"));
8131            }
8132            out.push('\n');
8133        }
8134    }
8135
8136    if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
8137        let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
8138        if !ctx.is_empty() {
8139            out.push_str(&format!("Active context: {ctx}\n"));
8140        }
8141    }
8142
8143    Ok(out.trim_end().to_string())
8144}
8145
8146// ── wsl ───────────────────────────────────────────────────────────────────────
8147
8148fn inspect_docker_filesystems(max_entries: usize) -> Result<String, String> {
8149    let mut out = String::from("Host inspection: docker_filesystems\n\n");
8150    let n = max_entries.clamp(3, 12);
8151
8152    match docker_engine_version() {
8153        Ok(version) => out.push_str(&format!("Docker Engine: {version}\n")),
8154        Err(message) => {
8155            out.push_str(&message);
8156            return Ok(out.trim_end().to_string());
8157        }
8158    }
8159
8160    if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
8161        let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
8162        if !ctx.is_empty() {
8163            out.push_str(&format!("Active context: {ctx}\n"));
8164        }
8165    }
8166    out.push('\n');
8167
8168    let mut containers = Vec::new();
8169    if let Ok(o) = Command::new("docker")
8170        .args([
8171            "ps",
8172            "-a",
8173            "--format",
8174            "{{.Names}}\t{{.Image}}\t{{.Status}}",
8175        ])
8176        .output()
8177    {
8178        for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
8179            let cols: Vec<&str> = line.split('\t').collect();
8180            if cols.len() < 3 {
8181                continue;
8182            }
8183            let name = cols[0].trim().to_string();
8184            if name.is_empty() {
8185                continue;
8186            }
8187            let inspect_output = Command::new("docker")
8188                .args(["inspect", &name, "--format", "{{json .Mounts}}"])
8189                .output();
8190            let mounts = match inspect_output {
8191                Ok(result) if result.status.success() => {
8192                    parse_docker_mounts(String::from_utf8_lossy(&result.stdout).trim())
8193                }
8194                _ => Vec::new(),
8195            };
8196            containers.push(DockerContainerAudit {
8197                name,
8198                image: cols[1].trim().to_string(),
8199                status: cols[2].trim().to_string(),
8200                mounts,
8201            });
8202        }
8203    }
8204
8205    let mut volumes = Vec::new();
8206    if let Ok(o) = Command::new("docker")
8207        .args(["volume", "ls", "--format", "{{.Name}}\t{{.Driver}}"])
8208        .output()
8209    {
8210        for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
8211            let cols: Vec<&str> = line.split('\t').collect();
8212            let Some(name) = cols.first().map(|v| v.trim()).filter(|v| !v.is_empty()) else {
8213                continue;
8214            };
8215            let mut audit = inspect_docker_volume(name);
8216            if audit.driver == "unknown" {
8217                audit.driver = cols
8218                    .get(1)
8219                    .map(|v| v.trim())
8220                    .filter(|v| !v.is_empty())
8221                    .unwrap_or("unknown")
8222                    .to_string();
8223            }
8224            volumes.push(audit);
8225        }
8226    }
8227
8228    let mut findings = Vec::new();
8229    for container in &containers {
8230        for mount in &container.mounts {
8231            if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
8232                let source = mount.source.as_deref().unwrap_or("<unknown>");
8233                findings.push(AuditFinding {
8234                    finding: format!(
8235                        "Container '{}' has a bind mount whose host source is missing: {} -> {}",
8236                        container.name, source, mount.destination
8237                    ),
8238                    impact: "The container may fail to start, or it may see an empty or incomplete directory at the target path.".to_string(),
8239                    fix: "Create the host path or correct the bind source in docker-compose.yml / docker run, then recreate the container.".to_string(),
8240                });
8241            }
8242        }
8243    }
8244
8245    #[cfg(target_os = "windows")]
8246    if let Some((path, size_bytes)) = docker_desktop_disk_image() {
8247        if size_bytes >= 20 * 1024 * 1024 * 1024 {
8248            findings.push(AuditFinding {
8249                finding: format!(
8250                    "Docker Desktop disk image is large: {} at {}",
8251                    human_bytes(size_bytes),
8252                    path.display()
8253                ),
8254                impact: "Unused layers, volumes, and build cache can silently consume Windows disk even after projects are deleted.".to_string(),
8255                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(),
8256            });
8257        }
8258    }
8259
8260    out.push_str("=== Findings ===\n");
8261    if findings.is_empty() {
8262        out.push_str("- Finding: No missing bind-mount sources or oversized Docker Desktop disk images were detected.\n");
8263        out.push_str("  Impact: The Docker host-side filesystem wiring looks sane from this inspection pass.\n");
8264        out.push_str("  Fix: If a workload still cannot see files, compare the mount destinations below against the app's expected paths.\n");
8265    } else {
8266        for finding in &findings {
8267            out.push_str(&format!("- Finding: {}\n", finding.finding));
8268            out.push_str(&format!("  Impact: {}\n", finding.impact));
8269            out.push_str(&format!("  Fix: {}\n", finding.fix));
8270        }
8271    }
8272
8273    out.push_str("\n=== Container mount summary ===\n");
8274    if containers.is_empty() {
8275        out.push_str("- No containers found.\n");
8276    } else {
8277        for container in &containers {
8278            out.push_str(&format!(
8279                "- {} ({}) [{}]\n",
8280                container.name, container.image, container.status
8281            ));
8282            if container.mounts.is_empty() {
8283                out.push_str("  - no mounts reported\n");
8284                continue;
8285            }
8286            for mount in &container.mounts {
8287                let mut source = mount
8288                    .name
8289                    .clone()
8290                    .or_else(|| mount.source.clone())
8291                    .unwrap_or_else(|| "<unknown>".to_string());
8292                if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
8293                    source.push_str(" [missing]");
8294                }
8295                let mut extras = Vec::new();
8296                if let Some(rw) = mount.read_write {
8297                    extras.push(if rw { "rw" } else { "ro" }.to_string());
8298                }
8299                if let Some(driver) = &mount.driver {
8300                    extras.push(format!("driver={driver}"));
8301                }
8302                let extra_suffix = if extras.is_empty() {
8303                    String::new()
8304                } else {
8305                    format!(" ({})", extras.join(", "))
8306                };
8307                out.push_str(&format!(
8308                    "  - {}: {} -> {}{}\n",
8309                    mount.mount_type, source, mount.destination, extra_suffix
8310                ));
8311            }
8312        }
8313    }
8314
8315    out.push_str("\n=== Named volumes ===\n");
8316    if volumes.is_empty() {
8317        out.push_str("- No named volumes found.\n");
8318    } else {
8319        for volume in &volumes {
8320            let mut detail = format!("- {} (driver: {})", volume.name, volume.driver);
8321            if let Some(scope) = &volume.scope {
8322                detail.push_str(&format!(", scope: {scope}"));
8323            }
8324            if let Some(mountpoint) = &volume.mountpoint {
8325                detail.push_str(&format!(", mountpoint: {mountpoint}"));
8326            }
8327            out.push_str(&format!("{detail}\n"));
8328        }
8329    }
8330
8331    #[cfg(target_os = "windows")]
8332    if let Some((path, size_bytes)) = docker_desktop_disk_image() {
8333        out.push_str("\n=== Docker Desktop disk ===\n");
8334        out.push_str(&format!(
8335            "- {} at {}\n",
8336            human_bytes(size_bytes),
8337            path.display()
8338        ));
8339    }
8340
8341    Ok(out.trim_end().to_string())
8342}
8343
8344fn inspect_wsl() -> Result<String, String> {
8345    let mut out = String::from("Host inspection: wsl\n\n");
8346
8347    #[cfg(target_os = "windows")]
8348    {
8349        if let Ok(o) = Command::new("wsl").args(["--version"]).output() {
8350            let raw = String::from_utf8_lossy(&o.stdout);
8351            let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8352            for line in cleaned.lines().take(4) {
8353                let t = line.trim();
8354                if !t.is_empty() {
8355                    out.push_str(&format!("  {t}\n"));
8356                }
8357            }
8358            out.push('\n');
8359        }
8360
8361        let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8362        match list_output {
8363            Err(e) => {
8364                out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
8365                out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8366            }
8367            Ok(o) if !o.status.success() => {
8368                let stderr = String::from_utf8_lossy(&o.stderr);
8369                let cleaned: String = stderr.chars().filter(|c| *c != '\0').collect();
8370                out.push_str(&format!("WSL: error — {}\n", cleaned.trim()));
8371                out.push_str("Run: wsl --install\n");
8372            }
8373            Ok(o) => {
8374                let raw = String::from_utf8_lossy(&o.stdout);
8375                let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8376                let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
8377                let distro_lines: Vec<&str> = lines
8378                    .iter()
8379                    .filter(|l| {
8380                        let t = l.trim();
8381                        !t.is_empty()
8382                            && !t.to_uppercase().starts_with("NAME")
8383                            && !t.starts_with("---")
8384                    })
8385                    .copied()
8386                    .collect();
8387
8388                if distro_lines.is_empty() {
8389                    out.push_str("WSL: installed but no distributions found.\n");
8390                    out.push_str("Install a distro: wsl --install -d Ubuntu\n");
8391                } else {
8392                    out.push_str("=== WSL Distributions ===\n");
8393                    for line in &lines {
8394                        out.push_str(&format!("  {}\n", line.trim()));
8395                    }
8396                    out.push_str(&format!("\nTotal distributions: {}\n", distro_lines.len()));
8397                }
8398            }
8399        }
8400
8401        if let Ok(o) = Command::new("wsl").args(["--status"]).output() {
8402            let raw = String::from_utf8_lossy(&o.stdout);
8403            let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8404            let status_lines: Vec<&str> = cleaned
8405                .lines()
8406                .filter(|l| !l.trim().is_empty())
8407                .take(8)
8408                .collect();
8409            if !status_lines.is_empty() {
8410                out.push_str("\n=== WSL status ===\n");
8411                for line in status_lines {
8412                    out.push_str(&format!("  {}\n", line.trim()));
8413                }
8414            }
8415        }
8416    }
8417
8418    #[cfg(not(target_os = "windows"))]
8419    {
8420        out.push_str("WSL (Windows Subsystem for Linux) is a Windows-only feature.\n");
8421        out.push_str("On Linux/macOS, use native virtualization (KVM, UTM, Parallels) instead.\n");
8422    }
8423
8424    Ok(out.trim_end().to_string())
8425}
8426
8427// ── ssh ───────────────────────────────────────────────────────────────────────
8428
8429fn inspect_wsl_filesystems(max_entries: usize) -> Result<String, String> {
8430    let mut out = String::from("Host inspection: wsl_filesystems\n\n");
8431
8432    #[cfg(target_os = "windows")]
8433    {
8434        let n = max_entries.clamp(3, 12);
8435        let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8436        let distros = match list_output {
8437            Err(e) => {
8438                out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
8439                out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8440                return Ok(out.trim_end().to_string());
8441            }
8442            Ok(o) if !o.status.success() => {
8443                let cleaned = clean_wsl_text(&o.stderr);
8444                out.push_str(&format!("WSL: error - {}\n", cleaned.trim()));
8445                out.push_str("Run: wsl --install\n");
8446                return Ok(out.trim_end().to_string());
8447            }
8448            Ok(o) => parse_wsl_distros(&clean_wsl_text(&o.stdout)),
8449        };
8450
8451        out.push_str(&format!("Distributions detected: {}\n\n", distros.len()));
8452
8453        let vhdx_files = collect_wsl_vhdx_files();
8454        let mut findings = Vec::new();
8455        let mut live_usage = Vec::new();
8456
8457        for distro in distros.iter().take(n) {
8458            if distro.state.eq_ignore_ascii_case("Running") {
8459                if let Some(usage) = wsl_root_usage(&distro.name) {
8460                    if let Some(false) = usage.mnt_c_present {
8461                        findings.push(AuditFinding {
8462                            finding: format!(
8463                                "Distro '{}' is running without /mnt/c available",
8464                                distro.name
8465                            ),
8466                            impact: "Windows to WSL path bridging is broken, so projects under C:\\ may not be reachable from Linux tools.".to_string(),
8467                            fix: "Check /etc/wsl.conf automount settings, restart WSL with `wsl --shutdown`, then confirm drvfs automount is enabled.".to_string(),
8468                        });
8469                    }
8470
8471                    let percent_num = usage
8472                        .use_percent
8473                        .trim_end_matches('%')
8474                        .parse::<u32>()
8475                        .unwrap_or(0);
8476                    if percent_num >= 85 {
8477                        findings.push(AuditFinding {
8478                            finding: format!(
8479                                "Distro '{}' root filesystem is {} full",
8480                                distro.name, usage.use_percent
8481                            ),
8482                            impact: "Package installs, git checkouts, and build caches inside WSL can start failing even when Windows still has free space.".to_string(),
8483                            fix: "Free space inside the distro first, then shut WSL down and compact the VHDX if the host-side file stays large.".to_string(),
8484                        });
8485                    }
8486                    live_usage.push((distro.name.clone(), usage));
8487                }
8488            }
8489        }
8490
8491        for (path, size_bytes) in vhdx_files.iter().take(n) {
8492            if *size_bytes >= 20 * 1024 * 1024 * 1024 {
8493                findings.push(AuditFinding {
8494                    finding: format!(
8495                        "Host-side WSL disk image is large: {} at {}",
8496                        human_bytes(*size_bytes),
8497                        path.display()
8498                    ),
8499                    impact: "Sparse VHDX files can keep consuming Windows disk after files are deleted inside the distro.".to_string(),
8500                    fix: "Clean files inside the distro first, then shut WSL down and compact the VHDX with your normal maintenance workflow.".to_string(),
8501                });
8502            }
8503        }
8504
8505        out.push_str("=== Findings ===\n");
8506        if findings.is_empty() {
8507            out.push_str("- Finding: No oversized WSL disk images or broken /mnt/c bridge mounts were detected in the sampled distros.\n");
8508            out.push_str("  Impact: WSL storage and Windows path bridging look healthy from this inspection pass.\n");
8509            out.push_str("  Fix: If a specific project path still fails, inspect the per-distro bridge and disk details below.\n");
8510        } else {
8511            for finding in &findings {
8512                out.push_str(&format!("- Finding: {}\n", finding.finding));
8513                out.push_str(&format!("  Impact: {}\n", finding.impact));
8514                out.push_str(&format!("  Fix: {}\n", finding.fix));
8515            }
8516        }
8517
8518        out.push_str("\n=== Distro bridge and root usage ===\n");
8519        if distros.is_empty() {
8520            out.push_str("- No WSL distributions found.\n");
8521        } else {
8522            for distro in distros.iter().take(n) {
8523                out.push_str(&format!(
8524                    "- {} [state: {}, version: {}]\n",
8525                    distro.name, distro.state, distro.version
8526                ));
8527                if let Some((_, usage)) = live_usage.iter().find(|(name, _)| name == &distro.name) {
8528                    out.push_str(&format!(
8529                        "  - rootfs: {} used / {} total ({}), free: {}\n",
8530                        human_bytes(usage.used_kb * 1024),
8531                        human_bytes(usage.total_kb * 1024),
8532                        usage.use_percent,
8533                        human_bytes(usage.avail_kb * 1024)
8534                    ));
8535                    match usage.mnt_c_present {
8536                        Some(true) => out.push_str("  - /mnt/c bridge: present\n"),
8537                        Some(false) => out.push_str("  - /mnt/c bridge: missing\n"),
8538                        None => out.push_str("  - /mnt/c bridge: unknown\n"),
8539                    }
8540                } else if distro.state.eq_ignore_ascii_case("Running") {
8541                    out.push_str("  - live rootfs check: unavailable\n");
8542                } else {
8543                    out.push_str(
8544                        "  - live rootfs check: skipped to avoid starting a stopped distro\n",
8545                    );
8546                }
8547            }
8548        }
8549
8550        out.push_str("\n=== Host-side VHDX files ===\n");
8551        if vhdx_files.is_empty() {
8552            out.push_str("- No ext4.vhdx files found under %LOCALAPPDATA%\\Packages. Imported distros may live elsewhere.\n");
8553        } else {
8554            for (path, size_bytes) in vhdx_files.iter().take(n) {
8555                out.push_str(&format!(
8556                    "- {} at {}\n",
8557                    human_bytes(*size_bytes),
8558                    path.display()
8559                ));
8560            }
8561        }
8562    }
8563
8564    #[cfg(not(target_os = "windows"))]
8565    {
8566        let _ = max_entries;
8567        out.push_str("WSL filesystem auditing is a Windows-only inspection.\n");
8568        out.push_str("On Linux/macOS, use native VM/container storage inspection instead.\n");
8569    }
8570
8571    Ok(out.trim_end().to_string())
8572}
8573
8574fn dirs_home() -> Option<PathBuf> {
8575    std::env::var("HOME")
8576        .ok()
8577        .map(PathBuf::from)
8578        .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
8579}
8580
8581fn inspect_ssh() -> Result<String, String> {
8582    let mut out = String::from("Host inspection: ssh\n\n");
8583
8584    if let Ok(o) = Command::new("ssh").args(["-V"]).output() {
8585        let ver = if o.stdout.is_empty() {
8586            String::from_utf8_lossy(&o.stderr).trim().to_string()
8587        } else {
8588            String::from_utf8_lossy(&o.stdout).trim().to_string()
8589        };
8590        if !ver.is_empty() {
8591            out.push_str(&format!("SSH client: {ver}\n"));
8592        }
8593    } else {
8594        out.push_str("SSH client: not found on PATH.\n");
8595    }
8596
8597    #[cfg(target_os = "windows")]
8598    {
8599        let script = r#"
8600$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue
8601if ($svc) { "SSHD:" + $svc.Status + " | StartType:" + $svc.StartType }
8602else { "SSHD:not_installed" }
8603"#;
8604        if let Ok(o) = Command::new("powershell")
8605            .args(["-NoProfile", "-Command", script])
8606            .output()
8607        {
8608            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8609            if text.contains("not_installed") {
8610                out.push_str("SSH server (sshd): not installed\n");
8611            } else {
8612                out.push_str(&format!(
8613                    "SSH server (sshd): {}\n",
8614                    text.trim_start_matches("SSHD:")
8615                ));
8616            }
8617        }
8618    }
8619
8620    #[cfg(not(target_os = "windows"))]
8621    {
8622        if let Ok(o) = Command::new("systemctl")
8623            .args(["is-active", "sshd"])
8624            .output()
8625        {
8626            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8627            out.push_str(&format!("SSH server (sshd): {status}\n"));
8628        } else if let Ok(o) = Command::new("systemctl")
8629            .args(["is-active", "ssh"])
8630            .output()
8631        {
8632            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8633            out.push_str(&format!("SSH server (ssh): {status}\n"));
8634        }
8635    }
8636
8637    out.push('\n');
8638
8639    if let Some(ssh_dir) = dirs_home().map(|h| h.join(".ssh")) {
8640        if ssh_dir.exists() {
8641            out.push_str(&format!("~/.ssh: {}\n", ssh_dir.display()));
8642
8643            let kh = ssh_dir.join("known_hosts");
8644            if kh.exists() {
8645                let count = fs::read_to_string(&kh)
8646                    .map(|c| {
8647                        c.lines()
8648                            .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8649                            .count()
8650                    })
8651                    .unwrap_or(0);
8652                out.push_str(&format!("  known_hosts: {count} entries\n"));
8653            } else {
8654                out.push_str("  known_hosts: not present\n");
8655            }
8656
8657            let ak = ssh_dir.join("authorized_keys");
8658            if ak.exists() {
8659                let count = fs::read_to_string(&ak)
8660                    .map(|c| {
8661                        c.lines()
8662                            .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8663                            .count()
8664                    })
8665                    .unwrap_or(0);
8666                out.push_str(&format!("  authorized_keys: {count} public keys\n"));
8667            } else {
8668                out.push_str("  authorized_keys: not present\n");
8669            }
8670
8671            let key_names = [
8672                "id_rsa",
8673                "id_ed25519",
8674                "id_ecdsa",
8675                "id_dsa",
8676                "id_ecdsa_sk",
8677                "id_ed25519_sk",
8678            ];
8679            let found_keys: Vec<&str> = key_names
8680                .iter()
8681                .filter(|k| ssh_dir.join(k).exists())
8682                .copied()
8683                .collect();
8684            if !found_keys.is_empty() {
8685                out.push_str(&format!("  Private keys: {}\n", found_keys.join(", ")));
8686            } else {
8687                out.push_str("  Private keys: none found\n");
8688            }
8689
8690            let config_path = ssh_dir.join("config");
8691            if config_path.exists() {
8692                out.push_str("\n=== SSH config hosts ===\n");
8693                match fs::read_to_string(&config_path) {
8694                    Ok(content) => {
8695                        let mut hosts: Vec<(String, Vec<String>)> = Vec::new();
8696                        let mut current: Option<(String, Vec<String>)> = None;
8697                        for line in content.lines() {
8698                            let t = line.trim();
8699                            if t.is_empty() || t.starts_with('#') {
8700                                continue;
8701                            }
8702                            if let Some(host) = t.strip_prefix("Host ") {
8703                                if let Some(prev) = current.take() {
8704                                    hosts.push(prev);
8705                                }
8706                                current = Some((host.trim().to_string(), Vec::new()));
8707                            } else if let Some((_, ref mut details)) = current {
8708                                let tu = t.to_uppercase();
8709                                if tu.starts_with("HOSTNAME ")
8710                                    || tu.starts_with("USER ")
8711                                    || tu.starts_with("PORT ")
8712                                    || tu.starts_with("IDENTITYFILE ")
8713                                {
8714                                    details.push(t.to_string());
8715                                }
8716                            }
8717                        }
8718                        if let Some(prev) = current {
8719                            hosts.push(prev);
8720                        }
8721
8722                        if hosts.is_empty() {
8723                            out.push_str("  No Host entries found.\n");
8724                        } else {
8725                            for (h, details) in &hosts {
8726                                if details.is_empty() {
8727                                    out.push_str(&format!("  Host {h}\n"));
8728                                } else {
8729                                    out.push_str(&format!(
8730                                        "  Host {h}  [{}]\n",
8731                                        details.join(", ")
8732                                    ));
8733                                }
8734                            }
8735                            out.push_str(&format!("\n  Total configured hosts: {}\n", hosts.len()));
8736                        }
8737                    }
8738                    Err(e) => out.push_str(&format!("  Could not read config: {e}\n")),
8739                }
8740            } else {
8741                out.push_str("  SSH config: not present\n");
8742            }
8743        } else {
8744            out.push_str("~/.ssh: directory not found (no SSH keys configured).\n");
8745        }
8746    }
8747
8748    Ok(out.trim_end().to_string())
8749}
8750
8751// ── installed_software ────────────────────────────────────────────────────────
8752
8753fn inspect_installed_software(max_entries: usize) -> Result<String, String> {
8754    let mut out = String::from("Host inspection: installed_software\n\n");
8755    let n = max_entries.clamp(10, 50);
8756
8757    #[cfg(target_os = "windows")]
8758    {
8759        let winget_out = Command::new("winget")
8760            .args(["list", "--accept-source-agreements"])
8761            .output();
8762
8763        if let Ok(o) = winget_out {
8764            if o.status.success() {
8765                let raw = String::from_utf8_lossy(&o.stdout);
8766                let mut header_done = false;
8767                let mut packages: Vec<&str> = Vec::new();
8768                for line in raw.lines() {
8769                    let t = line.trim();
8770                    if t.starts_with("---") {
8771                        header_done = true;
8772                        continue;
8773                    }
8774                    if header_done && !t.is_empty() {
8775                        packages.push(line);
8776                    }
8777                }
8778                let total = packages.len();
8779                out.push_str(&format!(
8780                    "=== Installed software via winget ({total} packages) ===\n\n"
8781                ));
8782                for line in packages.iter().take(n) {
8783                    out.push_str(&format!("  {line}\n"));
8784                }
8785                if total > n {
8786                    out.push_str(&format!("\n  ... and {} more packages\n", total - n));
8787                }
8788                out.push_str("\nFor full list: winget list\n");
8789                return Ok(out.trim_end().to_string());
8790            }
8791        }
8792
8793        // Fallback: registry scan
8794        let script = format!(
8795            r#"
8796$apps = @()
8797$reg_paths = @(
8798    'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
8799    'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
8800    'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
8801)
8802foreach ($p in $reg_paths) {{
8803    try {{
8804        $apps += Get-ItemProperty $p -ErrorAction SilentlyContinue |
8805            Where-Object {{ $_.DisplayName }} |
8806            Select-Object DisplayName, DisplayVersion, Publisher
8807    }} catch {{}}
8808}}
8809$sorted = $apps | Sort-Object DisplayName -Unique
8810"TOTAL:" + $sorted.Count
8811$sorted | Select-Object -First {n} | ForEach-Object {{
8812    $_.DisplayName + "|" + $_.DisplayVersion + "|" + $_.Publisher
8813}}
8814"#
8815        );
8816        if let Ok(o) = Command::new("powershell")
8817            .args(["-NoProfile", "-Command", &script])
8818            .output()
8819        {
8820            let raw = String::from_utf8_lossy(&o.stdout);
8821            out.push_str("=== Installed software (registry scan) ===\n");
8822            out.push_str(&format!("  {:<50} {:<18} Publisher\n", "Name", "Version"));
8823            out.push_str(&format!("  {}\n", "-".repeat(90)));
8824            for line in raw.lines() {
8825                if let Some(rest) = line.strip_prefix("TOTAL:") {
8826                    let total: usize = rest.trim().parse().unwrap_or(0);
8827                    out.push_str(&format!("  (Total: {total}, showing first {n})\n\n"));
8828                } else if !line.trim().is_empty() {
8829                    let parts: Vec<&str> = line.splitn(3, '|').collect();
8830                    let name = parts.first().map(|s| s.trim()).unwrap_or("");
8831                    let ver = parts.get(1).map(|s| s.trim()).unwrap_or("");
8832                    let pub_ = parts.get(2).map(|s| s.trim()).unwrap_or("");
8833                    out.push_str(&format!("  {:<50} {:<18} {pub_}\n", name, ver));
8834                }
8835            }
8836        } else {
8837            out.push_str(
8838                "Could not query installed software (winget and registry scan both failed).\n",
8839            );
8840        }
8841    }
8842
8843    #[cfg(target_os = "linux")]
8844    {
8845        let mut found = false;
8846        if let Ok(o) = Command::new("dpkg").args(["--get-selections"]).output() {
8847            if o.status.success() {
8848                let raw = String::from_utf8_lossy(&o.stdout);
8849                let installed: Vec<&str> = raw.lines().filter(|l| l.contains("install")).collect();
8850                let total = installed.len();
8851                out.push_str(&format!("=== Installed packages via dpkg ({total}) ===\n"));
8852                for line in installed.iter().take(n) {
8853                    out.push_str(&format!("  {}\n", line.trim()));
8854                }
8855                if total > n {
8856                    out.push_str(&format!("  ... and {} more\n", total - n));
8857                }
8858                out.push_str("\nFor full list: dpkg --get-selections | grep install\n");
8859                found = true;
8860            }
8861        }
8862        if !found {
8863            if let Ok(o) = Command::new("rpm")
8864                .args(["-qa", "--queryformat", "%{NAME} %{VERSION}\n"])
8865                .output()
8866            {
8867                if o.status.success() {
8868                    let raw = String::from_utf8_lossy(&o.stdout);
8869                    let lines: Vec<&str> = raw.lines().collect();
8870                    let total = lines.len();
8871                    out.push_str(&format!("=== Installed packages via rpm ({total}) ===\n"));
8872                    for line in lines.iter().take(n) {
8873                        out.push_str(&format!("  {line}\n"));
8874                    }
8875                    if total > n {
8876                        out.push_str(&format!("  ... and {} more\n", total - n));
8877                    }
8878                    found = true;
8879                }
8880            }
8881        }
8882        if !found {
8883            if let Ok(o) = Command::new("pacman").args(["-Q"]).output() {
8884                if o.status.success() {
8885                    let raw = String::from_utf8_lossy(&o.stdout);
8886                    let lines: Vec<&str> = raw.lines().collect();
8887                    let total = lines.len();
8888                    out.push_str(&format!(
8889                        "=== Installed packages via pacman ({total}) ===\n"
8890                    ));
8891                    for line in lines.iter().take(n) {
8892                        out.push_str(&format!("  {line}\n"));
8893                    }
8894                    if total > n {
8895                        out.push_str(&format!("  ... and {} more\n", total - n));
8896                    }
8897                    found = true;
8898                }
8899            }
8900        }
8901        if !found {
8902            out.push_str("No package manager found (tried dpkg, rpm, pacman).\n");
8903        }
8904    }
8905
8906    #[cfg(target_os = "macos")]
8907    {
8908        if let Ok(o) = Command::new("brew").args(["list", "--versions"]).output() {
8909            if o.status.success() {
8910                let raw = String::from_utf8_lossy(&o.stdout);
8911                let lines: Vec<&str> = raw.lines().collect();
8912                let total = lines.len();
8913                out.push_str(&format!("=== Homebrew packages ({total}) ===\n"));
8914                for line in lines.iter().take(n) {
8915                    out.push_str(&format!("  {line}\n"));
8916                }
8917                if total > n {
8918                    out.push_str(&format!("  ... and {} more\n", total - n));
8919                }
8920                out.push_str("\nFor full list: brew list --versions\n");
8921            }
8922        } else {
8923            out.push_str("Homebrew not found.\n");
8924        }
8925        if let Ok(o) = Command::new("mas").args(["list"]).output() {
8926            if o.status.success() {
8927                let raw = String::from_utf8_lossy(&o.stdout);
8928                let lines: Vec<&str> = raw.lines().collect();
8929                out.push_str(&format!("\n=== Mac App Store apps ({}) ===\n", lines.len()));
8930                for line in lines.iter().take(n) {
8931                    out.push_str(&format!("  {line}\n"));
8932                }
8933            }
8934        }
8935    }
8936
8937    Ok(out.trim_end().to_string())
8938}
8939
8940// ── git_config ────────────────────────────────────────────────────────────────
8941
8942fn inspect_git_config() -> Result<String, String> {
8943    let mut out = String::from("Host inspection: git_config\n\n");
8944
8945    if let Ok(o) = Command::new("git").args(["--version"]).output() {
8946        let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
8947        out.push_str(&format!("Git: {ver}\n\n"));
8948    } else {
8949        out.push_str("Git: not found on PATH.\n");
8950        return Ok(out.trim_end().to_string());
8951    }
8952
8953    if let Ok(o) = Command::new("git")
8954        .args(["config", "--global", "--list"])
8955        .output()
8956    {
8957        if o.status.success() {
8958            let raw = String::from_utf8_lossy(&o.stdout);
8959            let mut pairs: Vec<(String, String)> = raw
8960                .lines()
8961                .filter_map(|l| {
8962                    let mut parts = l.splitn(2, '=');
8963                    let k = parts.next()?.trim().to_string();
8964                    let v = parts.next().unwrap_or("").trim().to_string();
8965                    Some((k, v))
8966                })
8967                .collect();
8968            pairs.sort_by(|a, b| a.0.cmp(&b.0));
8969
8970            out.push_str("=== Global git config ===\n");
8971
8972            let sections: &[(&str, &[&str])] = &[
8973                ("Identity", &["user.name", "user.email", "user.signingkey"]),
8974                (
8975                    "Core",
8976                    &[
8977                        "core.editor",
8978                        "core.autocrlf",
8979                        "core.eol",
8980                        "core.ignorecase",
8981                        "core.filemode",
8982                    ],
8983                ),
8984                (
8985                    "Commit/Signing",
8986                    &[
8987                        "commit.gpgsign",
8988                        "tag.gpgsign",
8989                        "gpg.format",
8990                        "gpg.ssh.allowedsignersfile",
8991                    ],
8992                ),
8993                (
8994                    "Push/Pull",
8995                    &[
8996                        "push.default",
8997                        "push.autosetupremote",
8998                        "pull.rebase",
8999                        "pull.ff",
9000                    ],
9001                ),
9002                ("Credential", &["credential.helper"]),
9003                ("Branch", &["init.defaultbranch", "branch.autosetuprebase"]),
9004            ];
9005
9006            let mut shown_keys: HashSet<String> = HashSet::new();
9007            for (section, keys) in sections {
9008                let mut section_lines: Vec<String> = Vec::new();
9009                for key in *keys {
9010                    if let Some((k, v)) = pairs.iter().find(|(kk, _)| kk == key) {
9011                        section_lines.push(format!("  {k} = {v}"));
9012                        shown_keys.insert(k.clone());
9013                    }
9014                }
9015                if !section_lines.is_empty() {
9016                    out.push_str(&format!("\n[{section}]\n"));
9017                    for line in section_lines {
9018                        out.push_str(&format!("{line}\n"));
9019                    }
9020                }
9021            }
9022
9023            let other: Vec<&(String, String)> = pairs
9024                .iter()
9025                .filter(|(k, _)| !shown_keys.contains(k) && !k.starts_with("alias."))
9026                .collect();
9027            if !other.is_empty() {
9028                out.push_str("\n[Other]\n");
9029                for (k, v) in other.iter().take(20) {
9030                    out.push_str(&format!("  {k} = {v}\n"));
9031                }
9032                if other.len() > 20 {
9033                    out.push_str(&format!("  ... and {} more\n", other.len() - 20));
9034                }
9035            }
9036
9037            out.push_str(&format!("\nTotal global config keys: {}\n", pairs.len()));
9038        } else {
9039            out.push_str("No global git config found.\n");
9040            out.push_str("Set up with:\n");
9041            out.push_str("  git config --global user.name \"Your Name\"\n");
9042            out.push_str("  git config --global user.email \"you@example.com\"\n");
9043        }
9044    }
9045
9046    if let Ok(o) = Command::new("git")
9047        .args(["config", "--local", "--list"])
9048        .output()
9049    {
9050        if o.status.success() {
9051            let raw = String::from_utf8_lossy(&o.stdout);
9052            let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
9053            if !lines.is_empty() {
9054                out.push_str(&format!(
9055                    "\n=== Local repo config ({} keys) ===\n",
9056                    lines.len()
9057                ));
9058                for line in lines.iter().take(15) {
9059                    out.push_str(&format!("  {line}\n"));
9060                }
9061                if lines.len() > 15 {
9062                    out.push_str(&format!("  ... and {} more\n", lines.len() - 15));
9063                }
9064            }
9065        }
9066    }
9067
9068    if let Ok(o) = Command::new("git")
9069        .args(["config", "--global", "--get-regexp", r"alias\."])
9070        .output()
9071    {
9072        if o.status.success() {
9073            let raw = String::from_utf8_lossy(&o.stdout);
9074            let aliases: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
9075            if !aliases.is_empty() {
9076                out.push_str(&format!("\n=== Git aliases ({}) ===\n", aliases.len()));
9077                for a in aliases.iter().take(20) {
9078                    out.push_str(&format!("  {a}\n"));
9079                }
9080                if aliases.len() > 20 {
9081                    out.push_str(&format!("  ... and {} more\n", aliases.len() - 20));
9082                }
9083            }
9084        }
9085    }
9086
9087    Ok(out.trim_end().to_string())
9088}
9089
9090// ── databases ─────────────────────────────────────────────────────────────────
9091
9092fn inspect_databases() -> Result<String, String> {
9093    let mut out = String::from("Host inspection: databases\n\n");
9094    out.push_str("Scanning for local database engines (service state, port, version)...\n\n");
9095
9096    struct DbEngine {
9097        name: &'static str,
9098        service_names: &'static [&'static str],
9099        default_port: u16,
9100        cli_name: &'static str,
9101        cli_version_args: &'static [&'static str],
9102    }
9103
9104    let engines: &[DbEngine] = &[
9105        DbEngine {
9106            name: "PostgreSQL",
9107            service_names: &[
9108                "postgresql",
9109                "postgresql-x64-14",
9110                "postgresql-x64-15",
9111                "postgresql-x64-16",
9112                "postgresql-x64-17",
9113            ],
9114
9115            default_port: 5432,
9116            cli_name: "psql",
9117            cli_version_args: &["--version"],
9118        },
9119        DbEngine {
9120            name: "MySQL",
9121            service_names: &["mysql", "mysql80", "mysql57"],
9122
9123            default_port: 3306,
9124            cli_name: "mysql",
9125            cli_version_args: &["--version"],
9126        },
9127        DbEngine {
9128            name: "MariaDB",
9129            service_names: &["mariadb", "mariadb.exe"],
9130
9131            default_port: 3306,
9132            cli_name: "mariadb",
9133            cli_version_args: &["--version"],
9134        },
9135        DbEngine {
9136            name: "MongoDB",
9137            service_names: &["mongodb", "mongod"],
9138
9139            default_port: 27017,
9140            cli_name: "mongod",
9141            cli_version_args: &["--version"],
9142        },
9143        DbEngine {
9144            name: "Redis",
9145            service_names: &["redis", "redis-server"],
9146
9147            default_port: 6379,
9148            cli_name: "redis-server",
9149            cli_version_args: &["--version"],
9150        },
9151        DbEngine {
9152            name: "SQL Server",
9153            service_names: &["mssqlserver", "mssql$sqlexpress", "mssql$localdb"],
9154
9155            default_port: 1433,
9156            cli_name: "sqlcmd",
9157            cli_version_args: &["-?"],
9158        },
9159        DbEngine {
9160            name: "SQLite",
9161            service_names: &[], // no service — file-based
9162
9163            default_port: 0, // no port — file-based
9164            cli_name: "sqlite3",
9165            cli_version_args: &["--version"],
9166        },
9167        DbEngine {
9168            name: "CouchDB",
9169            service_names: &["couchdb", "apache-couchdb"],
9170
9171            default_port: 5984,
9172            cli_name: "couchdb",
9173            cli_version_args: &["--version"],
9174        },
9175        DbEngine {
9176            name: "Cassandra",
9177            service_names: &["cassandra"],
9178
9179            default_port: 9042,
9180            cli_name: "cqlsh",
9181            cli_version_args: &["--version"],
9182        },
9183        DbEngine {
9184            name: "Elasticsearch",
9185            service_names: &["elasticsearch-service-x64", "elasticsearch"],
9186
9187            default_port: 9200,
9188            cli_name: "elasticsearch",
9189            cli_version_args: &["--version"],
9190        },
9191    ];
9192
9193    // Helper: check if port is listening
9194    fn port_listening(port: u16) -> bool {
9195        if port == 0 {
9196            return false;
9197        }
9198        // Use netstat-style check via connecting
9199        std::net::TcpStream::connect_timeout(
9200            &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
9201            std::time::Duration::from_millis(150),
9202        )
9203        .is_ok()
9204    }
9205
9206    let mut found_any = false;
9207
9208    for engine in engines {
9209        let mut status_parts: Vec<String> = Vec::new();
9210        let mut detected = false;
9211
9212        // 1. CLI version check (fastest — works cross-platform)
9213        let version = Command::new(engine.cli_name)
9214            .args(engine.cli_version_args)
9215            .output()
9216            .ok()
9217            .and_then(|o| {
9218                let combined = if o.stdout.is_empty() {
9219                    String::from_utf8_lossy(&o.stderr).trim().to_string()
9220                } else {
9221                    String::from_utf8_lossy(&o.stdout).trim().to_string()
9222                };
9223                // Take just the first line
9224                combined.lines().next().map(|l| l.trim().to_string())
9225            });
9226
9227        if let Some(ref ver) = version {
9228            if !ver.is_empty() {
9229                status_parts.push(format!("version: {ver}"));
9230                detected = true;
9231            }
9232        }
9233
9234        // 2. Port check
9235        if engine.default_port > 0 && port_listening(engine.default_port) {
9236            status_parts.push(format!("listening on :{}", engine.default_port));
9237            detected = true;
9238        } else if engine.default_port > 0 && detected {
9239            status_parts.push(format!("not listening on :{}", engine.default_port));
9240        }
9241
9242        // 3. Windows service check
9243        #[cfg(target_os = "windows")]
9244        {
9245            if !engine.service_names.is_empty() {
9246                let service_list = engine.service_names.join("','");
9247                let script = format!(
9248                    r#"$names = @('{}'); foreach ($n in $names) {{ $s = Get-Service -Name $n -ErrorAction SilentlyContinue; if ($s) {{ $n + ':' + $s.Status; break }} }}"#,
9249                    service_list
9250                );
9251                if let Ok(o) = Command::new("powershell")
9252                    .args(["-NoProfile", "-Command", &script])
9253                    .output()
9254                {
9255                    let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
9256                    if !text.is_empty() {
9257                        let parts: Vec<&str> = text.splitn(2, ':').collect();
9258                        let svc_name = parts.first().map(|s| s.trim()).unwrap_or("");
9259                        let svc_state = parts.get(1).map(|s| s.trim()).unwrap_or("unknown");
9260                        status_parts.push(format!("service '{svc_name}': {svc_state}"));
9261                        detected = true;
9262                    }
9263                }
9264            }
9265        }
9266
9267        // 4. Linux/macOS systemctl / launchctl check
9268        #[cfg(not(target_os = "windows"))]
9269        {
9270            for svc in engine.service_names {
9271                if let Ok(o) = Command::new("systemctl").args(["is-active", svc]).output() {
9272                    let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
9273                    if !state.is_empty() && state != "inactive" {
9274                        status_parts.push(format!("systemd '{svc}': {state}"));
9275                        detected = true;
9276                        break;
9277                    }
9278                }
9279            }
9280        }
9281
9282        if detected {
9283            found_any = true;
9284            let label = if engine.default_port > 0 {
9285                format!("{} (default port: {})", engine.name, engine.default_port)
9286            } else {
9287                format!("{} (file-based, no port)", engine.name)
9288            };
9289            out.push_str(&format!("[FOUND] {label}\n"));
9290            for part in &status_parts {
9291                out.push_str(&format!("  {part}\n"));
9292            }
9293            out.push('\n');
9294        }
9295    }
9296
9297    if !found_any {
9298        out.push_str("No local database engines detected.\n");
9299        out.push_str("(Checked: PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQL Server, SQLite, CouchDB, Cassandra, Elasticsearch)\n\n");
9300        out.push_str(
9301            "Note: databases running inside Docker containers are listed under topic='docker'.\n",
9302        );
9303    } else {
9304        out.push_str("---\n");
9305        out.push_str(
9306            "Note: databases running inside Docker containers are listed under topic='docker'.\n",
9307        );
9308        out.push_str("This topic checks service state and port reachability only — no credentials or queries are used.\n");
9309    }
9310
9311    Ok(out.trim_end().to_string())
9312}
9313
9314// ── user_accounts ─────────────────────────────────────────────────────────────
9315
9316fn inspect_user_accounts(max_entries: usize) -> Result<String, String> {
9317    let mut out = String::from("Host inspection: user_accounts\n\n");
9318
9319    #[cfg(target_os = "windows")]
9320    {
9321        let users_out = Command::new("powershell")
9322            .args([
9323                "-NoProfile", "-NonInteractive", "-Command",
9324                "Get-LocalUser | ForEach-Object { $logon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'never' }; \"  $($_.Name) | Enabled: $($_.Enabled) | LastLogon: $logon | PwdRequired: $($_.PasswordRequired) | $($_.Description)\" }",
9325            ])
9326            .output()
9327            .ok()
9328            .and_then(|o| String::from_utf8(o.stdout).ok())
9329            .unwrap_or_default();
9330
9331        out.push_str("=== Local User Accounts ===\n");
9332        if users_out.trim().is_empty() {
9333            out.push_str("  (requires elevation or Get-LocalUser unavailable)\n");
9334        } else {
9335            for line in users_out.lines().take(max_entries) {
9336                if !line.trim().is_empty() {
9337                    out.push_str(line);
9338                    out.push('\n');
9339                }
9340            }
9341        }
9342
9343        let admins_out = Command::new("powershell")
9344            .args([
9345                "-NoProfile", "-NonInteractive", "-Command",
9346                "Get-LocalGroupMember -Group 'Administrators' 2>$null | ForEach-Object { \"  $($_.ObjectClass): $($_.Name)\" }",
9347            ])
9348            .output()
9349            .ok()
9350            .and_then(|o| String::from_utf8(o.stdout).ok())
9351            .unwrap_or_default();
9352
9353        out.push_str("\n=== Administrators Group Members ===\n");
9354        if admins_out.trim().is_empty() {
9355            out.push_str("  (unable to retrieve)\n");
9356        } else {
9357            out.push_str(admins_out.trim());
9358            out.push('\n');
9359        }
9360
9361        let sessions_out = Command::new("powershell")
9362            .args([
9363                "-NoProfile",
9364                "-NonInteractive",
9365                "-Command",
9366                "query user 2>$null",
9367            ])
9368            .output()
9369            .ok()
9370            .and_then(|o| String::from_utf8(o.stdout).ok())
9371            .unwrap_or_default();
9372
9373        out.push_str("\n=== Active Logon Sessions ===\n");
9374        if sessions_out.trim().is_empty() {
9375            out.push_str("  (none or requires elevation)\n");
9376        } else {
9377            for line in sessions_out.lines().take(max_entries) {
9378                if !line.trim().is_empty() {
9379                    out.push_str(&format!("  {}\n", line));
9380                }
9381            }
9382        }
9383
9384        let is_admin = Command::new("powershell")
9385            .args([
9386                "-NoProfile", "-NonInteractive", "-Command",
9387                "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
9388            ])
9389            .output()
9390            .ok()
9391            .and_then(|o| String::from_utf8(o.stdout).ok())
9392            .map(|s| s.trim().to_lowercase())
9393            .unwrap_or_default();
9394
9395        out.push_str("\n=== Current Session Elevation ===\n");
9396        out.push_str(&format!(
9397            "  Running as Administrator: {}\n",
9398            if is_admin.contains("true") {
9399                "YES"
9400            } else {
9401                "no"
9402            }
9403        ));
9404    }
9405
9406    #[cfg(not(target_os = "windows"))]
9407    {
9408        let who_out = Command::new("who")
9409            .output()
9410            .ok()
9411            .and_then(|o| String::from_utf8(o.stdout).ok())
9412            .unwrap_or_default();
9413        out.push_str("=== Active Sessions ===\n");
9414        if who_out.trim().is_empty() {
9415            out.push_str("  (none)\n");
9416        } else {
9417            for line in who_out.lines().take(max_entries) {
9418                out.push_str(&format!("  {}\n", line));
9419            }
9420        }
9421        let id_out = Command::new("id")
9422            .output()
9423            .ok()
9424            .and_then(|o| String::from_utf8(o.stdout).ok())
9425            .unwrap_or_default();
9426        out.push_str(&format!("\n=== Current User ===\n  {}\n", id_out.trim()));
9427    }
9428
9429    Ok(out.trim_end().to_string())
9430}
9431
9432// ── audit_policy ──────────────────────────────────────────────────────────────
9433
9434fn inspect_audit_policy() -> Result<String, String> {
9435    let mut out = String::from("Host inspection: audit_policy\n\n");
9436
9437    #[cfg(target_os = "windows")]
9438    {
9439        let auditpol_out = Command::new("auditpol")
9440            .args(["/get", "/category:*"])
9441            .output()
9442            .ok()
9443            .and_then(|o| String::from_utf8(o.stdout).ok())
9444            .unwrap_or_default();
9445
9446        if auditpol_out.trim().is_empty()
9447            || auditpol_out.to_lowercase().contains("access is denied")
9448        {
9449            out.push_str("Audit policy requires Administrator elevation to read.\n");
9450            out.push_str(
9451                "Run Hematite as Administrator, or check manually: auditpol /get /category:*\n",
9452            );
9453        } else {
9454            out.push_str("=== Windows Audit Policy ===\n");
9455            let mut any_enabled = false;
9456            for line in auditpol_out.lines() {
9457                let trimmed = line.trim();
9458                if trimmed.is_empty() {
9459                    continue;
9460                }
9461                if trimmed.contains("Success") || trimmed.contains("Failure") {
9462                    out.push_str(&format!("  [ENABLED] {}\n", trimmed));
9463                    any_enabled = true;
9464                } else {
9465                    out.push_str(&format!("  {}\n", trimmed));
9466                }
9467            }
9468            if !any_enabled {
9469                out.push_str("\n[WARNING] No audit categories are enabled — security events will not be logged.\n");
9470                out.push_str(
9471                    "Minimum recommended: enable Logon/Logoff and Account Logon success+failure.\n",
9472                );
9473            }
9474        }
9475
9476        let evtlog = Command::new("powershell")
9477            .args([
9478                "-NoProfile", "-NonInteractive", "-Command",
9479                "Get-Service EventLog -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Status",
9480            ])
9481            .output()
9482            .ok()
9483            .and_then(|o| String::from_utf8(o.stdout).ok())
9484            .map(|s| s.trim().to_string())
9485            .unwrap_or_default();
9486
9487        out.push_str(&format!(
9488            "\n=== Windows Event Log Service ===\n  Status: {}\n",
9489            if evtlog.is_empty() {
9490                "unknown".to_string()
9491            } else {
9492                evtlog
9493            }
9494        ));
9495    }
9496
9497    #[cfg(not(target_os = "windows"))]
9498    {
9499        let auditd_status = Command::new("systemctl")
9500            .args(["is-active", "auditd"])
9501            .output()
9502            .ok()
9503            .and_then(|o| String::from_utf8(o.stdout).ok())
9504            .map(|s| s.trim().to_string())
9505            .unwrap_or_else(|| "not found".to_string());
9506
9507        out.push_str(&format!(
9508            "=== auditd service ===\n  Status: {}\n",
9509            auditd_status
9510        ));
9511
9512        if auditd_status == "active" {
9513            let rules = Command::new("auditctl")
9514                .args(["-l"])
9515                .output()
9516                .ok()
9517                .and_then(|o| String::from_utf8(o.stdout).ok())
9518                .unwrap_or_default();
9519            out.push_str("\n=== Active Audit Rules ===\n");
9520            if rules.trim().is_empty() || rules.contains("No rules") {
9521                out.push_str("  No rules configured.\n");
9522            } else {
9523                for line in rules.lines() {
9524                    out.push_str(&format!("  {}\n", line));
9525                }
9526            }
9527        }
9528    }
9529
9530    Ok(out.trim_end().to_string())
9531}
9532
9533// ── shares ────────────────────────────────────────────────────────────────────
9534
9535fn inspect_shares(max_entries: usize) -> Result<String, String> {
9536    let mut out = String::from("Host inspection: shares\n\n");
9537
9538    #[cfg(target_os = "windows")]
9539    {
9540        let smb_out = Command::new("powershell")
9541            .args([
9542                "-NoProfile", "-NonInteractive", "-Command",
9543                "Get-SmbShare | ForEach-Object { \"  $($_.Name) | Path: $($_.Path) | State: $($_.ShareState) | Encrypted: $($_.EncryptData) | $($_.Description)\" }",
9544            ])
9545            .output()
9546            .ok()
9547            .and_then(|o| String::from_utf8(o.stdout).ok())
9548            .unwrap_or_default();
9549
9550        out.push_str("=== SMB Shares (exposed by this machine) ===\n");
9551        let smb_lines: Vec<&str> = smb_out
9552            .lines()
9553            .filter(|l| !l.trim().is_empty())
9554            .take(max_entries)
9555            .collect();
9556        if smb_lines.is_empty() {
9557            out.push_str("  No SMB shares or unable to retrieve.\n");
9558        } else {
9559            for line in &smb_lines {
9560                let name = line.trim().split('|').next().unwrap_or("").trim();
9561                if name.ends_with('$') {
9562                    out.push_str(&format!("  {}\n", line.trim()));
9563                } else {
9564                    out.push_str(&format!("  [CUSTOM] {}\n", line.trim()));
9565                }
9566            }
9567        }
9568
9569        let smb_security = Command::new("powershell")
9570            .args([
9571                "-NoProfile", "-NonInteractive", "-Command",
9572                "Get-SmbServerConfiguration | ForEach-Object { \"  SMB1: $($_.EnableSMB1Protocol) | SMB2: $($_.EnableSMB2Protocol) | Signing Required: $($_.RequireSecuritySignature) | Encryption: $($_.EncryptData)\" }",
9573            ])
9574            .output()
9575            .ok()
9576            .and_then(|o| String::from_utf8(o.stdout).ok())
9577            .unwrap_or_default();
9578
9579        out.push_str("\n=== SMB Server Security Settings ===\n");
9580        if smb_security.trim().is_empty() {
9581            out.push_str("  (unable to retrieve)\n");
9582        } else {
9583            out.push_str(smb_security.trim());
9584            out.push('\n');
9585            if smb_security.to_lowercase().contains("smb1: true") {
9586                out.push_str("  [WARNING] SMB1 is ENABLED — disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\n");
9587            }
9588        }
9589
9590        let drives_out = Command::new("powershell")
9591            .args([
9592                "-NoProfile", "-NonInteractive", "-Command",
9593                "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { \"  $($_.Name): -> $($_.DisplayRoot)\" }",
9594            ])
9595            .output()
9596            .ok()
9597            .and_then(|o| String::from_utf8(o.stdout).ok())
9598            .unwrap_or_default();
9599
9600        out.push_str("\n=== Mapped Network Drives ===\n");
9601        if drives_out.trim().is_empty() {
9602            out.push_str("  None.\n");
9603        } else {
9604            for line in drives_out.lines().take(max_entries) {
9605                if !line.trim().is_empty() {
9606                    out.push_str(line);
9607                    out.push('\n');
9608                }
9609            }
9610        }
9611    }
9612
9613    #[cfg(not(target_os = "windows"))]
9614    {
9615        let smb_conf = std::fs::read_to_string("/etc/samba/smb.conf").unwrap_or_default();
9616        out.push_str("=== Samba Config (/etc/samba/smb.conf) ===\n");
9617        if smb_conf.is_empty() {
9618            out.push_str("  Not found or Samba not installed.\n");
9619        } else {
9620            for line in smb_conf.lines().take(max_entries) {
9621                out.push_str(&format!("  {}\n", line));
9622            }
9623        }
9624        let nfs_exports = std::fs::read_to_string("/etc/exports").unwrap_or_default();
9625        out.push_str("\n=== NFS Exports (/etc/exports) ===\n");
9626        if nfs_exports.is_empty() {
9627            out.push_str("  Not configured.\n");
9628        } else {
9629            for line in nfs_exports.lines().take(max_entries) {
9630                out.push_str(&format!("  {}\n", line));
9631            }
9632        }
9633    }
9634
9635    Ok(out.trim_end().to_string())
9636}
9637
9638// ── dns_servers ───────────────────────────────────────────────────────────────
9639
9640fn inspect_dns_servers() -> Result<String, String> {
9641    let mut out = String::from("Host inspection: dns_servers\n\n");
9642
9643    #[cfg(target_os = "windows")]
9644    {
9645        let dns_out = Command::new("powershell")
9646            .args([
9647                "-NoProfile", "-NonInteractive", "-Command",
9648                "Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses.Count -gt 0 } | ForEach-Object { $addrs = $_.ServerAddresses -join ', '; \"  $($_.InterfaceAlias) (AF $($_.AddressFamily)): $addrs\" }",
9649            ])
9650            .output()
9651            .ok()
9652            .and_then(|o| String::from_utf8(o.stdout).ok())
9653            .unwrap_or_default();
9654
9655        out.push_str("=== Configured DNS Resolvers (per adapter) ===\n");
9656        if dns_out.trim().is_empty() {
9657            out.push_str("  (unable to retrieve)\n");
9658        } else {
9659            for line in dns_out.lines() {
9660                if line.trim().is_empty() {
9661                    continue;
9662                }
9663                let mut annotation = "";
9664                if line.contains("8.8.8.8") || line.contains("8.8.4.4") {
9665                    annotation = "  <- Google Public DNS";
9666                } else if line.contains("1.1.1.1") || line.contains("1.0.0.1") {
9667                    annotation = "  <- Cloudflare DNS";
9668                } else if line.contains("9.9.9.9") {
9669                    annotation = "  <- Quad9";
9670                } else if line.contains("208.67.222") || line.contains("208.67.220") {
9671                    annotation = "  <- OpenDNS";
9672                }
9673                out.push_str(line);
9674                out.push_str(annotation);
9675                out.push('\n');
9676            }
9677        }
9678
9679        let doh_out = Command::new("powershell")
9680            .args([
9681                "-NoProfile", "-NonInteractive", "-Command",
9682                "Get-DnsClientDohServerAddress 2>$null | ForEach-Object { \"  $($_.ServerAddress): $($_.DohTemplate)\" }",
9683            ])
9684            .output()
9685            .ok()
9686            .and_then(|o| String::from_utf8(o.stdout).ok())
9687            .unwrap_or_default();
9688
9689        out.push_str("\n=== DNS over HTTPS (DoH) ===\n");
9690        if doh_out.trim().is_empty() {
9691            out.push_str("  Not configured (plain DNS).\n");
9692        } else {
9693            out.push_str(doh_out.trim());
9694            out.push('\n');
9695        }
9696
9697        let suffixes = Command::new("powershell")
9698            .args([
9699                "-NoProfile", "-NonInteractive", "-Command",
9700                "Get-DnsClientGlobalSetting | Select-Object -ExpandProperty SuffixSearchList | ForEach-Object { \"  $_\" }",
9701            ])
9702            .output()
9703            .ok()
9704            .and_then(|o| String::from_utf8(o.stdout).ok())
9705            .unwrap_or_default();
9706
9707        if !suffixes.trim().is_empty() {
9708            out.push_str("\n=== DNS Search Suffix List ===\n");
9709            out.push_str(suffixes.trim());
9710            out.push('\n');
9711        }
9712    }
9713
9714    #[cfg(not(target_os = "windows"))]
9715    {
9716        let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
9717        out.push_str("=== /etc/resolv.conf ===\n");
9718        if resolv.is_empty() {
9719            out.push_str("  Not found.\n");
9720        } else {
9721            for line in resolv.lines() {
9722                if !line.trim().is_empty() && !line.starts_with('#') {
9723                    out.push_str(&format!("  {}\n", line));
9724                }
9725            }
9726        }
9727        let resolved_out = Command::new("resolvectl")
9728            .args(["status", "--no-pager"])
9729            .output()
9730            .ok()
9731            .and_then(|o| String::from_utf8(o.stdout).ok())
9732            .unwrap_or_default();
9733        if !resolved_out.is_empty() {
9734            out.push_str("\n=== systemd-resolved ===\n");
9735            for line in resolved_out.lines().take(30) {
9736                out.push_str(&format!("  {}\n", line));
9737            }
9738        }
9739    }
9740
9741    Ok(out.trim_end().to_string())
9742}
9743
9744fn inspect_bitlocker() -> Result<String, String> {
9745    let mut out = String::from("Host inspection: bitlocker\n\n");
9746
9747    #[cfg(target_os = "windows")]
9748    {
9749        let ps_cmd = "Get-BitLockerVolume | Select-Object MountPoint, VolumeStatus, ProtectionStatus, EncryptionPercentage | ForEach-Object { \"$($_.MountPoint) [$($_.VolumeStatus)] Protection:$($_.ProtectionStatus) ($($_.EncryptionPercentage)%)\" }";
9750        let output = Command::new("powershell")
9751            .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
9752            .output()
9753            .map_err(|e| format!("Failed to execute PowerShell: {e}"))?;
9754
9755        let stdout = String::from_utf8(output.stdout).unwrap_or_default();
9756        let stderr = String::from_utf8(output.stderr).unwrap_or_default();
9757
9758        if !stdout.trim().is_empty() {
9759            out.push_str("=== BitLocker Volumes ===\n");
9760            for line in stdout.lines() {
9761                out.push_str(&format!("  {}\n", line));
9762            }
9763        } else if !stderr.trim().is_empty() {
9764            if stderr.contains("Access is denied") {
9765                out.push_str("Error: Access denied. BitLocker diagnostics require Administrator elevation.\n");
9766            } else {
9767                out.push_str(&format!(
9768                    "Error retrieving BitLocker info: {}\n",
9769                    stderr.trim()
9770                ));
9771            }
9772        } else {
9773            out.push_str("No BitLocker volumes detected or access denied.\n");
9774        }
9775    }
9776
9777    #[cfg(not(target_os = "windows"))]
9778    {
9779        out.push_str(
9780            "BitLocker is a Windows-specific technology. Checking for LUKS/dm-crypt...\n\n",
9781        );
9782        let lsblk = Command::new("lsblk")
9783            .args(["-f", "-o", "NAME,FSTYPE,MOUNTPOINT"])
9784            .output()
9785            .ok()
9786            .and_then(|o| String::from_utf8(o.stdout).ok())
9787            .unwrap_or_default();
9788        if lsblk.contains("crypto_LUKS") {
9789            out.push_str("=== LUKS Encrypted Volumes ===\n");
9790            for line in lsblk.lines().filter(|l| l.contains("crypto_LUKS")) {
9791                out.push_str(&format!("  {}\n", line));
9792            }
9793        } else {
9794            out.push_str("No LUKS encrypted volumes detected via lsblk.\n");
9795        }
9796    }
9797
9798    Ok(out.trim_end().to_string())
9799}
9800
9801fn inspect_rdp() -> Result<String, String> {
9802    let mut out = String::from("Host inspection: rdp\n\n");
9803
9804    #[cfg(target_os = "windows")]
9805    {
9806        let reg_path = "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server";
9807        let f_deny = Command::new("powershell")
9808            .args([
9809                "-NoProfile",
9810                "-Command",
9811                &format!("(Get-ItemProperty '{}').fDenyTSConnections", reg_path),
9812            ])
9813            .output()
9814            .ok()
9815            .and_then(|o| String::from_utf8(o.stdout).ok())
9816            .unwrap_or_default()
9817            .trim()
9818            .to_string();
9819
9820        let status = if f_deny == "0" { "ENABLED" } else { "DISABLED" };
9821        out.push_str(&format!("=== RDP Status: {} ===\n", status));
9822
9823        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"])
9824            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default().trim().to_string();
9825        out.push_str(&format!(
9826            "  Port: {}\n",
9827            if port.is_empty() {
9828                "3389 (default)"
9829            } else {
9830                &port
9831            }
9832        ));
9833
9834        let nla = Command::new("powershell")
9835            .args([
9836                "-NoProfile",
9837                "-Command",
9838                &format!("(Get-ItemProperty '{}').UserAuthentication", reg_path),
9839            ])
9840            .output()
9841            .ok()
9842            .and_then(|o| String::from_utf8(o.stdout).ok())
9843            .unwrap_or_default()
9844            .trim()
9845            .to_string();
9846        out.push_str(&format!(
9847            "  NLA Required: {}\n",
9848            if nla == "1" { "Yes" } else { "No" }
9849        ));
9850
9851        let rdp_tcp_path =
9852            "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp";
9853        let sec_layer = Command::new("powershell")
9854            .args([
9855                "-NoProfile",
9856                "-Command",
9857                &format!("(Get-ItemProperty '{}').SecurityLayer", rdp_tcp_path),
9858            ])
9859            .output()
9860            .ok()
9861            .and_then(|o| String::from_utf8(o.stdout).ok())
9862            .unwrap_or_default()
9863            .trim()
9864            .to_string();
9865        let sec_label = match sec_layer.as_str() {
9866            "0" => "RDP Security (no SSL)",
9867            "1" => "Negotiate (prefer TLS)",
9868            "2" => "SSL/TLS required",
9869            _ => &sec_layer,
9870        };
9871        out.push_str(&format!(
9872            "  Security Layer: {} ({})\n",
9873            sec_layer, sec_label
9874        ));
9875
9876        let enc_level = Command::new("powershell")
9877            .args([
9878                "-NoProfile",
9879                "-Command",
9880                &format!("(Get-ItemProperty '{}').MinEncryptionLevel", rdp_tcp_path),
9881            ])
9882            .output()
9883            .ok()
9884            .and_then(|o| String::from_utf8(o.stdout).ok())
9885            .unwrap_or_default()
9886            .trim()
9887            .to_string();
9888        let enc_label = match enc_level.as_str() {
9889            "1" => "Low",
9890            "2" => "Client Compatible",
9891            "3" => "High",
9892            "4" => "FIPS Compliant",
9893            _ => "Unknown",
9894        };
9895        out.push_str(&format!(
9896            "  Encryption Level: {} ({})\n",
9897            enc_level, enc_label
9898        ));
9899
9900        out.push_str("\n=== Active Sessions ===\n");
9901        let qwinsta = Command::new("qwinsta")
9902            .output()
9903            .ok()
9904            .and_then(|o| String::from_utf8(o.stdout).ok())
9905            .unwrap_or_default();
9906        if qwinsta.trim().is_empty() {
9907            out.push_str("  No active sessions listed.\n");
9908        } else {
9909            for line in qwinsta.lines() {
9910                out.push_str(&format!("  {}\n", line));
9911            }
9912        }
9913
9914        out.push_str("\n=== Firewall Rule Check ===\n");
9915        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))\" }"])
9916            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
9917        if fw.trim().is_empty() {
9918            out.push_str("  No enabled RDP firewall rules found.\n");
9919        } else {
9920            out.push_str(fw.trim_end());
9921            out.push('\n');
9922        }
9923    }
9924
9925    #[cfg(not(target_os = "windows"))]
9926    {
9927        out.push_str("Checking for common RDP/VNC listeners (3389, 5900-5905)...\n");
9928        let ss = Command::new("ss")
9929            .args(["-tlnp"])
9930            .output()
9931            .ok()
9932            .and_then(|o| String::from_utf8(o.stdout).ok())
9933            .unwrap_or_default();
9934        let matches: Vec<&str> = ss
9935            .lines()
9936            .filter(|l| l.contains(":3389") || l.contains(":590"))
9937            .collect();
9938        if matches.is_empty() {
9939            out.push_str("  No RDP/VNC listeners detected via 'ss'.\n");
9940        } else {
9941            for m in matches {
9942                out.push_str(&format!("  {}\n", m));
9943            }
9944        }
9945    }
9946
9947    Ok(out.trim_end().to_string())
9948}
9949
9950fn inspect_shadow_copies() -> Result<String, String> {
9951    let mut out = String::from("Host inspection: shadow_copies\n\n");
9952
9953    #[cfg(target_os = "windows")]
9954    {
9955        let output = Command::new("vssadmin")
9956            .args(["list", "shadows"])
9957            .output()
9958            .map_err(|e| format!("Failed to run vssadmin: {e}"))?;
9959        let stdout = String::from_utf8(output.stdout).unwrap_or_default();
9960
9961        if stdout.contains("No items found") || stdout.trim().is_empty() {
9962            out.push_str("No Volume Shadow Copies found.\n");
9963        } else {
9964            out.push_str("=== Volume Shadow Copies ===\n");
9965            for line in stdout.lines().take(50) {
9966                if line.contains("Creation Time:")
9967                    || line.contains("Contents:")
9968                    || line.contains("Volume Name:")
9969                {
9970                    out.push_str(&format!("  {}\n", line.trim()));
9971                }
9972            }
9973        }
9974
9975        // Most recent snapshot age
9976        let age_script = r#"
9977try {
9978    $snaps = Get-WmiObject Win32_ShadowCopy -ErrorAction SilentlyContinue | Sort-Object InstallDate -Descending
9979    if ($snaps) {
9980        $newest = $snaps[0]
9981        $created = [Management.ManagementDateTimeConverter]::ToDateTime($newest.InstallDate)
9982        $age = [math]::Round(([datetime]::Now - $created).TotalDays, 1)
9983        $count = @($snaps).Count
9984        "Most recent snapshot: $($created.ToString('yyyy-MM-dd HH:mm'))  ($age days ago)  — $count total snapshots"
9985    } else { "No snapshots found via WMI." }
9986} catch { "WMI snapshot query unavailable: $_" }
9987"#;
9988        if let Ok(age_out) = Command::new("powershell")
9989            .args(["-NoProfile", "-Command", age_script])
9990            .output()
9991        {
9992            let age_text = String::from_utf8_lossy(&age_out.stdout).trim().to_string();
9993            if !age_text.is_empty() {
9994                out.push_str("\n=== Snapshot Age ===\n");
9995                out.push_str(&format!("  {}\n", age_text));
9996            }
9997        }
9998
9999        out.push_str("\n=== Shadow Copy Storage ===\n");
10000        let storage_out = Command::new("vssadmin")
10001            .args(["list", "shadowstorage"])
10002            .output()
10003            .ok();
10004        if let Some(o) = storage_out {
10005            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10006            for line in stdout.lines() {
10007                if line.contains("Used Shadow Copy Storage space:")
10008                    || line.contains("Max Shadow Copy Storage space:")
10009                {
10010                    out.push_str(&format!("  {}\n", line.trim()));
10011                }
10012            }
10013        }
10014    }
10015
10016    #[cfg(not(target_os = "windows"))]
10017    {
10018        out.push_str("Checking for LVM snapshots or Btrfs subvolumes...\n\n");
10019        let lvs = Command::new("lvs")
10020            .output()
10021            .ok()
10022            .and_then(|o| String::from_utf8(o.stdout).ok())
10023            .unwrap_or_default();
10024        if !lvs.is_empty() {
10025            out.push_str("=== LVM Volumes (checking for snapshots) ===\n");
10026            out.push_str(&lvs);
10027        } else {
10028            out.push_str("No LVM volumes detected.\n");
10029        }
10030    }
10031
10032    Ok(out.trim_end().to_string())
10033}
10034
10035fn inspect_pagefile() -> Result<String, String> {
10036    let mut out = String::from("Host inspection: pagefile\n\n");
10037
10038    #[cfg(target_os = "windows")]
10039    {
10040        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)\" }";
10041        let output = Command::new("powershell")
10042            .args(["-NoProfile", "-Command", ps_cmd])
10043            .output()
10044            .ok()
10045            .and_then(|o| String::from_utf8(o.stdout).ok())
10046            .unwrap_or_default();
10047
10048        if output.trim().is_empty() {
10049            out.push_str("No page files detected (system may be running without a page file or managed differently).\n");
10050            let managed = Command::new("powershell")
10051                .args([
10052                    "-NoProfile",
10053                    "-Command",
10054                    "(Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile",
10055                ])
10056                .output()
10057                .ok()
10058                .and_then(|o| String::from_utf8(o.stdout).ok())
10059                .unwrap_or_default()
10060                .trim()
10061                .to_string();
10062            out.push_str(&format!("Automatic Managed Pagefile: {}\n", managed));
10063        } else {
10064            out.push_str("=== Page File Usage ===\n");
10065            out.push_str(&output);
10066        }
10067    }
10068
10069    #[cfg(not(target_os = "windows"))]
10070    {
10071        out.push_str("=== Swap Usage (Linux/macOS) ===\n");
10072        let swap = Command::new("swapon")
10073            .args(["--show"])
10074            .output()
10075            .ok()
10076            .and_then(|o| String::from_utf8(o.stdout).ok())
10077            .unwrap_or_default();
10078        if swap.is_empty() {
10079            let free = Command::new("free")
10080                .args(["-h"])
10081                .output()
10082                .ok()
10083                .and_then(|o| String::from_utf8(o.stdout).ok())
10084                .unwrap_or_default();
10085            out.push_str(&free);
10086        } else {
10087            out.push_str(&swap);
10088        }
10089    }
10090
10091    Ok(out.trim_end().to_string())
10092}
10093
10094fn inspect_windows_features(max_entries: usize) -> Result<String, String> {
10095    let mut out = String::from("Host inspection: windows_features\n\n");
10096
10097    #[cfg(target_os = "windows")]
10098    {
10099        out.push_str("=== Quick Check: Notable Features ===\n");
10100        let quick_ps = "Get-WindowsOptionalFeature -Online | Where-Object { $_.FeatureName -match 'IIS|Hyper-V|VirtualMachinePlatform|Subsystem-Linux' -and $_.State -eq 'Enabled' } | Select-Object -ExpandProperty FeatureName";
10101        let output = Command::new("powershell")
10102            .args(["-NoProfile", "-Command", quick_ps])
10103            .output()
10104            .ok();
10105
10106        if let Some(o) = output {
10107            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10108            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10109
10110            if !stdout.trim().is_empty() {
10111                for f in stdout.lines() {
10112                    out.push_str(&format!("  [ENABLED] {}\n", f));
10113                }
10114            } else if stderr.contains("Access is denied") || stderr.contains("requires elevation") {
10115                out.push_str("  Error: Access denied. Listing Windows Features requires Administrator elevation.\n");
10116            } else if quick_ps.contains("-Online") && stdout.trim().is_empty() {
10117                out.push_str(
10118                    "  No major features (IIS, Hyper-V, WSL) appear enabled in the quick check.\n",
10119                );
10120            }
10121        }
10122
10123        out.push_str(&format!(
10124            "\n=== All Enabled Features (capped at {}) ===\n",
10125            max_entries
10126        ));
10127        let all_ps = format!("Get-WindowsOptionalFeature -Online | Where-Object {{$_.State -eq 'Enabled'}} | Select-Object -First {} -ExpandProperty FeatureName", max_entries);
10128        let all_out = Command::new("powershell")
10129            .args(["-NoProfile", "-Command", &all_ps])
10130            .output()
10131            .ok();
10132        if let Some(o) = all_out {
10133            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10134            if !stdout.trim().is_empty() {
10135                out.push_str(&stdout);
10136            }
10137        }
10138    }
10139
10140    #[cfg(not(target_os = "windows"))]
10141    {
10142        let _ = max_entries;
10143        out.push_str("Windows Optional Features are Windows-specific. On Linux, check your package manager.\n");
10144    }
10145
10146    Ok(out.trim_end().to_string())
10147}
10148
10149fn inspect_audio(max_entries: usize) -> Result<String, String> {
10150    let mut out = String::from("Host inspection: audio\n\n");
10151
10152    #[cfg(target_os = "windows")]
10153    {
10154        let n = max_entries.clamp(5, 20);
10155        let services = collect_services().unwrap_or_default();
10156        let core_service_names = ["Audiosrv", "AudioEndpointBuilder"];
10157        let bluetooth_audio_service_names = ["BthAvctpSvc", "BTAGService"];
10158
10159        let core_services: Vec<&ServiceEntry> = services
10160            .iter()
10161            .filter(|entry| {
10162                core_service_names
10163                    .iter()
10164                    .any(|name| entry.name.eq_ignore_ascii_case(name))
10165            })
10166            .collect();
10167        let bluetooth_audio_services: Vec<&ServiceEntry> = services
10168            .iter()
10169            .filter(|entry| {
10170                bluetooth_audio_service_names
10171                    .iter()
10172                    .any(|name| entry.name.eq_ignore_ascii_case(name))
10173            })
10174            .collect();
10175
10176        let probe_script = r#"
10177$media = @(Get-PnpDevice -Class Media -ErrorAction SilentlyContinue |
10178    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10179$endpoints = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
10180    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10181$sound = @(Get-CimInstance Win32_SoundDevice -ErrorAction SilentlyContinue |
10182    Select-Object Name, Status, Manufacturer, PNPDeviceID)
10183[pscustomobject]@{
10184    Media = $media
10185    Endpoints = $endpoints
10186    SoundDevices = $sound
10187} | ConvertTo-Json -Compress -Depth 4
10188"#;
10189        let probe_raw = Command::new("powershell")
10190            .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
10191            .output()
10192            .ok()
10193            .and_then(|o| String::from_utf8(o.stdout).ok())
10194            .unwrap_or_default();
10195        let probe_loaded = !probe_raw.trim().is_empty();
10196        let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
10197
10198        let endpoints = parse_windows_pnp_devices(probe_value.get("Endpoints"));
10199        let media_devices = parse_windows_pnp_devices(probe_value.get("Media"));
10200        let sound_devices = parse_windows_sound_devices(probe_value.get("SoundDevices"));
10201
10202        let playback_endpoints: Vec<&WindowsPnpDevice> = endpoints
10203            .iter()
10204            .filter(|device| !is_microphone_like_name(&device.name))
10205            .collect();
10206        let recording_endpoints: Vec<&WindowsPnpDevice> = endpoints
10207            .iter()
10208            .filter(|device| is_microphone_like_name(&device.name))
10209            .collect();
10210        let bluetooth_endpoints: Vec<&WindowsPnpDevice> = endpoints
10211            .iter()
10212            .filter(|device| is_bluetooth_like_name(&device.name))
10213            .collect();
10214        let endpoint_problems: Vec<&WindowsPnpDevice> = endpoints
10215            .iter()
10216            .filter(|device| windows_device_has_issue(device))
10217            .collect();
10218        let media_problems: Vec<&WindowsPnpDevice> = media_devices
10219            .iter()
10220            .filter(|device| windows_device_has_issue(device))
10221            .collect();
10222        let sound_problems: Vec<&WindowsSoundDevice> = sound_devices
10223            .iter()
10224            .filter(|device| windows_sound_device_has_issue(device))
10225            .collect();
10226
10227        let mut findings = Vec::new();
10228
10229        let stopped_core_services: Vec<&ServiceEntry> = core_services
10230            .iter()
10231            .copied()
10232            .filter(|service| !service_is_running(service))
10233            .collect();
10234        if !stopped_core_services.is_empty() {
10235            let names = stopped_core_services
10236                .iter()
10237                .map(|service| service.name.as_str())
10238                .collect::<Vec<_>>()
10239                .join(", ");
10240            findings.push(AuditFinding {
10241                finding: format!("Core audio services are not running: {names}"),
10242                impact: "Playback and recording devices can vanish or fail even when the hardware is physically present.".to_string(),
10243                fix: "Start Windows Audio (`Audiosrv`) and Windows Audio Endpoint Builder, then recheck the endpoint inventory before reinstalling drivers.".to_string(),
10244            });
10245        }
10246
10247        if probe_loaded
10248            && endpoints.is_empty()
10249            && media_devices.is_empty()
10250            && sound_devices.is_empty()
10251        {
10252            findings.push(AuditFinding {
10253                finding: "No audio endpoints or sound hardware were detected in the Windows device inventory.".to_string(),
10254                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(),
10255                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(),
10256            });
10257        }
10258
10259        if !endpoint_problems.is_empty() || !media_problems.is_empty() || !sound_problems.is_empty()
10260        {
10261            let mut problem_labels = Vec::new();
10262            problem_labels.extend(
10263                endpoint_problems
10264                    .iter()
10265                    .take(3)
10266                    .map(|device| device.name.clone()),
10267            );
10268            problem_labels.extend(
10269                media_problems
10270                    .iter()
10271                    .take(3)
10272                    .map(|device| device.name.clone()),
10273            );
10274            problem_labels.extend(
10275                sound_problems
10276                    .iter()
10277                    .take(3)
10278                    .map(|device| device.name.clone()),
10279            );
10280            findings.push(AuditFinding {
10281                finding: format!(
10282                    "Windows reports audio device issues for: {}",
10283                    problem_labels.join(", ")
10284                ),
10285                impact: "Apps can lose speakers, microphones, or headset paths when endpoint or media-class devices are degraded, disabled, or driver-broken.".to_string(),
10286                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(),
10287            });
10288        }
10289
10290        let stopped_bt_audio_services: Vec<&ServiceEntry> = bluetooth_audio_services
10291            .iter()
10292            .copied()
10293            .filter(|service| !service_is_running(service))
10294            .collect();
10295        if !bluetooth_endpoints.is_empty() && !stopped_bt_audio_services.is_empty() {
10296            let names = stopped_bt_audio_services
10297                .iter()
10298                .map(|service| service.name.as_str())
10299                .collect::<Vec<_>>()
10300                .join(", ");
10301            findings.push(AuditFinding {
10302                finding: format!(
10303                    "Bluetooth-branded audio endpoints exist, but Bluetooth audio services are not fully running: {names}"
10304                ),
10305                impact: "Headsets may pair yet fail to expose the correct playback or microphone profile, especially after wake or reconnect events.".to_string(),
10306                fix: "Restart the Bluetooth audio services and reconnect the headset before blaming the application layer.".to_string(),
10307            });
10308        }
10309
10310        out.push_str("=== Findings ===\n");
10311        if findings.is_empty() {
10312            out.push_str("- Finding: No obvious Windows audio-service outage or device-inventory failure was detected.\n");
10313            out.push_str("  Impact: Playback and recording look structurally present from this inspection pass.\n");
10314            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");
10315        } else {
10316            for finding in &findings {
10317                out.push_str(&format!("- Finding: {}\n", finding.finding));
10318                out.push_str(&format!("  Impact: {}\n", finding.impact));
10319                out.push_str(&format!("  Fix: {}\n", finding.fix));
10320            }
10321        }
10322
10323        out.push_str("\n=== Audio services ===\n");
10324        if core_services.is_empty() && bluetooth_audio_services.is_empty() {
10325            out.push_str(
10326                "- No Windows audio services were retrieved from the service inventory.\n",
10327            );
10328        } else {
10329            for service in core_services.iter().chain(bluetooth_audio_services.iter()) {
10330                out.push_str(&format!(
10331                    "- {} | Status: {} | Startup: {}\n",
10332                    service.name,
10333                    service.status,
10334                    service.startup.as_deref().unwrap_or("Unknown")
10335                ));
10336            }
10337        }
10338
10339        out.push_str("\n=== Playback and recording endpoints ===\n");
10340        if !probe_loaded {
10341            out.push_str("- Windows endpoint inventory probe returned no data.\n");
10342        } else if endpoints.is_empty() {
10343            out.push_str("- No audio endpoints detected.\n");
10344        } else {
10345            out.push_str(&format!(
10346                "- Playback-style endpoints: {} | Recording-style endpoints: {}\n",
10347                playback_endpoints.len(),
10348                recording_endpoints.len()
10349            ));
10350            for device in playback_endpoints.iter().take(n) {
10351                out.push_str(&format!(
10352                    "- [PLAYBACK] {} | Status: {}{}\n",
10353                    device.name,
10354                    device.status,
10355                    device
10356                        .problem
10357                        .filter(|problem| *problem != 0)
10358                        .map(|problem| format!(" | ProblemCode: {problem}"))
10359                        .unwrap_or_default()
10360                ));
10361            }
10362            for device in recording_endpoints.iter().take(n) {
10363                out.push_str(&format!(
10364                    "- [MIC] {} | Status: {}{}\n",
10365                    device.name,
10366                    device.status,
10367                    device
10368                        .problem
10369                        .filter(|problem| *problem != 0)
10370                        .map(|problem| format!(" | ProblemCode: {problem}"))
10371                        .unwrap_or_default()
10372                ));
10373            }
10374        }
10375
10376        out.push_str("\n=== Sound hardware devices ===\n");
10377        if sound_devices.is_empty() {
10378            out.push_str("- No Win32_SoundDevice entries were returned.\n");
10379        } else {
10380            for device in sound_devices.iter().take(n) {
10381                out.push_str(&format!(
10382                    "- {} | Status: {}{}\n",
10383                    device.name,
10384                    device.status,
10385                    device
10386                        .manufacturer
10387                        .as_deref()
10388                        .map(|manufacturer| format!(" | Vendor: {manufacturer}"))
10389                        .unwrap_or_default()
10390                ));
10391            }
10392        }
10393
10394        out.push_str("\n=== Media-class device inventory ===\n");
10395        if media_devices.is_empty() {
10396            out.push_str("- No media-class PnP devices were returned.\n");
10397        } else {
10398            for device in media_devices.iter().take(n) {
10399                out.push_str(&format!(
10400                    "- {} | Status: {}{}\n",
10401                    device.name,
10402                    device.status,
10403                    device
10404                        .class_name
10405                        .as_deref()
10406                        .map(|class_name| format!(" | Class: {class_name}"))
10407                        .unwrap_or_default()
10408                ));
10409            }
10410        }
10411    }
10412
10413    #[cfg(not(target_os = "windows"))]
10414    {
10415        let _ = max_entries;
10416        out.push_str(
10417            "Audio inspection currently provides deep endpoint and service coverage on Windows.\n",
10418        );
10419        out.push_str(
10420            "On Linux/macOS, ask narrower questions about PipeWire/PulseAudio/ALSA state if you want a dedicated native audit path added.\n",
10421        );
10422    }
10423
10424    Ok(out.trim_end().to_string())
10425}
10426
10427fn inspect_bluetooth(max_entries: usize) -> Result<String, String> {
10428    let mut out = String::from("Host inspection: bluetooth\n\n");
10429
10430    #[cfg(target_os = "windows")]
10431    {
10432        let n = max_entries.clamp(5, 20);
10433        let services = collect_services().unwrap_or_default();
10434        let bluetooth_services: Vec<&ServiceEntry> = services
10435            .iter()
10436            .filter(|entry| {
10437                entry.name.eq_ignore_ascii_case("bthserv")
10438                    || entry.name.eq_ignore_ascii_case("BthAvctpSvc")
10439                    || entry.name.eq_ignore_ascii_case("BTAGService")
10440                    || entry.name.starts_with("BluetoothUserService")
10441                    || entry
10442                        .display_name
10443                        .as_deref()
10444                        .unwrap_or("")
10445                        .to_ascii_lowercase()
10446                        .contains("bluetooth")
10447            })
10448            .collect();
10449
10450        let probe_script = r#"
10451$radios = @(Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue |
10452    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10453$devices = @(Get-PnpDevice -ErrorAction SilentlyContinue |
10454    Where-Object {
10455        $_.Class -eq 'Bluetooth' -or
10456        $_.FriendlyName -match 'Bluetooth' -or
10457        $_.InstanceId -like 'BTH*'
10458    } |
10459    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10460$audio = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
10461    Where-Object { $_.FriendlyName -match 'Bluetooth|Hands-Free|A2DP' } |
10462    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10463[pscustomobject]@{
10464    Radios = $radios
10465    Devices = $devices
10466    AudioEndpoints = $audio
10467} | ConvertTo-Json -Compress -Depth 4
10468"#;
10469        let probe_raw = Command::new("powershell")
10470            .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
10471            .output()
10472            .ok()
10473            .and_then(|o| String::from_utf8(o.stdout).ok())
10474            .unwrap_or_default();
10475        let probe_loaded = !probe_raw.trim().is_empty();
10476        let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
10477
10478        let radios = parse_windows_pnp_devices(probe_value.get("Radios"));
10479        let devices = parse_windows_pnp_devices(probe_value.get("Devices"));
10480        let audio_endpoints = parse_windows_pnp_devices(probe_value.get("AudioEndpoints"));
10481        let radio_problems: Vec<&WindowsPnpDevice> = radios
10482            .iter()
10483            .filter(|device| windows_device_has_issue(device))
10484            .collect();
10485        let device_problems: Vec<&WindowsPnpDevice> = devices
10486            .iter()
10487            .filter(|device| windows_device_has_issue(device))
10488            .collect();
10489
10490        let mut findings = Vec::new();
10491
10492        if probe_loaded && radios.is_empty() {
10493            findings.push(AuditFinding {
10494                finding: "No Bluetooth radio or adapter was detected in the device inventory.".to_string(),
10495                impact: "Pairing, reconnects, and Bluetooth audio paths cannot work without a healthy local radio.".to_string(),
10496                fix: "Check whether Bluetooth is disabled in firmware, turned off in Windows, or missing its vendor driver.".to_string(),
10497            });
10498        }
10499
10500        let stopped_bluetooth_services: Vec<&ServiceEntry> = bluetooth_services
10501            .iter()
10502            .copied()
10503            .filter(|service| !service_is_running(service))
10504            .collect();
10505        if !stopped_bluetooth_services.is_empty() {
10506            let names = stopped_bluetooth_services
10507                .iter()
10508                .map(|service| service.name.as_str())
10509                .collect::<Vec<_>>()
10510                .join(", ");
10511            findings.push(AuditFinding {
10512                finding: format!("Bluetooth-related services are not fully running: {names}"),
10513                impact: "Discovery, pairing, reconnects, and headset profile switching can all fail even when the adapter appears installed.".to_string(),
10514                fix: "Start the Bluetooth Support Service first, then reconnect the device and recheck the adapter and endpoint state.".to_string(),
10515            });
10516        }
10517
10518        if !radio_problems.is_empty() || !device_problems.is_empty() {
10519            let problem_labels = radio_problems
10520                .iter()
10521                .chain(device_problems.iter())
10522                .take(5)
10523                .map(|device| device.name.as_str())
10524                .collect::<Vec<_>>()
10525                .join(", ");
10526            findings.push(AuditFinding {
10527                finding: format!("Windows reports Bluetooth device issues for: {problem_labels}"),
10528                impact: "A degraded radio or paired-device node can cause pairing loops, sudden disconnects, or one-way headset behavior.".to_string(),
10529                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(),
10530            });
10531        }
10532
10533        if !audio_endpoints.is_empty()
10534            && bluetooth_services
10535                .iter()
10536                .any(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10537            && bluetooth_services
10538                .iter()
10539                .filter(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10540                .any(|service| !service_is_running(service))
10541        {
10542            findings.push(AuditFinding {
10543                finding: "Bluetooth audio endpoints exist, but the Bluetooth AVCTP service is not running.".to_string(),
10544                impact: "Headsets can connect yet expose the wrong audio role or lose media controls and microphone availability.".to_string(),
10545                fix: "Restart the AVCTP service and reconnect the headset before troubleshooting the app or conferencing tool.".to_string(),
10546            });
10547        }
10548
10549        out.push_str("=== Findings ===\n");
10550        if findings.is_empty() {
10551            out.push_str("- Finding: No obvious Bluetooth radio, service, or paired-device failure was detected.\n");
10552            out.push_str("  Impact: The Bluetooth stack looks structurally healthy from this inspection pass.\n");
10553            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");
10554        } else {
10555            for finding in &findings {
10556                out.push_str(&format!("- Finding: {}\n", finding.finding));
10557                out.push_str(&format!("  Impact: {}\n", finding.impact));
10558                out.push_str(&format!("  Fix: {}\n", finding.fix));
10559            }
10560        }
10561
10562        out.push_str("\n=== Bluetooth services ===\n");
10563        if bluetooth_services.is_empty() {
10564            out.push_str(
10565                "- No Bluetooth-related services were retrieved from the service inventory.\n",
10566            );
10567        } else {
10568            for service in bluetooth_services.iter().take(n) {
10569                out.push_str(&format!(
10570                    "- {} | Status: {} | Startup: {}\n",
10571                    service.name,
10572                    service.status,
10573                    service.startup.as_deref().unwrap_or("Unknown")
10574                ));
10575            }
10576        }
10577
10578        out.push_str("\n=== Bluetooth radios and adapters ===\n");
10579        if !probe_loaded {
10580            out.push_str("- Windows Bluetooth adapter inventory probe returned no data.\n");
10581        } else if radios.is_empty() {
10582            out.push_str("- No Bluetooth radios detected.\n");
10583        } else {
10584            for device in radios.iter().take(n) {
10585                out.push_str(&format!(
10586                    "- {} | Status: {}{}\n",
10587                    device.name,
10588                    device.status,
10589                    device
10590                        .problem
10591                        .filter(|problem| *problem != 0)
10592                        .map(|problem| format!(" | ProblemCode: {problem}"))
10593                        .unwrap_or_default()
10594                ));
10595            }
10596        }
10597
10598        out.push_str("\n=== Bluetooth-associated devices ===\n");
10599        if devices.is_empty() {
10600            out.push_str("- No Bluetooth-associated device nodes detected.\n");
10601        } else {
10602            for device in devices.iter().take(n) {
10603                out.push_str(&format!(
10604                    "- {} | Status: {}{}\n",
10605                    device.name,
10606                    device.status,
10607                    device
10608                        .class_name
10609                        .as_deref()
10610                        .map(|class_name| format!(" | Class: {class_name}"))
10611                        .unwrap_or_default()
10612                ));
10613            }
10614        }
10615
10616        out.push_str("\n=== Bluetooth audio endpoints ===\n");
10617        if audio_endpoints.is_empty() {
10618            out.push_str("- No Bluetooth-branded audio endpoints detected.\n");
10619        } else {
10620            for device in audio_endpoints.iter().take(n) {
10621                out.push_str(&format!(
10622                    "- {} | Status: {}{}\n",
10623                    device.name,
10624                    device.status,
10625                    device
10626                        .instance_id
10627                        .as_deref()
10628                        .map(|instance_id| format!(" | Instance: {instance_id}"))
10629                        .unwrap_or_default()
10630                ));
10631            }
10632        }
10633    }
10634
10635    #[cfg(not(target_os = "windows"))]
10636    {
10637        let _ = max_entries;
10638        out.push_str("Bluetooth inspection currently provides deep service and device coverage on Windows.\n");
10639        out.push_str(
10640            "On Linux/macOS, ask a narrower Bluetooth question if you want a dedicated native audit path added.\n",
10641        );
10642    }
10643
10644    Ok(out.trim_end().to_string())
10645}
10646
10647fn inspect_printers(max_entries: usize) -> Result<String, String> {
10648    let mut out = String::from("Host inspection: printers\n\n");
10649
10650    #[cfg(target_os = "windows")]
10651    {
10652        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)])
10653            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10654        if list.trim().is_empty() {
10655            out.push_str("No printers detected.\n");
10656        } else {
10657            out.push_str("=== Installed Printers ===\n");
10658            out.push_str(&list);
10659        }
10660
10661        let jobs = Command::new("powershell").args(["-NoProfile", "-Command", "Get-PrintJob | Select-Object PrinterName, ID, DocumentName, Status | ForEach-Object { \"  [$($_.PrinterName)] Job $($_.ID): $($_.DocumentName) - $($_.Status)\" }"])
10662            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10663        if !jobs.trim().is_empty() {
10664            out.push_str("\n=== Active Print Jobs ===\n");
10665            out.push_str(&jobs);
10666        }
10667    }
10668
10669    #[cfg(not(target_os = "windows"))]
10670    {
10671        let _ = max_entries;
10672        out.push_str("Checking LPSTAT for printers...\n");
10673        let lpstat = Command::new("lpstat")
10674            .args(["-p", "-d"])
10675            .output()
10676            .ok()
10677            .and_then(|o| String::from_utf8(o.stdout).ok())
10678            .unwrap_or_default();
10679        if lpstat.is_empty() {
10680            out.push_str("  No CUPS/LP printers found.\n");
10681        } else {
10682            out.push_str(&lpstat);
10683        }
10684    }
10685
10686    Ok(out.trim_end().to_string())
10687}
10688
10689fn inspect_winrm() -> Result<String, String> {
10690    let mut out = String::from("Host inspection: winrm\n\n");
10691
10692    #[cfg(target_os = "windows")]
10693    {
10694        let svc = Command::new("powershell")
10695            .args(["-NoProfile", "-Command", "(Get-Service WinRM).Status"])
10696            .output()
10697            .ok()
10698            .and_then(|o| String::from_utf8(o.stdout).ok())
10699            .unwrap_or_default()
10700            .trim()
10701            .to_string();
10702        out.push_str(&format!(
10703            "WinRM Service Status: {}\n\n",
10704            if svc.is_empty() { "NOT_FOUND" } else { &svc }
10705        ));
10706
10707        out.push_str("=== WinRM Listeners ===\n");
10708        let output = Command::new("powershell")
10709            .args([
10710                "-NoProfile",
10711                "-Command",
10712                "winrm enumerate winrm/config/listener 2>$null",
10713            ])
10714            .output()
10715            .ok();
10716        if let Some(o) = output {
10717            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10718            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10719
10720            if !stdout.trim().is_empty() {
10721                for line in stdout.lines() {
10722                    if line.contains("Address =")
10723                        || line.contains("Transport =")
10724                        || line.contains("Port =")
10725                    {
10726                        out.push_str(&format!("  {}\n", line.trim()));
10727                    }
10728                }
10729            } else if stderr.contains("Access is denied") {
10730                out.push_str("  Error: Access denied to WinRM configuration.\n");
10731            } else {
10732                out.push_str("  No listeners configured.\n");
10733            }
10734        }
10735
10736        out.push_str("\n=== PowerShell Remoting Test (Local) ===\n");
10737        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))\" }"])
10738            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10739        if test_out.trim().is_empty() {
10740            out.push_str("  WinRM not responding to local WS-Man requests.\n");
10741        } else {
10742            out.push_str(&test_out);
10743        }
10744    }
10745
10746    #[cfg(not(target_os = "windows"))]
10747    {
10748        out.push_str(
10749            "WinRM is primarily a Windows technology. Checking for listening port 5985/5986...\n",
10750        );
10751        let ss = Command::new("ss")
10752            .args(["-tln"])
10753            .output()
10754            .ok()
10755            .and_then(|o| String::from_utf8(o.stdout).ok())
10756            .unwrap_or_default();
10757        if ss.contains(":5985") || ss.contains(":5986") {
10758            out.push_str("  WinRM ports (5985/5986) are listening.\n");
10759        } else {
10760            out.push_str("  WinRM ports not detected.\n");
10761        }
10762    }
10763
10764    Ok(out.trim_end().to_string())
10765}
10766
10767fn inspect_network_stats(max_entries: usize) -> Result<String, String> {
10768    let mut out = String::from("Host inspection: network_stats\n\n");
10769
10770    #[cfg(target_os = "windows")]
10771    {
10772        let ps_cmd = format!(
10773            "$s1 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes; \
10774             Start-Sleep -Milliseconds 250; \
10775             $s2 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes, ReceivedPacketErrors, OutboundPacketErrors | Select-Object -First {}; \
10776             $s2 | ForEach-Object {{ \
10777                $name = $_.Name; \
10778                $prev = $s1 | Where-Object {{ $_.Name -eq $name }}; \
10779                if ($prev) {{ \
10780                    $rb = ($_.ReceivedBytes - $prev.ReceivedBytes) / 0.25; \
10781                    $sb = ($_.SentBytes - $prev.SentBytes) / 0.25; \
10782                    $rmbps = [math]::Round(($rb * 8) / 1000000, 2); \
10783                    $smbps = [math]::Round(($sb * 8) / 1000000, 2); \
10784                    $tr = [math]::Round($_.ReceivedBytes / 1MB, 2); \
10785                    $tt = [math]::Round($_.SentBytes / 1MB, 2); \
10786                    \"  $($name): Rate(RX/TX): $($rmbps)/$($smbps) Mbps | Total: $($tr)/$($tt) MB | Errors: $($_.ReceivedPacketErrors)/$($_.OutboundPacketErrors)\" \
10787                }} \
10788             }}",
10789            max_entries
10790        );
10791        let output = Command::new("powershell")
10792            .args(["-NoProfile", "-Command", &ps_cmd])
10793            .output()
10794            .ok()
10795            .and_then(|o| String::from_utf8(o.stdout).ok())
10796            .unwrap_or_default();
10797        if output.trim().is_empty() {
10798            out.push_str("No network adapter statistics available.\n");
10799        } else {
10800            out.push_str("=== Adapter Throughput (Mbps) & Health ===\n");
10801            out.push_str(&output);
10802        }
10803
10804        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)\" } }"])
10805            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10806        if !discards.trim().is_empty() {
10807            out.push_str("\n=== Packet Discards (Non-Zero Only) ===\n");
10808            out.push_str(&discards);
10809        }
10810    }
10811
10812    #[cfg(not(target_os = "windows"))]
10813    {
10814        let _ = max_entries;
10815        out.push_str("=== Network Stats (ip -s link) ===\n");
10816        let ip_s = Command::new("ip")
10817            .args(["-s", "link"])
10818            .output()
10819            .ok()
10820            .and_then(|o| String::from_utf8(o.stdout).ok())
10821            .unwrap_or_default();
10822        if ip_s.is_empty() {
10823            let netstat = Command::new("netstat")
10824                .args(["-i"])
10825                .output()
10826                .ok()
10827                .and_then(|o| String::from_utf8(o.stdout).ok())
10828                .unwrap_or_default();
10829            out.push_str(&netstat);
10830        } else {
10831            out.push_str(&ip_s);
10832        }
10833    }
10834
10835    Ok(out.trim_end().to_string())
10836}
10837
10838fn inspect_udp_ports(max_entries: usize) -> Result<String, String> {
10839    let mut out = String::from("Host inspection: udp_ports\n\n");
10840
10841    #[cfg(target_os = "windows")]
10842    {
10843        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);
10844        let output = Command::new("powershell")
10845            .args(["-NoProfile", "-Command", &ps_cmd])
10846            .output()
10847            .ok();
10848
10849        if let Some(o) = output {
10850            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10851            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10852
10853            if !stdout.trim().is_empty() {
10854                out.push_str("=== UDP Listeners (Local:Port) ===\n");
10855                for line in stdout.lines() {
10856                    let mut note = "";
10857                    if line.contains(":53 ") {
10858                        note = " [DNS]";
10859                    } else if line.contains(":67 ") || line.contains(":68 ") {
10860                        note = " [DHCP]";
10861                    } else if line.contains(":123 ") {
10862                        note = " [NTP]";
10863                    } else if line.contains(":161 ") {
10864                        note = " [SNMP]";
10865                    } else if line.contains(":1900 ") {
10866                        note = " [SSDP/UPnP]";
10867                    } else if line.contains(":5353 ") {
10868                        note = " [mDNS]";
10869                    }
10870
10871                    out.push_str(&format!("{}{}\n", line, note));
10872                }
10873            } else if stderr.contains("Access is denied") {
10874                out.push_str("Error: Access denied. Full UDP listener details require Administrator elevation.\n");
10875            } else {
10876                out.push_str("No UDP listeners detected.\n");
10877            }
10878        }
10879    }
10880
10881    #[cfg(not(target_os = "windows"))]
10882    {
10883        let ss_out = Command::new("ss")
10884            .args(["-ulnp"])
10885            .output()
10886            .ok()
10887            .and_then(|o| String::from_utf8(o.stdout).ok())
10888            .unwrap_or_default();
10889        out.push_str("=== UDP Listeners (ss -ulnp) ===\n");
10890        if ss_out.is_empty() {
10891            let netstat_out = Command::new("netstat")
10892                .args(["-ulnp"])
10893                .output()
10894                .ok()
10895                .and_then(|o| String::from_utf8(o.stdout).ok())
10896                .unwrap_or_default();
10897            if netstat_out.is_empty() {
10898                out.push_str("  Neither 'ss' nor 'netstat' available.\n");
10899            } else {
10900                for line in netstat_out.lines().take(max_entries) {
10901                    out.push_str(&format!("  {}\n", line));
10902                }
10903            }
10904        } else {
10905            for line in ss_out.lines().take(max_entries) {
10906                out.push_str(&format!("  {}\n", line));
10907            }
10908        }
10909    }
10910
10911    Ok(out.trim_end().to_string())
10912}
10913
10914fn inspect_gpo() -> Result<String, String> {
10915    let mut out = String::from("Host inspection: gpo\n\n");
10916
10917    #[cfg(target_os = "windows")]
10918    {
10919        let output = Command::new("gpresult")
10920            .args(["/r", "/scope", "computer"])
10921            .output()
10922            .ok();
10923
10924        if let Some(o) = output {
10925            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10926            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10927
10928            if stdout.contains("Applied Group Policy Objects") {
10929                out.push_str("=== Applied Group Policy Objects (Computer Scope) ===\n");
10930                let mut capture = false;
10931                for line in stdout.lines() {
10932                    if line.contains("Applied Group Policy Objects") {
10933                        capture = true;
10934                    } else if capture && line.contains("The following GPOs were not applied") {
10935                        break;
10936                    }
10937                    if capture && !line.trim().is_empty() {
10938                        out.push_str(&format!("  {}\n", line.trim()));
10939                    }
10940                }
10941            } else if stderr.contains("Access is denied") || stdout.contains("Access is denied") {
10942                out.push_str("Error: Access denied. Group Policy inspection requires Administrator elevation.\n");
10943            } else {
10944                out.push_str("No applied Group Policy Objects detected or insufficient permissions to query computer scope.\n");
10945            }
10946        }
10947    }
10948
10949    #[cfg(not(target_os = "windows"))]
10950    {
10951        out.push_str("Group Policy (GPO) is a Windows-only topic.\n");
10952    }
10953
10954    Ok(out.trim_end().to_string())
10955}
10956
10957fn inspect_certificates(max_entries: usize) -> Result<String, String> {
10958    let mut out = String::from("Host inspection: certificates\n\n");
10959
10960    #[cfg(target_os = "windows")]
10961    {
10962        let ps_cmd = format!(
10963            "Get-ChildItem -Path Cert:\\LocalMachine\\My | Select-Object Subject, NotAfter, Thumbprint | Select-Object -First {} | ForEach-Object {{ \
10964                $days = ($_.NotAfter - (Get-Date)).Days; \
10965                $status = if ($days -lt 0) {{ \"[EXPIRED]\" }} else if ($days -lt 30) {{ \"[EXPIRING SOON ($days days)]\" }} else {{ \"\" }}; \
10966                \"  $($_.Subject) - Expires: $($_.NotAfter.ToString('yyyy-MM-dd')) $status (Thumb: $($_.Thumbprint.Substring(0,8))...)\" \
10967            }}", 
10968            max_entries
10969        );
10970        let output = Command::new("powershell")
10971            .args(["-NoProfile", "-Command", &ps_cmd])
10972            .output()
10973            .ok();
10974
10975        if let Some(o) = output {
10976            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10977            if !stdout.trim().is_empty() {
10978                out.push_str("=== Local Machine Certificates (Personal Store) ===\n");
10979                out.push_str(&stdout);
10980            } else {
10981                out.push_str("No certificates found in the Local Machine Personal store.\n");
10982            }
10983        }
10984    }
10985
10986    #[cfg(not(target_os = "windows"))]
10987    {
10988        let _ = max_entries;
10989        out.push_str("Host inspection: certificates (Linux/macOS)\n\n");
10990        // Check standard cert locations
10991        for path in ["/etc/ssl/certs", "/etc/pki/tls/certs"] {
10992            if Path::new(path).exists() {
10993                out.push_str(&format!("  Cert directory found: {}\n", path));
10994            }
10995        }
10996    }
10997
10998    Ok(out.trim_end().to_string())
10999}
11000
11001fn inspect_integrity() -> Result<String, String> {
11002    let mut out = String::from("Host inspection: integrity\n\n");
11003
11004    #[cfg(target_os = "windows")]
11005    {
11006        let ps_cmd = "Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing' | Select-Object Corrupt, AutoRepairNeeded, LastRepairAttempted | ConvertTo-Json";
11007        let output = Command::new("powershell")
11008            .args(["-NoProfile", "-Command", &ps_cmd])
11009            .output()
11010            .ok();
11011
11012        if let Some(o) = output {
11013            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11014            if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
11015                out.push_str("=== Windows Component Store Health (CBS) ===\n");
11016                let corrupt = val.get("Corrupt").and_then(|v| v.as_u64()).unwrap_or(0);
11017                let repair = val
11018                    .get("AutoRepairNeeded")
11019                    .and_then(|v| v.as_u64())
11020                    .unwrap_or(0);
11021
11022                out.push_str(&format!(
11023                    "  Corruption Detected: {}\n",
11024                    if corrupt != 0 {
11025                        "YES (SFC/DISM recommended)"
11026                    } else {
11027                        "No"
11028                    }
11029                ));
11030                out.push_str(&format!(
11031                    "  Auto-Repair Needed: {}\n",
11032                    if repair != 0 { "YES" } else { "No" }
11033                ));
11034
11035                if let Some(last) = val.get("LastRepairAttempted").and_then(|v| v.as_u64()) {
11036                    out.push_str(&format!("  Last Repair Attempt: (Raw code: {})\n", last));
11037                }
11038            } else {
11039                out.push_str("Could not retrieve CBS health from registry. System may be healthy or state is unknown.\n");
11040            }
11041        }
11042
11043        if Path::new("C:\\Windows\\Logs\\CBS\\CBS.log").exists() {
11044            out.push_str(
11045                "\nNote: Detailed integrity logs available at C:\\Windows\\Logs\\CBS\\CBS.log\n",
11046            );
11047        }
11048    }
11049
11050    #[cfg(not(target_os = "windows"))]
11051    {
11052        out.push_str("System integrity check (Linux)\n\n");
11053        let pkg_check = Command::new("rpm")
11054            .args(["-Va"])
11055            .output()
11056            .or_else(|_| Command::new("dpkg").args(["--verify"]).output())
11057            .ok();
11058        if let Some(o) = pkg_check {
11059            out.push_str("  Package verification system active.\n");
11060            if o.status.success() {
11061                out.push_str("  No major package integrity issues detected.\n");
11062            }
11063        }
11064    }
11065
11066    Ok(out.trim_end().to_string())
11067}
11068
11069fn inspect_domain() -> Result<String, String> {
11070    let mut out = String::from("Host inspection: domain\n\n");
11071
11072    #[cfg(target_os = "windows")]
11073    {
11074        out.push_str("=== Windows Domain / Workgroup Identity ===\n");
11075        let ps_cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Name, Domain, PartOfDomain, Workgroup | ConvertTo-Json";
11076        let output = Command::new("powershell")
11077            .args(["-NoProfile", "-Command", &ps_cmd])
11078            .output()
11079            .ok();
11080
11081        if let Some(o) = output {
11082            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11083            if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
11084                let part_of_domain = val
11085                    .get("PartOfDomain")
11086                    .and_then(|v| v.as_bool())
11087                    .unwrap_or(false);
11088                let domain = val
11089                    .get("Domain")
11090                    .and_then(|v| v.as_str())
11091                    .unwrap_or("Unknown");
11092                let workgroup = val
11093                    .get("Workgroup")
11094                    .and_then(|v| v.as_str())
11095                    .unwrap_or("Unknown");
11096
11097                out.push_str(&format!(
11098                    "  Join Status: {}\n",
11099                    if part_of_domain {
11100                        "DOMAIN JOINED"
11101                    } else {
11102                        "WORKGROUP"
11103                    }
11104                ));
11105                if part_of_domain {
11106                    out.push_str(&format!("  Active Directory Domain: {}\n", domain));
11107                } else {
11108                    out.push_str(&format!("  Workgroup Name: {}\n", workgroup));
11109                }
11110
11111                if let Some(name) = val.get("Name").and_then(|v| v.as_str()) {
11112                    out.push_str(&format!("  NetBIOS Name: {}\n", name));
11113                }
11114            } else {
11115                out.push_str("  Domain identity data unavailable from WMI.\n");
11116            }
11117        } else {
11118            out.push_str("  Domain identity data unavailable from WMI.\n");
11119        }
11120    }
11121
11122    #[cfg(not(target_os = "windows"))]
11123    {
11124        let domainname = Command::new("domainname")
11125            .output()
11126            .ok()
11127            .and_then(|o| String::from_utf8(o.stdout).ok())
11128            .unwrap_or_default();
11129        out.push_str("=== Linux Domain Identity ===\n");
11130        if !domainname.trim().is_empty() && domainname.trim() != "(none)" {
11131            out.push_str(&format!("  NIS/YP Domain: {}\n", domainname.trim()));
11132        } else {
11133            out.push_str("  No NIS domain configured.\n");
11134        }
11135    }
11136
11137    Ok(out.trim_end().to_string())
11138}
11139
11140fn inspect_device_health() -> Result<String, String> {
11141    let mut out = String::from("Host inspection: device_health\n\n");
11142
11143    #[cfg(target_os = "windows")]
11144    {
11145        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)\" }";
11146        let output = Command::new("powershell")
11147            .args(["-NoProfile", "-Command", ps_cmd])
11148            .output()
11149            .ok()
11150            .and_then(|o| String::from_utf8(o.stdout).ok())
11151            .unwrap_or_default();
11152
11153        if output.trim().is_empty() {
11154            out.push_str("All PnP devices report as healthy (no ConfigManager errors detected).\n");
11155        } else {
11156            out.push_str("=== Malfunctioning Devices (Yellow Bangs) ===\n");
11157            out.push_str(&output);
11158            out.push_str(
11159                "\nTip: Error codes 10 and 28 usually indicate missing or incompatible drivers.\n",
11160            );
11161        }
11162    }
11163
11164    #[cfg(not(target_os = "windows"))]
11165    {
11166        out.push_str("Checking dmesg for hardware errors...\n");
11167        let dmesg = Command::new("dmesg")
11168            .args(["--level=err,crit,alert"])
11169            .output()
11170            .ok()
11171            .and_then(|o| String::from_utf8(o.stdout).ok())
11172            .unwrap_or_default();
11173        if dmesg.is_empty() {
11174            out.push_str("  No critical hardware errors found in dmesg.\n");
11175        } else {
11176            out.push_str(&dmesg.lines().take(20).collect::<Vec<_>>().join("\n"));
11177        }
11178    }
11179
11180    Ok(out.trim_end().to_string())
11181}
11182
11183fn inspect_drivers(max_entries: usize) -> Result<String, String> {
11184    let mut out = String::from("Host inspection: drivers\n\n");
11185
11186    #[cfg(target_os = "windows")]
11187    {
11188        out.push_str("=== Active System Drivers (CIM Snapshot) ===\n");
11189        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);
11190        let output = Command::new("powershell")
11191            .args(["-NoProfile", "-Command", &ps_cmd])
11192            .output()
11193            .ok()
11194            .and_then(|o| String::from_utf8(o.stdout).ok())
11195            .unwrap_or_default();
11196
11197        if output.trim().is_empty() {
11198            out.push_str("  No drivers retrieved via WMI.\n");
11199        } else {
11200            out.push_str(&output);
11201        }
11202    }
11203
11204    #[cfg(not(target_os = "windows"))]
11205    {
11206        out.push_str("=== Loaded Kernel Modules (lsmod) ===\n");
11207        let lsmod = Command::new("lsmod")
11208            .output()
11209            .ok()
11210            .and_then(|o| String::from_utf8(o.stdout).ok())
11211            .unwrap_or_default();
11212        out.push_str(
11213            &lsmod
11214                .lines()
11215                .take(max_entries)
11216                .collect::<Vec<_>>()
11217                .join("\n"),
11218        );
11219    }
11220
11221    Ok(out.trim_end().to_string())
11222}
11223
11224fn inspect_peripherals(max_entries: usize) -> Result<String, String> {
11225    let mut out = String::from("Host inspection: peripherals\n\n");
11226
11227    #[cfg(target_os = "windows")]
11228    {
11229        let _ = max_entries;
11230        out.push_str("=== USB Controllers & Hubs ===\n");
11231        let usb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_USBController | ForEach-Object { \"  $($_.Name) ($($_.Status))\" }"])
11232            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11233        out.push_str(if usb.is_empty() {
11234            "  None detected.\n"
11235        } else {
11236            &usb
11237        });
11238
11239        out.push_str("\n=== Input Devices (Keyboard/Pointer) ===\n");
11240        let kb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_Keyboard | ForEach-Object { \"  [KB] $($_.Name) ($($_.Status))\" }"])
11241            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11242        let mouse = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_PointingDevice | ForEach-Object { \"  [PTR] $($_.Name) ($($_.Status))\" }"])
11243            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11244        out.push_str(&kb);
11245        out.push_str(&mouse);
11246
11247        out.push_str("\n=== Connected Monitors (WMI) ===\n");
11248        let mon = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams | ForEach-Object { \"  Display ($($_.Active ? 'Active' : 'Inactive'))\" }"])
11249            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11250        out.push_str(if mon.is_empty() {
11251            "  No active monitors identified via WMI.\n"
11252        } else {
11253            &mon
11254        });
11255    }
11256
11257    #[cfg(not(target_os = "windows"))]
11258    {
11259        out.push_str("=== Connected USB Devices (lsusb) ===\n");
11260        let lsusb = Command::new("lsusb")
11261            .output()
11262            .ok()
11263            .and_then(|o| String::from_utf8(o.stdout).ok())
11264            .unwrap_or_default();
11265        out.push_str(
11266            &lsusb
11267                .lines()
11268                .take(max_entries)
11269                .collect::<Vec<_>>()
11270                .join("\n"),
11271        );
11272    }
11273
11274    Ok(out.trim_end().to_string())
11275}
11276
11277fn inspect_sessions(max_entries: usize) -> Result<String, String> {
11278    let mut out = String::from("Host inspection: sessions\n\n");
11279
11280    #[cfg(target_os = "windows")]
11281    {
11282        out.push_str("=== Active Logon Sessions (WMI Snapshot) ===\n");
11283        let script = r#"Get-CimInstance Win32_LogonSession | ForEach-Object {
11284    "$($_.LogonId)|$($_.StartTime)|$($_.LogonType)|$($_.AuthenticationPackage)"
11285}"#;
11286        if let Ok(o) = Command::new("powershell")
11287            .args(["-NoProfile", "-Command", script])
11288            .output()
11289        {
11290            let text = String::from_utf8_lossy(&o.stdout);
11291            let lines: Vec<&str> = text.lines().collect();
11292            if lines.is_empty() {
11293                out.push_str("  No active logon sessions enumerated via WMI.\n");
11294            } else {
11295                for line in lines
11296                    .iter()
11297                    .take(max_entries)
11298                    .filter(|l| !l.trim().is_empty())
11299                {
11300                    let parts: Vec<&str> = line.trim().split('|').collect();
11301                    if parts.len() == 4 {
11302                        let logon_type = match parts[2] {
11303                            "2" => "Interactive",
11304                            "3" => "Network",
11305                            "4" => "Batch",
11306                            "5" => "Service",
11307                            "7" => "Unlock",
11308                            "8" => "NetworkCleartext",
11309                            "9" => "NewCredentials",
11310                            "10" => "RemoteInteractive",
11311                            "11" => "CachedInteractive",
11312                            _ => "Other",
11313                        };
11314                        out.push_str(&format!(
11315                            "- ID: {} | Type: {} | Started: {} | Auth: {}\n",
11316                            parts[0], logon_type, parts[1], parts[3]
11317                        ));
11318                    }
11319                }
11320            }
11321        } else {
11322            out.push_str("  Active logon session data unavailable from WMI.\n");
11323        }
11324    }
11325
11326    #[cfg(not(target_os = "windows"))]
11327    {
11328        out.push_str("=== Logged-in Users (who) ===\n");
11329        let who = Command::new("who")
11330            .output()
11331            .ok()
11332            .and_then(|o| String::from_utf8(o.stdout).ok())
11333            .unwrap_or_default();
11334        out.push_str(&who.lines().take(max_entries).collect::<Vec<_>>().join("\n"));
11335    }
11336
11337    Ok(out.trim_end().to_string())
11338}
11339
11340async fn inspect_disk_benchmark(path: PathBuf) -> Result<String, String> {
11341    let mut out = String::from("Host inspection: disk_benchmark\n\n");
11342    let mut final_path = path;
11343
11344    if !final_path.exists() {
11345        if let Ok(current_exe) = std::env::current_exe() {
11346            out.push_str(&format!(
11347                "Note: Requested target '{}' not found. Falling back to current binary for silicon-aware intensity report.\n",
11348                final_path.display()
11349            ));
11350            final_path = current_exe;
11351        } else {
11352            return Err(format!("Target not found: {}", final_path.display()));
11353        }
11354    }
11355
11356    let target = if final_path.is_dir() {
11357        // Find a representative file to read
11358        let mut target_file = final_path.join("Cargo.toml");
11359        if !target_file.exists() {
11360            target_file = final_path.join("README.md");
11361        }
11362        if !target_file.exists() {
11363            return Err("Target path is a directory but no representative file (Cargo.toml/README.md) found for benchmarking.".to_string());
11364        }
11365        target_file
11366    } else {
11367        final_path
11368    };
11369
11370    out.push_str(&format!("Target: {}\n", target.display()));
11371    out.push_str("Running diagnostic stress test (5s read-thrash + kernel counter trace)...\n\n");
11372
11373    #[cfg(target_os = "windows")]
11374    {
11375        let script = format!(
11376            r#"
11377$target = "{}"
11378if (-not (Test-Path $target)) {{ "ERROR:Target not found"; exit }}
11379
11380$diskQueue = @()
11381$readStats = @()
11382$startTime = Get-Date
11383$duration = 5
11384
11385# Background reader job
11386$job = Start-Job -ScriptBlock {{
11387    param($t, $d)
11388    $stop = (Get-Date).AddSeconds($d)
11389    while ((Get-Date) -lt $stop) {{
11390        try {{ [System.IO.File]::ReadAllBytes($t) | Out-Null }} catch {{ }}
11391    }}
11392}} -ArgumentList $target, $duration
11393
11394# Metrics collector loop
11395$stopTime = (Get-Date).AddSeconds($duration)
11396while ((Get-Date) -lt $stopTime) {{
11397    $q = Get-Counter '\PhysicalDisk(_Total)\Avg. Disk Queue Length' -ErrorAction SilentlyContinue
11398    if ($q) {{ $diskQueue += $q.CounterSamples[0].CookedValue }}
11399    
11400    $r = Get-Counter '\PhysicalDisk(_Total)\Disk Reads/sec' -ErrorAction SilentlyContinue
11401    if ($r) {{ $readStats += $r.CounterSamples[0].CookedValue }}
11402    
11403    Start-Sleep -Milliseconds 250
11404}}
11405
11406Stop-Job $job
11407Receive-Job $job | Out-Null
11408Remove-Job $job
11409
11410$avgQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Average).Average }} else {{ 0 }}
11411$maxQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Maximum).Maximum }} else {{ 0 }}
11412$avgR = if ($readStats) {{ ($readStats | Measure-Object -Average).Average }} else {{ 0 }}
11413
11414"AVG_Q:$([math]::Round($avgQ, 4))|MAX_Q:$([math]::Round($maxQ, 4))|AVG_R:$([math]::Round($avgR, 2))"
11415"#,
11416            target.display()
11417        );
11418
11419        let output = Command::new("powershell")
11420            .args(["-NoProfile", "-Command", &script])
11421            .output()
11422            .map_err(|e| format!("Benchmark failed: {e}"))?;
11423
11424        let raw = String::from_utf8_lossy(&output.stdout);
11425        let text = raw.trim();
11426
11427        if text.starts_with("ERROR") {
11428            return Err(text.to_string());
11429        }
11430
11431        let mut lines = text.lines();
11432        if let Some(metrics_line) = lines.next() {
11433            let parts: Vec<&str> = metrics_line.split('|').collect();
11434            let mut avg_q = "unknown".to_string();
11435            let mut max_q = "unknown".to_string();
11436            let mut avg_r = "unknown".to_string();
11437
11438            for p in parts {
11439                if let Some((k, v)) = p.split_once(':') {
11440                    match k {
11441                        "AVG_Q" => avg_q = v.to_string(),
11442                        "MAX_Q" => max_q = v.to_string(),
11443                        "AVG_R" => avg_r = v.to_string(),
11444                        _ => {}
11445                    }
11446                }
11447            }
11448
11449            out.push_str("=== WORKSTATION INTENSITY REPORT ===\n");
11450            out.push_str(&format!("- Active Disk Queue (Avg): {}\n", avg_q));
11451            out.push_str(&format!("- Active Disk Queue (Max): {}\n", max_q));
11452            out.push_str(&format!("- Disk Throughput (Avg):  {} reads/sec\n", avg_r));
11453            out.push_str("\nVerdict: ");
11454            let q_num = avg_q.parse::<f64>().unwrap_or(0.0);
11455            if q_num > 1.0 {
11456                out.push_str(
11457                    "HIGH INTENSITY — the disk stack is saturated. Hardware bottleneck confirmed.",
11458                );
11459            } else if q_num > 0.1 {
11460                out.push_str("MODERATE LOAD — significant I/O pressure detected.");
11461            } else {
11462                out.push_str("LIGHT LOAD — the hardware is handling this volume comfortably.");
11463            }
11464        }
11465    }
11466
11467    #[cfg(not(target_os = "windows"))]
11468    {
11469        out.push_str("Note: Native silicon benchmarking is currently optimized for Windows performance counters.\n");
11470        out.push_str("Generic disk load simulated.\n");
11471    }
11472
11473    Ok(out)
11474}
11475
11476fn inspect_permissions(path: PathBuf, _max_entries: usize) -> Result<String, String> {
11477    let mut out = String::from("Host inspection: permissions\n\n");
11478    out.push_str(&format!(
11479        "Auditing access control for: {}\n\n",
11480        path.display()
11481    ));
11482
11483    #[cfg(target_os = "windows")]
11484    {
11485        let script = format!(
11486            "Get-Acl -Path '{}' | Select-Object Owner, AccessToString | ForEach-Object {{ \"OWNER:$($_.Owner)\"; \"RULES:$($_.AccessToString)\" }}",
11487            path.display()
11488        );
11489        let output = Command::new("powershell")
11490            .args(["-NoProfile", "-Command", &script])
11491            .output()
11492            .map_err(|e| format!("ACL check failed: {e}"))?;
11493
11494        let text = String::from_utf8_lossy(&output.stdout);
11495        if text.trim().is_empty() {
11496            out.push_str("No ACL information returned. Ensure the path exists and you have permission to query it.\n");
11497        } else {
11498            out.push_str("=== Windows NTFS Permissions ===\n");
11499            out.push_str(&text);
11500        }
11501    }
11502
11503    #[cfg(not(target_os = "windows"))]
11504    {
11505        let output = Command::new("ls")
11506            .args(["-ld", &path.to_string_lossy()])
11507            .output()
11508            .map_err(|e| format!("ls check failed: {e}"))?;
11509        out.push_str("=== Unix File Permissions ===\n");
11510        out.push_str(&String::from_utf8_lossy(&output.stdout));
11511    }
11512
11513    Ok(out.trim_end().to_string())
11514}
11515
11516fn inspect_login_history(max_entries: usize) -> Result<String, String> {
11517    let mut out = String::from("Host inspection: login_history\n\n");
11518
11519    #[cfg(target_os = "windows")]
11520    {
11521        out.push_str("Checking recent Logon events (Event ID 4624) from the Security Log...\n");
11522        out.push_str("Note: This typically requires Administrator elevation.\n\n");
11523
11524        let n = max_entries.clamp(1, 50);
11525        let script = format!(
11526            r#"try {{
11527    $events = Get-WinEvent -FilterHashtable @{{LogName='Security'; ID=4624}} -MaxEvents {n} -ErrorAction Stop
11528    $events | ForEach-Object {{
11529        $time = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm')
11530        # Extract target user name from the XML/Properties if possible
11531        $user = $_.Properties[5].Value
11532        $type = $_.Properties[8].Value
11533        "[$time] User: $user | Type: $type"
11534    }}
11535}} catch {{ "ERROR:" + $_.Exception.Message }}"#
11536        );
11537
11538        let output = Command::new("powershell")
11539            .args(["-NoProfile", "-Command", &script])
11540            .output()
11541            .map_err(|e| format!("Login history query failed: {e}"))?;
11542
11543        let text = String::from_utf8_lossy(&output.stdout);
11544        if text.starts_with("ERROR:") {
11545            out.push_str(&format!("Unable to query Security Log: {}\n", text));
11546        } else if text.trim().is_empty() {
11547            out.push_str("No recent logon events found or access denied.\n");
11548        } else {
11549            out.push_str("=== Recent Logons (Event ID 4624) ===\n");
11550            out.push_str(&text);
11551        }
11552    }
11553
11554    #[cfg(not(target_os = "windows"))]
11555    {
11556        let output = Command::new("last")
11557            .args(["-n", &max_entries.to_string()])
11558            .output()
11559            .map_err(|e| format!("last command failed: {e}"))?;
11560        out.push_str("=== Unix Login History (last) ===\n");
11561        out.push_str(&String::from_utf8_lossy(&output.stdout));
11562    }
11563
11564    Ok(out.trim_end().to_string())
11565}
11566
11567fn inspect_share_access(path: PathBuf) -> Result<String, String> {
11568    let mut out = String::from("Host inspection: share_access\n\n");
11569    out.push_str(&format!("Testing accessibility of: {}\n\n", path.display()));
11570
11571    #[cfg(target_os = "windows")]
11572    {
11573        let script = format!(
11574            r#"
11575$p = '{}'
11576$res = @{{ Reachable = $false; Readable = $false; Error = "" }}
11577if (Test-Connection -ComputerName ($p.Split('\')[2]) -Count 1 -Quiet -ErrorAction SilentlyContinue) {{
11578    $res.Reachable = $true
11579    try {{
11580        $null = Get-ChildItem -Path $p -ErrorAction Stop
11581        $res.Readable = $true
11582    }} catch {{
11583        $res.Error = $_.Exception.Message
11584    }}
11585}} else {{
11586    $res.Error = "Server unreachable (Ping failed)"
11587}}
11588"REACHABLE:$($res.Reachable)|READABLE:$($res.Readable)|ERROR:$($res.Error)""#,
11589            path.display()
11590        );
11591
11592        let output = Command::new("powershell")
11593            .args(["-NoProfile", "-Command", &script])
11594            .output()
11595            .map_err(|e| format!("Share test failed: {e}"))?;
11596
11597        let text = String::from_utf8_lossy(&output.stdout);
11598        out.push_str("=== Share Triage Results ===\n");
11599        out.push_str(&text);
11600    }
11601
11602    #[cfg(not(target_os = "windows"))]
11603    {
11604        out.push_str("Share access testing is primarily optimized for Windows UNC paths.\n");
11605    }
11606
11607    Ok(out.trim_end().to_string())
11608}
11609
11610fn inspect_dns_fix_plan(issue: &str) -> Result<String, String> {
11611    let mut out = String::from("Host inspection: fix_plan (DNS Resolution)\n\n");
11612    out.push_str(&format!("Issue: {}\n\n", issue));
11613    out.push_str("Proposed Remediation Steps:\n");
11614    out.push_str("1. **Flush DNS Cache**: Clear local resolver cache.\n");
11615    out.push_str("   `ipconfig /flushdns` (Windows) or `sudo resolvectl flush-caches` (Linux)\n");
11616    out.push_str("2. **Validate Hosts File**: Check for static overrides.\n");
11617    out.push_str("   `Get-Content C:\\Windows\\System32\\drivers\\etc\\hosts` (Windows)\n");
11618    out.push_str("3. **Test Name Resolution**: Use nslookup to query a specific server.\n");
11619    out.push_str("   `nslookup google.com 8.8.8.8` (Tests if external DNS works)\n");
11620    out.push_str("4. **Check Adapter DNS**: Ensure local settings match expected nameservers.\n");
11621    out.push_str(
11622        "   `Get-NetIPConfiguration | Select-Object InterfaceAlias, DNSServer` (Windows)\n",
11623    );
11624
11625    Ok(out)
11626}
11627
11628fn inspect_registry_audit() -> Result<String, String> {
11629    let mut out = String::from("Host inspection: registry_audit\n\n");
11630    out.push_str("Auditing advanced persistence points and shell integrity overrides...\n\n");
11631
11632    #[cfg(target_os = "windows")]
11633    {
11634        let script = r#"
11635$findings = @()
11636
11637# 1. Image File Execution Options (Debugger Hijacking)
11638$ifeo = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options"
11639if (Test-Path $ifeo) {
11640    Get-ChildItem $ifeo | ForEach-Object {
11641        $p = Get-ItemProperty $_.PSPath
11642        if ($p.debugger) { $findings += "[IFEO Hijack] $($_.PSChildName) -> Debugger defined: $($p.debugger)" }
11643    }
11644}
11645
11646# 2. Winlogon Shell Integrity
11647$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
11648$shell = (Get-ItemProperty $winlogon -Name Shell -ErrorAction SilentlyContinue).Shell
11649if ($shell -and $shell -ne "explorer.exe") {
11650    $findings += "[Winlogon Overlook] Non-standard shell defined: $shell"
11651}
11652
11653# 3. Session Manager BootExecute
11654$sm = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager"
11655$boot = (Get-ItemProperty $sm -Name BootExecute -ErrorAction SilentlyContinue).BootExecute
11656if ($boot -and $boot -notcontains "autocheck autochk *") {
11657    $findings += "[Boot Integrity] Non-standard BootExecute defined: $($boot -join ', ')"
11658}
11659
11660if ($findings.Count -eq 0) {
11661    "PASS: No common registry hijacking or shell overrides detected."
11662} else {
11663    $findings -join "`n"
11664}
11665"#;
11666        let output = Command::new("powershell")
11667            .args(["-NoProfile", "-Command", &script])
11668            .output()
11669            .map_err(|e| format!("Registry audit failed: {e}"))?;
11670
11671        let text = String::from_utf8_lossy(&output.stdout);
11672        out.push_str("=== Persistence & Integrity Check ===\n");
11673        out.push_str(&text);
11674    }
11675
11676    #[cfg(not(target_os = "windows"))]
11677    {
11678        out.push_str("Registry auditing is specific to Windows environments.\n");
11679    }
11680
11681    Ok(out.trim_end().to_string())
11682}
11683
11684fn inspect_thermal() -> Result<String, String> {
11685    let mut out = String::from("Host inspection: thermal\n\n");
11686    out.push_str("Checking CPU thermal state and active throttling indicators...\n\n");
11687
11688    #[cfg(target_os = "windows")]
11689    {
11690        let script = r#"
11691$thermal = Get-CimInstance -ClassName Win32_PerfRawData_Counters_ThermalZoneInformation -ErrorAction SilentlyContinue
11692if ($thermal) {
11693    $thermal | ForEach-Object {
11694        $temp = [math]::Round(($_.Temperature - 273.15), 1)
11695        "Zone: $($_.Name) | Temp: $temp °C | Throttling: $($_.HighPrecisionTemperature -eq 0 ? 'NO' : 'ACTIVE')"
11696    }
11697} else {
11698    "Thermal counters not directly available via WMI. Checking for system throttling indicators..."
11699    $throttling = Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty LoadPercentage
11700    "Current CPU Load: $throttling%"
11701}
11702"#;
11703        let output = Command::new("powershell")
11704            .args(["-NoProfile", "-Command", script])
11705            .output()
11706            .map_err(|e| format!("Thermal check failed: {e}"))?;
11707        out.push_str("=== Windows Thermal State ===\n");
11708        out.push_str(&String::from_utf8_lossy(&output.stdout));
11709    }
11710
11711    #[cfg(not(target_os = "windows"))]
11712    {
11713        out.push_str(
11714            "Thermal inspection is currently optimized for Windows performance counters.\n",
11715        );
11716    }
11717
11718    Ok(out.trim_end().to_string())
11719}
11720
11721fn inspect_activation() -> Result<String, String> {
11722    let mut out = String::from("Host inspection: activation\n\n");
11723    out.push_str("Auditing Windows activation and license state...\n\n");
11724
11725    #[cfg(target_os = "windows")]
11726    {
11727        let script = r#"
11728$xpr = cscript //nologo C:\Windows\System32\slmgr.vbs /xpr
11729$dli = cscript //nologo C:\Windows\System32\slmgr.vbs /dli
11730"Status: $($xpr.Trim())"
11731"Details: $($dli -join ' ' | Select-String -Pattern 'License Status|Name' -AllMatches | ForEach-Object { $_.ToString().Trim() })"
11732"#;
11733        let output = Command::new("powershell")
11734            .args(["-NoProfile", "-Command", script])
11735            .output()
11736            .map_err(|e| format!("Activation check failed: {e}"))?;
11737        out.push_str("=== Windows License Report ===\n");
11738        out.push_str(&String::from_utf8_lossy(&output.stdout));
11739    }
11740
11741    #[cfg(not(target_os = "windows"))]
11742    {
11743        out.push_str("Windows activation check is specific to the Windows platform.\n");
11744    }
11745
11746    Ok(out.trim_end().to_string())
11747}
11748
11749fn inspect_patch_history(max_entries: usize) -> Result<String, String> {
11750    let mut out = String::from("Host inspection: patch_history\n\n");
11751    out.push_str(&format!(
11752        "Listing the last {} installed Windows updates (KBs)...\n\n",
11753        max_entries
11754    ));
11755
11756    #[cfg(target_os = "windows")]
11757    {
11758        let n = max_entries.clamp(1, 50);
11759        let script = format!(
11760            "Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First {} | ForEach-Object {{ \"[$($_.InstalledOn.ToString('yyyy-MM-dd'))] $($_.HotFixID) - $($_.Description)\" }}",
11761            n
11762        );
11763        let output = Command::new("powershell")
11764            .args(["-NoProfile", "-Command", &script])
11765            .output()
11766            .map_err(|e| format!("Patch history query failed: {e}"))?;
11767        out.push_str("=== Recent HotFixes (KBs) ===\n");
11768        out.push_str(&String::from_utf8_lossy(&output.stdout));
11769    }
11770
11771    #[cfg(not(target_os = "windows"))]
11772    {
11773        out.push_str("Patch history is currently focused on Windows HotFixes.\n");
11774    }
11775
11776    Ok(out.trim_end().to_string())
11777}
11778
11779// ── ad_user ──────────────────────────────────────────────────────────────────
11780
11781fn inspect_ad_user(identity: &str) -> Result<String, String> {
11782    let mut out = String::from("Host inspection: ad_user\n\n");
11783    let ident = identity.trim();
11784    if ident.is_empty() {
11785        out.push_str("Status: No identity specified. Performing self-discovery...\n");
11786        #[cfg(target_os = "windows")]
11787        {
11788            let script = r#"
11789$u = [System.Security.Principal.WindowsIdentity]::GetCurrent()
11790"USER: " + $u.Name
11791"SID: " + $u.User.Value
11792"GROUPS: " + (($u.Groups | ForEach-Object { try { $_.Translate([System.Security.Principal.NTAccount]).Value } catch { $_.Value } }) -join ', ')
11793"ELEVATED: " + (New-Object System.Security.Principal.WindowsPrincipal($u)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
11794"#;
11795            let output = Command::new("powershell")
11796                .args(["-NoProfile", "-Command", script])
11797                .output()
11798                .ok();
11799            if let Some(o) = output {
11800                out.push_str(&String::from_utf8_lossy(&o.stdout));
11801            }
11802        }
11803        return Ok(out);
11804    }
11805
11806    #[cfg(target_os = "windows")]
11807    {
11808        let script = format!(
11809            r#"
11810try {{
11811    $u = Get-ADUser -Identity "{ident}" -Properties MemberOf, LastLogonDate, Enabled, PasswordExpired -ErrorAction Stop
11812    "NAME: " + $u.Name
11813    "SID: " + $u.SID
11814    "ENABLED: " + $u.Enabled
11815    "EXPIRED: " + $u.PasswordExpired
11816    "LOGON: " + $u.LastLogonDate
11817    "GROUPS: " + ($u.MemberOf -replace "CN=([^,]+),.*", "$1" -join ", ")
11818}} catch {{
11819    # Fallback to net user if AD module is missing or fails
11820    $net = net user "{ident}" /domain 2>&1
11821    if ($LASTEXITCODE -eq 0) {{
11822        $net | Select-String "User name", "Full Name", "Account active", "Password expires", "Last logon", "Local Group Memberships", "Global Group memberships" | ForEach-Object {{ $_.ToString().Trim() }}
11823    }} else {{
11824        "ERROR: " + $_.Exception.Message
11825    }}
11826}}"#
11827        );
11828
11829        let output = Command::new("powershell")
11830            .args(["-NoProfile", "-Command", &script])
11831            .output()
11832            .ok();
11833
11834        if let Some(o) = output {
11835            let stdout = String::from_utf8_lossy(&o.stdout);
11836            if stdout.contains("ERROR:") && stdout.contains("Get-ADUser") {
11837                out.push_str("Active Directory PowerShell module not found. Showing basic domain user info:\n\n");
11838            }
11839            out.push_str(&stdout);
11840        }
11841    }
11842
11843    #[cfg(not(target_os = "windows"))]
11844    {
11845        let _ = ident;
11846        out.push_str("(AD User lookup only available on Windows nodes)\n");
11847    }
11848
11849    Ok(out.trim_end().to_string())
11850}
11851
11852// ── dns_lookup ───────────────────────────────────────────────────────────────
11853
11854fn inspect_dns_lookup(name: &str, record_type: &str) -> Result<String, String> {
11855    let mut out = String::from("Host inspection: dns_lookup\n\n");
11856    let target = name.trim();
11857    if target.is_empty() {
11858        return Err("Missing required target name for dns_lookup.".to_string());
11859    }
11860
11861    #[cfg(target_os = "windows")]
11862    {
11863        let script = format!("Resolve-DnsName -Name '{target}' -Type {record_type} -ErrorAction SilentlyContinue | Select-Object Name, Type, TTL, Section, NameHost, Strings, IPAddress, Address | Format-List");
11864        let output = Command::new("powershell")
11865            .args(["-NoProfile", "-Command", &script])
11866            .output()
11867            .ok();
11868        if let Some(o) = output {
11869            let stdout = String::from_utf8_lossy(&o.stdout);
11870            if stdout.trim().is_empty() {
11871                out.push_str(&format!("No {record_type} records found for {target}.\n"));
11872            } else {
11873                out.push_str(&stdout);
11874            }
11875        }
11876    }
11877
11878    #[cfg(not(target_os = "windows"))]
11879    {
11880        let output = Command::new("dig")
11881            .args([target, record_type, "+short"])
11882            .output()
11883            .ok();
11884        if let Some(o) = output {
11885            out.push_str(&String::from_utf8_lossy(&o.stdout));
11886        }
11887    }
11888
11889    Ok(out.trim_end().to_string())
11890}
11891
11892// ── hyperv ───────────────────────────────────────────────────────────────────
11893
11894#[cfg(target_os = "windows")]
11895fn ps_exec(script: &str) -> String {
11896    Command::new("powershell")
11897        .args(["-NoProfile", "-NonInteractive", "-Command", script])
11898        .output()
11899        .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
11900        .unwrap_or_default()
11901}
11902
11903fn inspect_mdm_enrollment() -> Result<String, String> {
11904    #[cfg(target_os = "windows")]
11905    {
11906        let mut out = String::from("Host inspection: mdm_enrollment\n\n");
11907
11908        // ── dsregcmd /status — primary enrollment signal ──────────────────────
11909        out.push_str("=== Device join and MDM state (dsregcmd) ===\n");
11910        let ps_dsreg = r#"
11911$raw = dsregcmd /status 2>$null
11912$fields = @('AzureAdJoined','EnterpriseJoined','DomainJoined','MdmEnrolled',
11913            'WamDefaultSet','AzureAdPrt','TenantName','TenantId','MdmUrl','MdmTouUrl')
11914foreach ($line in $raw) {
11915    $t = $line.Trim()
11916    foreach ($f in $fields) {
11917        if ($t -like "$f :*") {
11918            $val = ($t -split ':',2)[1].Trim()
11919            "$f`: $val"
11920        }
11921    }
11922}
11923"#;
11924        match run_powershell(ps_dsreg) {
11925            Ok(o) if !o.trim().is_empty() => {
11926                for line in o.lines() {
11927                    let l = line.trim();
11928                    if !l.is_empty() {
11929                        out.push_str(&format!("- {l}\n"));
11930                    }
11931                }
11932            }
11933            Ok(_) => out.push_str(
11934                "- dsregcmd returned no enrollment fields (device may not be AAD-joined)\n",
11935            ),
11936            Err(e) => out.push_str(&format!("- dsregcmd error: {e}\n")),
11937        }
11938
11939        // ── Registry enrollment accounts ──────────────────────────────────────
11940        out.push_str("\n=== Enrollment accounts (registry) ===\n");
11941        let ps_enroll = r#"
11942$base = 'HKLM:\SOFTWARE\Microsoft\Enrollments'
11943if (Test-Path $base) {
11944    $accounts = Get-ChildItem $base -ErrorAction SilentlyContinue
11945    if ($accounts) {
11946        foreach ($acct in $accounts) {
11947            $p = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
11948            $upn    = if ($p.UPN)                { $p.UPN }                else { '(none)' }
11949            $server = if ($p.EnrollmentServerUrl){ $p.EnrollmentServerUrl }else { '(none)' }
11950            $type   = switch ($p.EnrollmentType) {
11951                6  { 'MDM' }
11952                13 { 'MAM' }
11953                default { "Type=$($p.EnrollmentType)" }
11954            }
11955            $state  = switch ($p.EnrollmentState) {
11956                1  { 'Enrolled' }
11957                2  { 'InProgress' }
11958                6  { 'Unenrolled' }
11959                default { "State=$($p.EnrollmentState)" }
11960            }
11961            "Account: $upn | $type | $state | $server"
11962        }
11963    } else { "No enrollment accounts found under $base" }
11964} else { "Enrollment registry key not found — device is not MDM-enrolled" }
11965"#;
11966        match run_powershell(ps_enroll) {
11967            Ok(o) => {
11968                for line in o.lines() {
11969                    let l = line.trim();
11970                    if !l.is_empty() {
11971                        out.push_str(&format!("- {l}\n"));
11972                    }
11973                }
11974            }
11975            Err(e) => out.push_str(&format!("- Registry read error: {e}\n")),
11976        }
11977
11978        // ── MDM service health ────────────────────────────────────────────────
11979        out.push_str("\n=== MDM services ===\n");
11980        let ps_svc = r#"
11981$names = @('IntuneManagementExtension','dmwappushservice','Microsoft.Management.Services.IntuneWindowsAgent')
11982foreach ($n in $names) {
11983    $s = Get-Service -Name $n -ErrorAction SilentlyContinue
11984    if ($s) { "$($s.Name): $($s.Status) (StartType: $($s.StartType))" }
11985}
11986"#;
11987        match run_powershell(ps_svc) {
11988            Ok(o) if !o.trim().is_empty() => {
11989                for line in o.lines() {
11990                    let l = line.trim();
11991                    if !l.is_empty() {
11992                        out.push_str(&format!("- {l}\n"));
11993                    }
11994                }
11995            }
11996            Ok(_) => out.push_str("- No Intune management services found (unmanaged device or extension not installed)\n"),
11997            Err(e) => out.push_str(&format!("- Service query error: {e}\n")),
11998        }
11999
12000        // ── Recent MDM / Intune events ────────────────────────────────────────
12001        out.push_str("\n=== Recent MDM events (last 24h) ===\n");
12002        let ps_evt = r#"
12003$logs = @('Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin',
12004          'Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot')
12005$cutoff = (Get-Date).AddHours(-24)
12006$found = $false
12007foreach ($log in $logs) {
12008    $evts = Get-WinEvent -LogName $log -MaxEvents 20 -ErrorAction SilentlyContinue |
12009            Where-Object { $_.TimeCreated -gt $cutoff -and $_.Level -le 3 }
12010    foreach ($e in $evts) {
12011        $found = $true
12012        $ts = $e.TimeCreated.ToString('HH:mm')
12013        $lvl = if ($e.Level -eq 2) { 'ERR' } else { 'WARN' }
12014        "[$lvl $ts] ID=$($e.Id) — $($e.Message.Split("`n")[0].Trim())"
12015    }
12016}
12017if (-not $found) { "No MDM warning/error events in the last 24 hours" }
12018"#;
12019        match run_powershell(ps_evt) {
12020            Ok(o) => {
12021                for line in o.lines() {
12022                    let l = line.trim();
12023                    if !l.is_empty() {
12024                        out.push_str(&format!("- {l}\n"));
12025                    }
12026                }
12027            }
12028            Err(e) => out.push_str(&format!("- Event log read error: {e}\n")),
12029        }
12030
12031        // ── Findings ──────────────────────────────────────────────────────────
12032        out.push_str("\n=== Findings ===\n");
12033        let body = out.clone();
12034        let enrolled = body.contains("MdmEnrolled: YES") || body.contains("| Enrolled |");
12035        let intune_running = body.contains("IntuneManagementExtension: Running");
12036        let has_errors = body.contains("[ERR ") || body.contains("[WARN ");
12037
12038        if !enrolled {
12039            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");
12040        } else {
12041            out.push_str("- ENROLLED: Device has an active MDM enrollment.\n");
12042            if !intune_running {
12043                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");
12044            }
12045        }
12046        if has_errors {
12047            out.push_str("- MDM error/warning events detected — review the events section above for blockers.\n");
12048        }
12049        if !enrolled && !has_errors {
12050            out.push_str("- No MDM error events detected. If enrollment is required, initiate from Settings > Accounts > Access work or school > Connect.\n");
12051        }
12052
12053        Ok(out)
12054    }
12055
12056    #[cfg(not(target_os = "windows"))]
12057    {
12058        Ok("Host inspection: mdm_enrollment\n\n=== Findings ===\n- MDM/Intune enrollment inspection is Windows-only.\n".into())
12059    }
12060}
12061
12062fn inspect_hyperv() -> Result<String, String> {
12063    #[cfg(target_os = "windows")]
12064    {
12065        let mut findings: Vec<String> = Vec::new();
12066        let mut out = String::new();
12067
12068        // --- Hyper-V role / VMMS service state ---
12069        let ps_role = r#"
12070$vmms = Get-Service -Name vmms -ErrorAction SilentlyContinue
12071$feature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -ErrorAction SilentlyContinue
12072$hostInfo = Get-VMHost -ErrorAction SilentlyContinue
12073$ram = (Get-CimInstance Win32_PhysicalMemory -ErrorAction SilentlyContinue | Measure-Object -Property Capacity -Sum).Sum
12074"VMMS:{0}|FeatureState:{1}|HostName:{2}|HostRAMBytes:{3}" -f `
12075    $(if ($vmms) { "$($vmms.Status)|$($vmms.StartType)" } else { "NotFound|Unknown" }),
12076    $(if ($feature) { $feature.State } else { "Unknown" }),
12077    $(if ($hostInfo) { $hostInfo.ComputerName } else { $env:COMPUTERNAME }),
12078    $(if ($ram) { $ram } else { "0" })
12079"#;
12080        let role_out = ps_exec(ps_role);
12081        out.push_str("=== Hyper-V role state ===\n");
12082
12083        let mut vmms_running = false;
12084        let mut host_ram_bytes: u64 = 0;
12085
12086        if let Some(line) = role_out.lines().find(|l| l.contains("VMMS:")) {
12087            let kv: std::collections::HashMap<&str, &str> = line
12088                .split('|')
12089                .filter_map(|p| {
12090                    let mut it = p.splitn(2, ':');
12091                    Some((it.next()?, it.next()?))
12092                })
12093                .collect();
12094            let vmms_status = kv.get("VMMS").copied().unwrap_or("Unknown");
12095            let feature_state = kv.get("FeatureState").copied().unwrap_or("Unknown");
12096            let host_name = kv.get("HostName").copied().unwrap_or("Unknown");
12097            host_ram_bytes = kv
12098                .get("HostRAMBytes")
12099                .and_then(|v| v.parse().ok())
12100                .unwrap_or(0);
12101
12102            let hyperv_installed = feature_state.eq_ignore_ascii_case("enabled");
12103            vmms_running = vmms_status.starts_with("Running");
12104
12105            out.push_str(&format!("- Host: {host_name}\n"));
12106            out.push_str(&format!(
12107                "- Hyper-V feature: {}\n",
12108                if hyperv_installed {
12109                    "Enabled"
12110                } else {
12111                    "Not installed"
12112                }
12113            ));
12114            out.push_str(&format!("- VMMS service: {vmms_status}\n"));
12115            if host_ram_bytes > 0 {
12116                out.push_str(&format!(
12117                    "- Host physical RAM: {} GB\n",
12118                    host_ram_bytes / 1_073_741_824
12119                ));
12120            }
12121
12122            if !hyperv_installed {
12123                findings.push(
12124                    "Hyper-V is not installed on this machine. Enable the Microsoft-Hyper-V-All feature to use virtualization.".into(),
12125                );
12126            } else if !vmms_running {
12127                findings.push(
12128                    "Hyper-V is installed but the Virtual Machine Management Service (vmms) is not running — VMs cannot start until the service is active.".into(),
12129                );
12130            }
12131        } else {
12132            out.push_str("- Could not determine Hyper-V role state\n");
12133            findings.push("Hyper-V does not appear to be installed on this machine.".into());
12134        }
12135
12136        // --- Virtual machines ---
12137        out.push_str("\n=== Virtual machines ===\n");
12138        if vmms_running {
12139            let ps_vms = r#"
12140Get-VM -ErrorAction SilentlyContinue | ForEach-Object {
12141    $ram_gb = [math]::Round($_.MemoryAssigned / 1GB, 2)
12142    "VM:{0}|State:{1}|CPU:{2}%|RAM:{3}GB|Uptime:{4}|Status:{5}|Generation:{6}" -f `
12143        $_.Name, $_.State, $_.CPUUsage, $ram_gb,
12144        $(if ($_.Uptime.TotalSeconds -gt 0) { "$($_.Uptime.Hours)h$($_.Uptime.Minutes)m" } else { "Off" }),
12145        $_.Status, $_.Generation
12146}
12147"#;
12148            let vms_out = ps_exec(ps_vms);
12149            let vm_lines: Vec<&str> = vms_out.lines().filter(|l| l.starts_with("VM:")).collect();
12150
12151            if vm_lines.is_empty() {
12152                out.push_str("- No virtual machines found on this host\n");
12153            } else {
12154                let mut total_ram_bytes: u64 = 0;
12155                let mut saved_vms: Vec<String> = Vec::new();
12156                for line in &vm_lines {
12157                    let kv: std::collections::HashMap<&str, &str> = line
12158                        .split('|')
12159                        .filter_map(|p| {
12160                            let mut it = p.splitn(2, ':');
12161                            Some((it.next()?, it.next()?))
12162                        })
12163                        .collect();
12164                    let name = kv.get("VM").copied().unwrap_or("Unknown");
12165                    let state = kv.get("State").copied().unwrap_or("Unknown");
12166                    let cpu = kv.get("CPU").copied().unwrap_or("0").trim_end_matches('%');
12167                    let ram = kv.get("RAM").copied().unwrap_or("0").trim_end_matches("GB");
12168                    let uptime = kv.get("Uptime").copied().unwrap_or("Off");
12169                    let status = kv.get("Status").copied().unwrap_or("");
12170                    let gen = kv.get("Generation").copied().unwrap_or("?");
12171
12172                    if let Ok(r) = ram.parse::<f64>() {
12173                        total_ram_bytes += (r * 1_073_741_824.0) as u64;
12174                    }
12175                    if state.eq_ignore_ascii_case("Saved") {
12176                        saved_vms.push(name.to_string());
12177                    }
12178
12179                    out.push_str(&format!(
12180                        "- {name} | State: {state} | CPU: {cpu}% | RAM: {ram} GB | Uptime: {uptime} | Gen{gen}\n"
12181                    ));
12182                    if !status.is_empty() && !status.eq_ignore_ascii_case("Operating normally") {
12183                        out.push_str(&format!("  Status: {status}\n"));
12184                    }
12185                }
12186
12187                out.push_str(&format!("\n- Total VMs: {}\n", vm_lines.len()));
12188                if total_ram_bytes > 0 && host_ram_bytes > 0 {
12189                    let pct = (total_ram_bytes * 100) / host_ram_bytes;
12190                    out.push_str(&format!(
12191                        "- Total VM RAM assigned: {} GB ({pct}% of host RAM)\n",
12192                        total_ram_bytes / 1_073_741_824
12193                    ));
12194                    if pct > 90 {
12195                        findings.push(format!(
12196                            "VM RAM assignment is at {pct}% of host physical RAM — the host may be under severe memory pressure if all VMs run simultaneously."
12197                        ));
12198                    }
12199                }
12200                if !saved_vms.is_empty() {
12201                    findings.push(format!(
12202                        "VMs in Saved state (consuming disk space for checkpoint): {} — resume or delete to free space.",
12203                        saved_vms.join(", ")
12204                    ));
12205                }
12206            }
12207        } else {
12208            out.push_str("- VMMS not running — cannot enumerate VMs\n");
12209        }
12210
12211        // --- VM network switches ---
12212        out.push_str("\n=== VM network switches ===\n");
12213        if vmms_running {
12214            let ps_switches = r#"
12215Get-VMSwitch -ErrorAction SilentlyContinue | ForEach-Object {
12216    "Switch:{0}|Type:{1}|Adapter:{2}" -f `
12217        $_.Name, $_.SwitchType,
12218        $(if ($_.NetAdapterInterfaceDescription) { $_.NetAdapterInterfaceDescription } else { "N/A" })
12219}
12220"#;
12221            let sw_out = ps_exec(ps_switches);
12222            let switch_lines: Vec<&str> = sw_out
12223                .lines()
12224                .filter(|l| l.starts_with("Switch:"))
12225                .collect();
12226
12227            if switch_lines.is_empty() {
12228                out.push_str("- No VM switches configured\n");
12229            } else {
12230                for line in &switch_lines {
12231                    let kv: std::collections::HashMap<&str, &str> = line
12232                        .split('|')
12233                        .filter_map(|p| {
12234                            let mut it = p.splitn(2, ':');
12235                            Some((it.next()?, it.next()?))
12236                        })
12237                        .collect();
12238                    let name = kv.get("Switch").copied().unwrap_or("Unknown");
12239                    let sw_type = kv.get("Type").copied().unwrap_or("Unknown");
12240                    let adapter = kv.get("Adapter").copied().unwrap_or("N/A");
12241                    out.push_str(&format!("- {name} | Type: {sw_type} | NIC: {adapter}\n"));
12242                }
12243            }
12244        } else {
12245            out.push_str("- VMMS not running — cannot enumerate switches\n");
12246        }
12247
12248        // --- VM checkpoints ---
12249        out.push_str("\n=== VM checkpoints ===\n");
12250        if vmms_running {
12251            let ps_checkpoints = r#"
12252$all = Get-VMCheckpoint -VMName * -ErrorAction SilentlyContinue
12253if ($all) {
12254    $all | ForEach-Object {
12255        "Checkpoint:{0}|VM:{1}|Created:{2}|Type:{3}" -f `
12256            $_.Name, $_.VMName,
12257            $_.CreationTime.ToString("yyyy-MM-dd HH:mm"),
12258            $_.SnapshotType
12259    }
12260} else {
12261    "NONE"
12262}
12263"#;
12264            let cp_out = ps_exec(ps_checkpoints);
12265            if cp_out.trim() == "NONE" || cp_out.trim().is_empty() {
12266                out.push_str("- No checkpoints found\n");
12267            } else {
12268                let cp_lines: Vec<&str> = cp_out
12269                    .lines()
12270                    .filter(|l| l.starts_with("Checkpoint:"))
12271                    .collect();
12272                let mut per_vm: std::collections::HashMap<&str, usize> =
12273                    std::collections::HashMap::new();
12274                for line in &cp_lines {
12275                    let kv: std::collections::HashMap<&str, &str> = line
12276                        .split('|')
12277                        .filter_map(|p| {
12278                            let mut it = p.splitn(2, ':');
12279                            Some((it.next()?, it.next()?))
12280                        })
12281                        .collect();
12282                    let cp_name = kv.get("Checkpoint").copied().unwrap_or("Unknown");
12283                    let vm_name = kv.get("VM").copied().unwrap_or("Unknown");
12284                    let created = kv.get("Created").copied().unwrap_or("");
12285                    let cp_type = kv.get("Type").copied().unwrap_or("");
12286                    out.push_str(&format!(
12287                        "- [{vm_name}] {cp_name} | Created: {created} | Type: {cp_type}\n"
12288                    ));
12289                    *per_vm.entry(vm_name).or_insert(0) += 1;
12290                }
12291                for (vm, count) in &per_vm {
12292                    if *count >= 3 {
12293                        findings.push(format!(
12294                            "VM '{vm}' has {count} checkpoints — excessive checkpoints grow the VHDX chain and degrade disk performance. Delete unneeded checkpoints."
12295                        ));
12296                    }
12297                }
12298            }
12299        } else {
12300            out.push_str("- VMMS not running — cannot enumerate checkpoints\n");
12301        }
12302
12303        let mut result = String::from("Host inspection: hyperv\n\n=== Findings ===\n");
12304        if findings.is_empty() {
12305            result.push_str("- No Hyper-V health issues detected.\n");
12306        } else {
12307            for f in &findings {
12308                result.push_str(&format!("- Finding: {f}\n"));
12309            }
12310        }
12311        result.push('\n');
12312        result.push_str(&out);
12313        return Ok(result.trim_end().to_string());
12314    }
12315
12316    #[cfg(not(target_os = "windows"))]
12317    Ok(
12318        "Host inspection: hyperv\n\n=== Findings ===\n- Hyper-V inspection is Windows-only.\n"
12319            .into(),
12320    )
12321}
12322
12323// ── ip_config ────────────────────────────────────────────────────────────────
12324
12325fn inspect_ip_config() -> Result<String, String> {
12326    let mut out = String::from("Host inspection: ip_config\n\n");
12327
12328    #[cfg(target_os = "windows")]
12329    {
12330        let script = "Get-NetIPConfiguration -Detailed | ForEach-Object { \
12331            $_.InterfaceAlias + ' [' + $_.InterfaceDescription + ']' + \
12332            '\\n  Status: ' + $_.NetAdapter.Status + \
12333            '\\n  Initial IPv4: ' + ($_.IPv4Address.IPAddress -join ', ') + \
12334            '\\n  DHCP Enabled: ' + $_.NetAdapter.DhcpStatus + \
12335            '\\n  DHCP Server: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
12336            '\\n  IPv4 Default Gateway: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
12337            '\\n  DNSServer: ' + ($_.DNSServer.ServerAddresses -join ', ') + '\\n' \
12338        }";
12339        let output = Command::new("powershell")
12340            .args(["-NoProfile", "-Command", script])
12341            .output()
12342            .ok();
12343        if let Some(o) = output {
12344            out.push_str(&String::from_utf8_lossy(&o.stdout));
12345        }
12346    }
12347
12348    #[cfg(not(target_os = "windows"))]
12349    {
12350        let output = Command::new("ip").args(["addr", "show"]).output().ok();
12351        if let Some(o) = output {
12352            out.push_str(&String::from_utf8_lossy(&o.stdout));
12353        }
12354    }
12355
12356    Ok(out.trim_end().to_string())
12357}
12358
12359// ── event_query ──────────────────────────────────────────────────────────────
12360
12361fn inspect_event_query(
12362    event_id: Option<u32>,
12363    log_name: Option<&str>,
12364    source: Option<&str>,
12365    hours: u32,
12366    level: Option<&str>,
12367    max_entries: usize,
12368) -> Result<String, String> {
12369    #[cfg(target_os = "windows")]
12370    {
12371        let mut findings: Vec<String> = Vec::new();
12372
12373        // Build the PowerShell filter hash
12374        let log = log_name.unwrap_or("*");
12375        let cap = max_entries.min(50);
12376
12377        // Level mapping: Error=2, Warning=3, Information=4
12378        let level_filter = match level.map(|l| l.to_lowercase()).as_deref() {
12379            Some("error") | Some("errors") => Some(2u8),
12380            Some("warning") | Some("warnings") | Some("warn") => Some(3u8),
12381            Some("information") | Some("info") => Some(4u8),
12382            _ => None,
12383        };
12384
12385        // Build filter hashtable entries
12386        let mut filter_parts = vec![format!("StartTime = (Get-Date).AddHours(-{hours})")];
12387        if log != "*" {
12388            filter_parts.push(format!("LogName = '{log}'"));
12389        }
12390        if let Some(id) = event_id {
12391            filter_parts.push(format!("Id = {id}"));
12392        }
12393        if let Some(src) = source {
12394            filter_parts.push(format!("ProviderName = '{src}'"));
12395        }
12396        if let Some(lvl) = level_filter {
12397            filter_parts.push(format!("Level = {lvl}"));
12398        }
12399
12400        let filter_ht = filter_parts.join("; ");
12401
12402        let ps = format!(
12403            r#"
12404$filter = @{{ {filter_ht} }}
12405try {{
12406    $events = Get-WinEvent -FilterHashtable $filter -MaxEvents {cap} -ErrorAction Stop |
12407        Select-Object TimeCreated, Id, LevelDisplayName, ProviderName,
12408            @{{N='Msg';E={{ ($_.Message -split "`n")[0] }}}}
12409    if ($events) {{
12410        $events | ForEach-Object {{
12411            "TIME:{{0}}|ID:{{1}}|LEVEL:{{2}}|SOURCE:{{3}}|MSG:{{4}}" -f `
12412                $_.TimeCreated.ToString("yyyy-MM-dd HH:mm:ss"),
12413                $_.Id, $_.LevelDisplayName, $_.ProviderName,
12414                ($_.Msg -replace '\|','/')
12415        }}
12416    }} else {{
12417        "NONE"
12418    }}
12419}} catch {{
12420    "ERROR:$($_.Exception.Message)"
12421}}
12422"#
12423        );
12424
12425        let raw = ps_exec(&ps);
12426        let lines: Vec<&str> = raw.lines().collect();
12427
12428        // Build query description for header
12429        let mut query_desc = format!("last {hours}h");
12430        if let Some(id) = event_id {
12431            query_desc.push_str(&format!(", Event ID {id}"));
12432        }
12433        if let Some(src) = source {
12434            query_desc.push_str(&format!(", source '{src}'"));
12435        }
12436        if log != "*" {
12437            query_desc.push_str(&format!(", log '{log}'"));
12438        }
12439        if let Some(l) = level {
12440            query_desc.push_str(&format!(", level '{l}'"));
12441        }
12442
12443        let mut out = format!("=== Event query: {query_desc} ===\n");
12444
12445        if lines
12446            .iter()
12447            .any(|l| l.trim() == "NONE" || l.trim().is_empty())
12448        {
12449            out.push_str("- No matching events found.\n");
12450        } else if let Some(err_line) = lines.iter().find(|l| l.starts_with("ERROR:")) {
12451            let msg = err_line.trim_start_matches("ERROR:").trim();
12452            if is_event_query_no_results_message(msg) {
12453                out.push_str("- No matching events found.\n");
12454            } else {
12455                out.push_str(&format!("- Query error: {msg}\n"));
12456                findings.push(format!("Event query failed: {msg}"));
12457            }
12458        } else {
12459            let event_lines: Vec<&str> = lines
12460                .iter()
12461                .filter(|l| l.starts_with("TIME:"))
12462                .copied()
12463                .collect();
12464            if event_lines.is_empty() {
12465                out.push_str("- No matching events found.\n");
12466            } else {
12467                // Tally by level for findings
12468                let mut error_count = 0usize;
12469                let mut warning_count = 0usize;
12470
12471                for line in &event_lines {
12472                    let kv: std::collections::HashMap<&str, &str> = line
12473                        .split('|')
12474                        .filter_map(|p| {
12475                            let mut it = p.splitn(2, ':');
12476                            Some((it.next()?, it.next()?))
12477                        })
12478                        .collect();
12479                    let time = kv.get("TIME").copied().unwrap_or("?");
12480                    let id = kv.get("ID").copied().unwrap_or("?");
12481                    let lvl = kv.get("LEVEL").copied().unwrap_or("?");
12482                    let src = kv.get("SOURCE").copied().unwrap_or("?");
12483                    let msg = kv.get("MSG").copied().unwrap_or("").trim();
12484
12485                    // Truncate long messages
12486                    let msg_display = if msg.len() > 120 {
12487                        format!("{}…", &msg[..120])
12488                    } else {
12489                        msg.to_string()
12490                    };
12491
12492                    out.push_str(&format!(
12493                        "- [{time}] ID {id} | {lvl} | {src}\n  {msg_display}\n"
12494                    ));
12495
12496                    if lvl.eq_ignore_ascii_case("error") || lvl.eq_ignore_ascii_case("critical") {
12497                        error_count += 1;
12498                    } else if lvl.eq_ignore_ascii_case("warning") {
12499                        warning_count += 1;
12500                    }
12501                }
12502
12503                out.push_str(&format!(
12504                    "\n- Total shown: {} event(s)\n",
12505                    event_lines.len()
12506                ));
12507
12508                if error_count > 0 {
12509                    findings.push(format!(
12510                        "{error_count} Error/Critical event(s) found in the {query_desc} window — review the entries above for root cause."
12511                    ));
12512                }
12513                if warning_count > 5 {
12514                    findings.push(format!(
12515                        "{warning_count} Warning events found — elevated warning volume may indicate a recurring issue."
12516                    ));
12517                }
12518            }
12519        }
12520
12521        let mut result = String::from("Host inspection: event_query\n\n=== Findings ===\n");
12522        if findings.is_empty() {
12523            result.push_str("- No actionable findings from this event query.\n");
12524        } else {
12525            for f in &findings {
12526                result.push_str(&format!("- Finding: {f}\n"));
12527            }
12528        }
12529        result.push('\n');
12530        result.push_str(&out);
12531        return Ok(result.trim_end().to_string());
12532    }
12533
12534    #[cfg(not(target_os = "windows"))]
12535    {
12536        let _ = (event_id, log_name, source, hours, level, max_entries);
12537        Ok("Host inspection: event_query\n\n=== Findings ===\n- Event log query is Windows-only.\n".into())
12538    }
12539}
12540
12541// ── app_crashes ───────────────────────────────────────────────────────────────
12542
12543fn inspect_app_crashes(process_filter: Option<&str>, max_entries: usize) -> Result<String, String> {
12544    let n = max_entries.clamp(5, 50);
12545    #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
12546    let mut findings: Vec<String> = Vec::new();
12547    #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
12548    let mut sections = String::new();
12549
12550    #[cfg(target_os = "windows")]
12551    {
12552        let proc_filter_ps = match process_filter {
12553            Some(proc) => format!(
12554                "| Where-Object {{ $_.Message -match [regex]::Escape('{}') }}",
12555                proc.replace('\'', "''")
12556            ),
12557            None => String::new(),
12558        };
12559
12560        let ps = format!(
12561            r#"
12562$results = @()
12563try {{
12564    $events = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue {proc_filter_ps}
12565    if ($events) {{
12566        foreach ($e in $events) {{
12567            $msg  = $e.Message
12568            $app  = if ($msg -match 'Faulting application name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ 'Unknown' }}
12569            $ver  = if ($msg -match 'Faulting application version: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12570            $mod  = if ($msg -match 'Faulting module name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12571            $exc  = if ($msg -match 'Exception code: (0x[0-9a-fA-F]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12572            $type = if ($e.Id -eq 1002) {{ 'HANG' }} else {{ 'CRASH' }}
12573            $results += "$($e.TimeCreated.ToString('yyyy-MM-dd HH:mm'))|$type|$app|$ver|$mod|$exc"
12574        }}
12575        $results
12576    }} else {{ 'NONE' }}
12577}} catch {{ 'ERROR:' + $_.Exception.Message }}
12578"#
12579        );
12580
12581        let raw = ps_exec(&ps);
12582        let text = raw.trim();
12583
12584        // WER archive count (non-blocking best-effort)
12585        let wer_ps = r#"
12586$wer = "$env:LOCALAPPDATA\Microsoft\Windows\WER"
12587$count = 0
12588if (Test-Path $wer) {
12589    $count = (Get-ChildItem -Path $wer -Recurse -Filter '*.wer' -ErrorAction SilentlyContinue).Count
12590}
12591$count
12592"#;
12593        let wer_count: usize = ps_exec(wer_ps).trim().parse().unwrap_or(0);
12594
12595        if text == "NONE" {
12596            sections.push_str("=== Application crashes ===\n- No application crashes or hangs in recent event log.\n");
12597        } else if text.starts_with("ERROR:") {
12598            let msg = text.trim_start_matches("ERROR:").trim();
12599            sections.push_str(&format!(
12600                "=== Application crashes ===\n- Unable to query Application event log: {msg}\n"
12601            ));
12602        } else {
12603            let events: Vec<&str> = text.lines().filter(|l| l.contains('|')).collect();
12604            let crash_count = events
12605                .iter()
12606                .filter(|l| l.splitn(3, '|').nth(1) == Some("CRASH"))
12607                .count();
12608            let hang_count = events
12609                .iter()
12610                .filter(|l| l.splitn(3, '|').nth(1) == Some("HANG"))
12611                .count();
12612
12613            // Tally crashes per app
12614            let mut app_counts: std::collections::HashMap<String, usize> =
12615                std::collections::HashMap::new();
12616            for line in &events {
12617                let parts: Vec<&str> = line.splitn(6, '|').collect();
12618                if parts.len() >= 3 {
12619                    *app_counts.entry(parts[2].to_string()).or_insert(0) += 1;
12620                }
12621            }
12622
12623            if crash_count > 0 {
12624                findings.push(format!(
12625                    "{crash_count} application crash event(s) — review below for faulting app and exception code."
12626                ));
12627            }
12628            if hang_count > 0 {
12629                findings.push(format!(
12630                    "{hang_count} application hang event(s) — process stopped responding."
12631                ));
12632            }
12633            if let Some((top_app, &count)) = app_counts.iter().max_by_key(|(_, c)| *c) {
12634                if count > 1 {
12635                    findings.push(format!(
12636                        "Most-crashed application: {top_app} ({count} events) — may indicate corrupted install or incompatible module."
12637                    ));
12638                }
12639            }
12640            if wer_count > 10 {
12641                findings.push(format!(
12642                    "{wer_count} WER reports archived — elevated crash history on this machine."
12643                ));
12644            }
12645
12646            let filter_note = match process_filter {
12647                Some(p) => format!(" (filtered: {p})"),
12648                None => String::new(),
12649            };
12650            sections.push_str(&format!(
12651                "=== Application crashes and hangs{filter_note} ===\n"
12652            ));
12653
12654            for line in &events {
12655                let parts: Vec<&str> = line.splitn(6, '|').collect();
12656                if parts.len() >= 6 {
12657                    let time = parts[0];
12658                    let kind = parts[1];
12659                    let app = parts[2];
12660                    let ver = parts[3];
12661                    let module = parts[4];
12662                    let exc = parts[5];
12663                    let ver_note = if !ver.is_empty() {
12664                        format!(" v{ver}")
12665                    } else {
12666                        String::new()
12667                    };
12668                    sections.push_str(&format!("  [{time}] {kind}: {app}{ver_note}\n"));
12669                    if !module.is_empty() && module != "?" {
12670                        let exc_note = if !exc.is_empty() {
12671                            format!(" (exc {exc})")
12672                        } else {
12673                            String::new()
12674                        };
12675                        sections.push_str(&format!("    faulting module: {module}{exc_note}\n"));
12676                    } else if !exc.is_empty() {
12677                        sections.push_str(&format!("    exception: {exc}\n"));
12678                    }
12679                }
12680            }
12681            sections.push_str(&format!(
12682                "\n  Total: {crash_count} crash(es), {hang_count} hang(s)\n"
12683            ));
12684
12685            if wer_count > 0 {
12686                sections.push_str(&format!(
12687                    "\n=== Windows Error Reporting ===\n  WER archive: {wer_count} report(s) in %LOCALAPPDATA%\\Microsoft\\Windows\\WER\n"
12688                ));
12689            }
12690        }
12691    }
12692
12693    #[cfg(not(target_os = "windows"))]
12694    {
12695        let _ = (process_filter, n);
12696        sections.push_str("=== Application crashes ===\n- Windows-only (uses Application Event Log, Event IDs 1000/1002).\n");
12697    }
12698
12699    let mut result = String::from("Host inspection: app_crashes\n\n=== Findings ===\n");
12700    if findings.is_empty() {
12701        result.push_str("- No actionable findings.\n");
12702    } else {
12703        for f in &findings {
12704            result.push_str(&format!("- Finding: {f}\n"));
12705        }
12706    }
12707    result.push('\n');
12708    result.push_str(&sections);
12709    Ok(result.trim_end().to_string())
12710}
12711
12712#[cfg(target_os = "windows")]
12713fn gpu_voltage_telemetry_note() -> String {
12714    let output = Command::new("nvidia-smi")
12715        .args(["--help-query-gpu"])
12716        .output();
12717
12718    match output {
12719        Ok(o) => {
12720            let text = String::from_utf8_lossy(&o.stdout).to_ascii_lowercase();
12721            if text.contains("\"voltage\"") || text.contains("voltage.") {
12722                "Driver query surface advertises GPU voltage fields, but Hematite is not yet decoding them on this path.".to_string()
12723            } else {
12724                "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()
12725            }
12726        }
12727        Err(_) => "Unavailable: `nvidia-smi` is not present, so Hematite cannot verify whether this driver path exposes GPU voltage rails.".to_string(),
12728    }
12729}
12730
12731#[cfg(target_os = "windows")]
12732fn decode_wmi_processor_voltage(raw: u64) -> Option<String> {
12733    if raw == 0 {
12734        return None;
12735    }
12736    if raw & 0x80 != 0 {
12737        let tenths = raw & 0x7f;
12738        return Some(format!(
12739            "{:.1} V (firmware-reported WMI current voltage)",
12740            tenths as f64 / 10.0
12741        ));
12742    }
12743
12744    let legacy = match raw {
12745        1 => Some("5.0 V"),
12746        2 => Some("3.3 V"),
12747        4 => Some("2.9 V"),
12748        _ => None,
12749    }?;
12750    Some(format!(
12751        "{} (legacy WMI voltage capability flag, not live telemetry)",
12752        legacy
12753    ))
12754}
12755
12756async fn inspect_overclocker() -> Result<String, String> {
12757    let mut out = String::from("Host inspection: overclocker\n\n");
12758
12759    #[cfg(target_os = "windows")]
12760    {
12761        out.push_str(
12762            "Gathering real-time silicon telemetry (2-second high-fidelity average)...\n\n",
12763        );
12764
12765        // 1. NVIDIA Census
12766        let nvidia = Command::new("nvidia-smi")
12767            .args([
12768                "--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",
12769                "--format=csv,noheader,nounits",
12770            ])
12771            .output();
12772
12773        if let Ok(o) = nvidia {
12774            let stdout = String::from_utf8_lossy(&o.stdout);
12775            if !stdout.trim().is_empty() {
12776                out.push_str("=== GPU SENSE (NVIDIA) ===\n");
12777                let parts: Vec<&str> = stdout.trim().split(',').map(|s| s.trim()).collect();
12778                if parts.len() >= 10 {
12779                    out.push_str(&format!("- Model:      {}\n", parts[0]));
12780                    out.push_str(&format!("- Graphics:   {} MHz\n", parts[1]));
12781                    out.push_str(&format!("- Memory:     {} MHz\n", parts[2]));
12782                    out.push_str(&format!("- Fan Speed:  {}%\n", parts[3]));
12783                    out.push_str(&format!("- Power Draw: {} W\n", parts[4]));
12784                    if !parts[6].eq_ignore_ascii_case("[N/A]") {
12785                        out.push_str(&format!("- Power Avg:  {} W\n", parts[6]));
12786                    }
12787                    if !parts[7].eq_ignore_ascii_case("[N/A]") {
12788                        out.push_str(&format!("- Power Inst: {} W\n", parts[7]));
12789                    }
12790                    if !parts[8].eq_ignore_ascii_case("[N/A]") {
12791                        out.push_str(&format!("- Power Cap:  {} W requested\n", parts[8]));
12792                    }
12793                    if !parts[9].eq_ignore_ascii_case("[N/A]") {
12794                        out.push_str(&format!("- Power Enf:  {} W enforced\n", parts[9]));
12795                    }
12796                    out.push_str(&format!("- Temperature: {}°C\n", parts[5]));
12797
12798                    if parts.len() > 10 {
12799                        let throttle_hex = parts[10];
12800                        let reasons = decode_nvidia_throttle_reasons(throttle_hex);
12801                        if !reasons.is_empty() {
12802                            out.push_str(&format!("- Throttling:  YES [Reason: {}]\n", reasons));
12803                        } else {
12804                            out.push_str("- Throttling:  None (Performance State: Max)\n");
12805                        }
12806                    }
12807                }
12808                out.push_str("\n");
12809            }
12810        }
12811
12812        out.push_str("=== VOLTAGE TELEMETRY ===\n");
12813        out.push_str(&format!(
12814            "- GPU Voltage:  {}\n\n",
12815            gpu_voltage_telemetry_note()
12816        ));
12817
12818        // 1b. Session Trends (RAM-only historians)
12819        let gpu_state = &crate::ui::gpu_monitor::GLOBAL_GPU_STATE;
12820        let history = gpu_state.history.lock().unwrap();
12821        if history.len() >= 2 {
12822            out.push_str("=== SILICON TRENDS (Session) ===\n");
12823            let first = history.front().unwrap();
12824            let last = history.back().unwrap();
12825
12826            let temp_diff = last.temperature as i32 - first.temperature as i32;
12827            let clock_diff = last.core_clock as i32 - first.core_clock as i32;
12828
12829            let temp_trend = if temp_diff > 1 {
12830                "Rising"
12831            } else if temp_diff < -1 {
12832                "Falling"
12833            } else {
12834                "Stable"
12835            };
12836            let clock_trend = if clock_diff > 10 {
12837                "Increasing"
12838            } else if clock_diff < -10 {
12839                "Decreasing"
12840            } else {
12841                "Stable"
12842            };
12843
12844            out.push_str(&format!(
12845                "- Temperature: {} ({}°C anomaly)\n",
12846                temp_trend, temp_diff
12847            ));
12848            out.push_str(&format!(
12849                "- Core Clock:  {} ({} MHz delta)\n",
12850                clock_trend, clock_diff
12851            ));
12852            out.push_str("\n");
12853        }
12854
12855        // 2. CPU Time-Series (2 samples)
12856        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))\" }";
12857        let cpu_stats = Command::new("powershell")
12858            .args(["-NoProfile", "-Command", ps_cmd])
12859            .output();
12860
12861        if let Ok(o) = cpu_stats {
12862            let stdout = String::from_utf8_lossy(&o.stdout);
12863            if !stdout.trim().is_empty() {
12864                out.push_str("=== SILICON CORE (CPU) ===\n");
12865                for line in stdout.lines() {
12866                    if let Some((path, val)) = line.split_once(':') {
12867                        if path.to_lowercase().contains("processor frequency") {
12868                            out.push_str(&format!("- Current Freq:  {} MHz (2s Avg)\n", val));
12869                        } else if path.to_lowercase().contains("% of maximum frequency") {
12870                            out.push_str(&format!("- Throttling:     {}% of Max Capacity\n", val));
12871                            let throttle_num = val.parse::<f64>().unwrap_or(100.0);
12872                            if throttle_num < 95.0 {
12873                                out.push_str(
12874                                    "  [WARNING] Active downclocking or power-saving detected.\n",
12875                                );
12876                            }
12877                        }
12878                    }
12879                }
12880            }
12881        }
12882
12883        // 2b. CPU Thermal Fallback
12884        let thermal = Command::new("powershell")
12885            .args([
12886                "-NoProfile",
12887                "-Command",
12888                "Get-CimInstance -Namespace root\\wmi -ClassName MSAcpi_ThermalZoneTemperature | Select-Object @{N='Temp';E={($_.CurrentTemperature - 2732) / 10}} | ConvertTo-Json",
12889            ])
12890            .output();
12891        if let Ok(o) = thermal {
12892            let stdout = String::from_utf8_lossy(&o.stdout);
12893            if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
12894                let temp = if v.is_array() {
12895                    v[0].get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
12896                } else {
12897                    v.get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
12898                };
12899                if temp > 1.0 {
12900                    out.push_str(&format!("- CPU Package:   {}°C (ACPI Zone)\n", temp));
12901                }
12902            }
12903        }
12904
12905        // 3. WMI Static Fallback/Context
12906        let wmi = Command::new("powershell")
12907            .args([
12908                "-NoProfile",
12909                "-Command",
12910                "Get-CimInstance Win32_Processor | Select-Object Name, MaxClockSpeed, CurrentVoltage | ConvertTo-Json",
12911            ])
12912            .output();
12913
12914        if let Ok(o) = wmi {
12915            let stdout = String::from_utf8_lossy(&o.stdout);
12916            if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
12917                out.push_str("\n=== HARDWARE DNA ===\n");
12918                out.push_str(&format!(
12919                    "- Rated Max:     {} MHz\n",
12920                    v.get("MaxClockSpeed").and_then(|x| x.as_u64()).unwrap_or(0)
12921                ));
12922                match v.get("CurrentVoltage").and_then(|x| x.as_u64()) {
12923                    Some(raw) => {
12924                        if let Some(decoded) = decode_wmi_processor_voltage(raw) {
12925                            out.push_str(&format!("- CPU Voltage:   {}\n", decoded));
12926                        } else {
12927                            out.push_str(
12928                                "- CPU Voltage:   Unavailable or non-telemetry WMI value on this firmware path\n",
12929                            );
12930                        }
12931                    }
12932                    None => out.push_str("- CPU Voltage:   Unavailable on this WMI path\n"),
12933                }
12934            }
12935        }
12936    }
12937
12938    #[cfg(not(target_os = "windows"))]
12939    {
12940        out.push_str("Overclocker telemetry is currently optimized for Windows performance counters and NVIDIA drivers.\n");
12941    }
12942
12943    Ok(out.trim_end().to_string())
12944}
12945
12946/// Decodes the NVIDIA Clocks Throttle Reasons HEX bitmask.
12947#[cfg(target_os = "windows")]
12948fn decode_nvidia_throttle_reasons(hex: &str) -> String {
12949    let hex = hex.trim().trim_start_matches("0x");
12950    let val = match u64::from_str_radix(hex, 16) {
12951        Ok(v) => v,
12952        Err(_) => return String::new(),
12953    };
12954
12955    if val == 0 {
12956        return String::new();
12957    }
12958
12959    let mut reasons = Vec::new();
12960    if val & 0x01 != 0 {
12961        reasons.push("GPU Idle");
12962    }
12963    if val & 0x02 != 0 {
12964        reasons.push("Applications Clocks Setting");
12965    }
12966    if val & 0x04 != 0 {
12967        reasons.push("SW Power Cap (PL1/PL2)");
12968    }
12969    if val & 0x08 != 0 {
12970        reasons.push("HW Slowdown (Thermal/Power)");
12971    }
12972    if val & 0x10 != 0 {
12973        reasons.push("Sync Boost");
12974    }
12975    if val & 0x20 != 0 {
12976        reasons.push("SW Thermal Slowdown");
12977    }
12978    if val & 0x40 != 0 {
12979        reasons.push("HW Thermal Slowdown");
12980    }
12981    if val & 0x80 != 0 {
12982        reasons.push("HW Power Brake Slowdown");
12983    }
12984    if val & 0x100 != 0 {
12985        reasons.push("Display Clock Setting");
12986    }
12987
12988    reasons.join(", ")
12989}
12990
12991// ── PowerShell helper (used by camera / sign_in / search_index) ───────────────
12992
12993#[cfg(windows)]
12994fn run_powershell(script: &str) -> Result<String, String> {
12995    use std::process::Command;
12996    let out = Command::new("powershell")
12997        .args(["-NoProfile", "-NonInteractive", "-Command", script])
12998        .output()
12999        .map_err(|e| format!("powershell launch failed: {e}"))?;
13000    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
13001}
13002
13003// ── inspect_camera ────────────────────────────────────────────────────────────
13004
13005#[cfg(windows)]
13006fn inspect_camera(max_entries: usize) -> Result<String, String> {
13007    let mut out = String::from("=== Camera devices ===\n");
13008
13009    // PnP camera devices
13010    let ps_devices = r#"
13011Get-PnpDevice -Class Camera -ErrorAction SilentlyContinue | Select-Object -First 20 |
13012ForEach-Object {
13013    $status = if ($_.Status -eq 'OK') { 'OK' } else { $_.Status }
13014    "$($_.FriendlyName) | Status: $status | InstanceId: $($_.InstanceId)"
13015}
13016"#;
13017    match run_powershell(ps_devices) {
13018        Ok(o) if !o.trim().is_empty() => {
13019            for line in o.lines().take(max_entries) {
13020                let l = line.trim();
13021                if !l.is_empty() {
13022                    out.push_str(&format!("- {l}\n"));
13023                }
13024            }
13025        }
13026        _ => out.push_str("- No camera devices found via PnP\n"),
13027    }
13028
13029    // Windows privacy / capability gate
13030    out.push_str("\n=== Windows camera privacy ===\n");
13031    let ps_privacy = r#"
13032$camKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\webcam'
13033$global = (Get-ItemProperty -Path $camKey -Name Value -ErrorAction SilentlyContinue).Value
13034"Global: $global"
13035$apps = Get-ChildItem $camKey -ErrorAction SilentlyContinue |
13036    Where-Object { $_.PSChildName -ne 'NonPackaged' } |
13037    ForEach-Object {
13038        $v = (Get-ItemProperty $_.PSPath -Name Value -ErrorAction SilentlyContinue).Value
13039        if ($v) { "  $($_.PSChildName): $v" }
13040    }
13041$apps
13042"#;
13043    match run_powershell(ps_privacy) {
13044        Ok(o) if !o.trim().is_empty() => {
13045            for line in o.lines().take(max_entries) {
13046                let l = line.trim_end();
13047                if !l.is_empty() {
13048                    out.push_str(&format!("{l}\n"));
13049                }
13050            }
13051        }
13052        _ => out.push_str("- Could not read camera privacy registry\n"),
13053    }
13054
13055    // Windows Hello camera (IR / face auth)
13056    out.push_str("\n=== Biometric / Hello camera ===\n");
13057    let ps_bio = r#"
13058Get-PnpDevice -Class Biometric -ErrorAction SilentlyContinue | Select-Object -First 10 |
13059ForEach-Object { "$($_.FriendlyName) | Status: $($_.Status)" }
13060"#;
13061    match run_powershell(ps_bio) {
13062        Ok(o) if !o.trim().is_empty() => {
13063            for line in o.lines().take(max_entries) {
13064                let l = line.trim();
13065                if !l.is_empty() {
13066                    out.push_str(&format!("- {l}\n"));
13067                }
13068            }
13069        }
13070        _ => out.push_str("- No biometric devices found\n"),
13071    }
13072
13073    // Findings
13074    let mut findings: Vec<String> = Vec::new();
13075    if out.contains("Status: Error") || out.contains("Status: Unknown") {
13076        findings.push("One or more camera devices report a non-OK status — check Device Manager for driver errors.".into());
13077    }
13078    if out.contains("Global: Deny") {
13079        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());
13080    }
13081
13082    let mut result = String::from("Host inspection: camera\n\n=== Findings ===\n");
13083    if findings.is_empty() {
13084        result.push_str("- No obvious camera or privacy gate issue detected.\n");
13085        result.push_str("  If an app still can't see the camera, check its individual permission in Settings > Privacy > Camera.\n");
13086    } else {
13087        for f in &findings {
13088            result.push_str(&format!("- Finding: {f}\n"));
13089        }
13090    }
13091    result.push('\n');
13092    result.push_str(&out);
13093    Ok(result)
13094}
13095
13096#[cfg(not(windows))]
13097fn inspect_camera(_max_entries: usize) -> Result<String, String> {
13098    Ok("Host inspection: camera\nCamera inspection is Windows-only.".into())
13099}
13100
13101// ── inspect_sign_in ───────────────────────────────────────────────────────────
13102
13103#[cfg(windows)]
13104fn inspect_sign_in(max_entries: usize) -> Result<String, String> {
13105    let mut out = String::from("=== Windows Hello and sign-in state ===\n");
13106
13107    // Windows Hello PIN and face/fingerprint readiness
13108    let ps_hello = r#"
13109$helloKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI'
13110$pinConfigured = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Provisioning\FingerPrint' -ErrorAction SilentlyContinue
13111$faceConfigured = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\WbioSrvc' -Name Start -ErrorAction SilentlyContinue).Start
13112"PIN-style logon path: $helloKey"
13113"WbioSrvc start type: $faceConfigured"
13114"FingerPrint key present: $pinConfigured"
13115"#;
13116    match run_powershell(ps_hello) {
13117        Ok(o) => {
13118            for line in o.lines().take(max_entries) {
13119                let l = line.trim();
13120                if !l.is_empty() {
13121                    out.push_str(&format!("- {l}\n"));
13122                }
13123            }
13124        }
13125        Err(e) => out.push_str(&format!("- Hello query error: {e}\n")),
13126    }
13127
13128    // Biometric service state
13129    out.push_str("\n=== Biometric service ===\n");
13130    let ps_bio_svc = r#"
13131$svc = Get-Service WbioSrvc -ErrorAction SilentlyContinue
13132if ($svc) { "WbioSrvc | Status: $($svc.Status) | StartType: $($svc.StartType)" }
13133else { "WbioSrvc not found" }
13134"#;
13135    match run_powershell(ps_bio_svc) {
13136        Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
13137        Err(_) => out.push_str("- Could not query biometric service\n"),
13138    }
13139
13140    // Recent logon failure events
13141    out.push_str("\n=== Recent sign-in failures (last 24h) ===\n");
13142    let ps_events = r#"
13143$cutoff = (Get-Date).AddHours(-24)
13144Get-WinEvent -LogName Security -FilterXPath "*[System[EventID=4625 and TimeCreated[timediff(@SystemTime) <= 86400000]]]" -MaxEvents 10 -ErrorAction SilentlyContinue |
13145ForEach-Object {
13146    $xml = [xml]$_.ToXml()
13147    $reason = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'FailureReason' }).'#text'
13148    $account = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text'
13149    "$($_.TimeCreated.ToString('HH:mm')) | Account: $account | Reason: $reason"
13150} | Select-Object -First 10
13151"#;
13152    match run_powershell(ps_events) {
13153        Ok(o) if !o.trim().is_empty() => {
13154            let count = o.lines().filter(|l| !l.trim().is_empty()).count();
13155            out.push_str(&format!("- {count} recent logon failure(s) detected:\n"));
13156            for line in o.lines().take(max_entries) {
13157                let l = line.trim();
13158                if !l.is_empty() {
13159                    out.push_str(&format!("  {l}\n"));
13160                }
13161            }
13162        }
13163        _ => out.push_str("- No sign-in failures in the last 24h (or insufficient privileges to read Security log)\n"),
13164    }
13165
13166    // Credential providers
13167    out.push_str("\n=== Active credential providers ===\n");
13168    let ps_cp = r#"
13169Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers' -ErrorAction SilentlyContinue |
13170ForEach-Object {
13171    $name = (Get-ItemProperty $_.PSPath -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
13172    if ($name) { $name }
13173} | Select-Object -First 15
13174"#;
13175    match run_powershell(ps_cp) {
13176        Ok(o) if !o.trim().is_empty() => {
13177            for line in o.lines().take(max_entries) {
13178                let l = line.trim();
13179                if !l.is_empty() {
13180                    out.push_str(&format!("- {l}\n"));
13181                }
13182            }
13183        }
13184        _ => out.push_str("- Could not enumerate credential providers\n"),
13185    }
13186
13187    let mut findings: Vec<String> = Vec::new();
13188    if out.contains("WbioSrvc | Status: Stopped") {
13189        findings.push("Windows Biometric Service is stopped — Windows Hello face/fingerprint will not work until it is running.".into());
13190    }
13191    if out.contains("recent logon failure") && !out.contains("0 recent") {
13192        findings.push("Recent sign-in failures detected — check the Security event log for account lockout or credential issues.".into());
13193    }
13194
13195    let mut result = String::from("Host inspection: sign_in\n\n=== Findings ===\n");
13196    if findings.is_empty() {
13197        result.push_str("- No obvious sign-in or Windows Hello service failure detected.\n");
13198        result.push_str("  If Hello is prompting for PIN or won't recognize you, try Settings > Accounts > Sign-in options > Repair.\n");
13199    } else {
13200        for f in &findings {
13201            result.push_str(&format!("- Finding: {f}\n"));
13202        }
13203    }
13204    result.push('\n');
13205    result.push_str(&out);
13206    Ok(result)
13207}
13208
13209#[cfg(not(windows))]
13210fn inspect_sign_in(_max_entries: usize) -> Result<String, String> {
13211    Ok("Host inspection: sign_in\nSign-in / Windows Hello inspection is Windows-only.".into())
13212}
13213
13214// ── inspect_installer_health ──────────────────────────────────────────────────
13215
13216#[cfg(windows)]
13217fn inspect_installer_health(max_entries: usize) -> Result<String, String> {
13218    let mut out = String::from("=== Installer engines ===\n");
13219
13220    let ps_engines = r#"
13221$services = 'msiserver','AppXSvc','ClipSVC','InstallService'
13222foreach ($name in $services) {
13223    $svc = Get-Service -Name $name -ErrorAction SilentlyContinue
13224    if ($svc) {
13225        $cim = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
13226        $startType = if ($cim) { $cim.StartMode } else { 'Unknown' }
13227        "$name | Status: $($svc.Status) | StartType: $startType"
13228    } else {
13229        "$name | Not present"
13230    }
13231}
13232if (Test-Path "$env:WINDIR\System32\msiexec.exe") {
13233    "msiexec.exe | Present: Yes"
13234} else {
13235    "msiexec.exe | Present: No"
13236}
13237"#;
13238    match run_powershell(ps_engines) {
13239        Ok(o) if !o.trim().is_empty() => {
13240            for line in o.lines().take(max_entries + 6) {
13241                let l = line.trim();
13242                if !l.is_empty() {
13243                    out.push_str(&format!("- {l}\n"));
13244                }
13245            }
13246        }
13247        _ => out.push_str("- Could not inspect installer engine services\n"),
13248    }
13249
13250    out.push_str("\n=== winget and App Installer ===\n");
13251    let ps_winget = r#"
13252$cmd = Get-Command winget -ErrorAction SilentlyContinue
13253if ($cmd) {
13254    try {
13255        $v = & winget --version 2>$null
13256        if ($LASTEXITCODE -eq 0 -and $v) { "winget | Version: $v" } else { "winget | Present but version query failed" }
13257    } catch { "winget | Present but invocation failed" }
13258} else {
13259    "winget | Missing"
13260}
13261$appInstaller = Get-AppxPackage Microsoft.DesktopAppInstaller -ErrorAction SilentlyContinue
13262if ($appInstaller) {
13263    "DesktopAppInstaller | Version: $($appInstaller.Version) | Status: Present"
13264} else {
13265    "DesktopAppInstaller | Status: Missing"
13266}
13267"#;
13268    match run_powershell(ps_winget) {
13269        Ok(o) if !o.trim().is_empty() => {
13270            for line in o.lines().take(max_entries) {
13271                let l = line.trim();
13272                if !l.is_empty() {
13273                    out.push_str(&format!("- {l}\n"));
13274                }
13275            }
13276        }
13277        _ => out.push_str("- Could not inspect winget/App Installer state\n"),
13278    }
13279
13280    out.push_str("\n=== Microsoft Store packages ===\n");
13281    let ps_store = r#"
13282$store = Get-AppxPackage Microsoft.WindowsStore -ErrorAction SilentlyContinue
13283if ($store) {
13284    "Microsoft.WindowsStore | Version: $($store.Version) | Status: Present"
13285} else {
13286    "Microsoft.WindowsStore | Status: Missing"
13287}
13288"#;
13289    match run_powershell(ps_store) {
13290        Ok(o) if !o.trim().is_empty() => {
13291            for line in o.lines().take(max_entries) {
13292                let l = line.trim();
13293                if !l.is_empty() {
13294                    out.push_str(&format!("- {l}\n"));
13295                }
13296            }
13297        }
13298        _ => out.push_str("- Could not inspect Microsoft Store package state\n"),
13299    }
13300
13301    out.push_str("\n=== Reboot and transaction blockers ===\n");
13302    let ps_blockers = r#"
13303$pending = $false
13304if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
13305    "RebootPending: CBS"
13306    $pending = $true
13307}
13308if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
13309    "RebootPending: WindowsUpdate"
13310    $pending = $true
13311}
13312$rename = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations
13313if ($rename) {
13314    "PendingFileRenameOperations: Yes"
13315    $pending = $true
13316}
13317if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\InProgress') {
13318    "InstallerInProgress: Yes"
13319    $pending = $true
13320}
13321if (-not $pending) { "No pending reboot or installer-in-progress flag detected" }
13322"#;
13323    match run_powershell(ps_blockers) {
13324        Ok(o) if !o.trim().is_empty() => {
13325            for line in o.lines().take(max_entries) {
13326                let l = line.trim();
13327                if !l.is_empty() {
13328                    out.push_str(&format!("- {l}\n"));
13329                }
13330            }
13331        }
13332        _ => out.push_str("- Could not inspect reboot or transaction blockers\n"),
13333    }
13334
13335    out.push_str("\n=== Recent installer failures (7d) ===\n");
13336    let ps_failures = r#"
13337$cutoff = (Get-Date).AddDays(-7)
13338$msi = Get-WinEvent -FilterHashtable @{ LogName='Application'; ProviderName='MsiInstaller'; StartTime=$cutoff; Level=2 } -MaxEvents 6 -ErrorAction SilentlyContinue |
13339    ForEach-Object { "MSI | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
13340$appx = Get-WinEvent -LogName 'Microsoft-Windows-AppXDeploymentServer/Operational' -ErrorAction SilentlyContinue -MaxEvents 30 |
13341    Where-Object { $_.LevelDisplayName -eq 'Error' -and $_.TimeCreated -ge $cutoff } |
13342    Select-Object -First 6 |
13343    ForEach-Object { "AppX | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
13344$all = @($msi) + @($appx)
13345if ($all.Count -eq 0) {
13346    "No recent MSI/AppX installer errors detected"
13347} else {
13348    $all | Select-Object -First 8
13349}
13350"#;
13351    match run_powershell(ps_failures) {
13352        Ok(o) if !o.trim().is_empty() => {
13353            for line in o.lines().take(max_entries + 2) {
13354                let l = line.trim();
13355                if !l.is_empty() {
13356                    out.push_str(&format!("- {l}\n"));
13357                }
13358            }
13359        }
13360        _ => out.push_str("- Could not inspect recent installer failure events\n"),
13361    }
13362
13363    let mut findings: Vec<String> = Vec::new();
13364    if out.contains("msiserver | Status: Stopped | StartType: Disabled") {
13365        findings.push("Windows Installer service (msiserver) is disabled - MSI installs cannot start until it is re-enabled.".into());
13366    }
13367    if out.contains("msiexec.exe | Present: No") {
13368        findings.push("msiexec.exe is missing from System32 - MSI installs will fail until Windows Installer is repaired.".into());
13369    }
13370    if out.contains("winget | Missing") {
13371        findings.push(
13372            "winget is missing - App Installer may not be installed or registered for this user."
13373                .into(),
13374        );
13375    }
13376    if out.contains("DesktopAppInstaller | Status: Missing") {
13377        findings.push("Microsoft Desktop App Installer is missing - winget and some app-installer flows will be unavailable.".into());
13378    }
13379    if out.contains("Microsoft.WindowsStore | Status: Missing") {
13380        findings.push(
13381            "Microsoft Store package is missing - Store-sourced installs and repairs may not work."
13382                .into(),
13383        );
13384    }
13385    if out.contains("RebootPending:") || out.contains("PendingFileRenameOperations: Yes") {
13386        findings.push("A pending reboot is present - installer transactions may stay blocked until the machine restarts.".into());
13387    }
13388    if out.contains("InstallerInProgress: Yes") {
13389        findings.push("Windows reports an installer transaction already in progress - concurrent installs may fail until it clears.".into());
13390    }
13391    if out.contains("MSI | ") || out.contains("AppX | ") {
13392        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());
13393    }
13394
13395    let mut result = String::from("Host inspection: installer_health\n\n=== Findings ===\n");
13396    if findings.is_empty() {
13397        result.push_str("- No obvious installer-platform blocker detected.\n");
13398    } else {
13399        for finding in &findings {
13400            result.push_str(&format!("- Finding: {finding}\n"));
13401        }
13402    }
13403    result.push('\n');
13404    result.push_str(&out);
13405    Ok(result)
13406}
13407
13408#[cfg(not(windows))]
13409fn inspect_installer_health(_max_entries: usize) -> Result<String, String> {
13410    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())
13411}
13412
13413// ── inspect_search_index ──────────────────────────────────────────────────────
13414
13415#[cfg(windows)]
13416fn inspect_onedrive(max_entries: usize) -> Result<String, String> {
13417    let mut out = String::from("=== OneDrive client ===\n");
13418
13419    let ps_client = r#"
13420$candidatePaths = @(
13421    (Join-Path $env:LOCALAPPDATA 'Microsoft\OneDrive\OneDrive.exe'),
13422    (Join-Path $env:ProgramFiles 'Microsoft OneDrive\OneDrive.exe'),
13423    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft OneDrive\OneDrive.exe')
13424) | Where-Object { $_ -and (Test-Path $_) }
13425$proc = Get-Process OneDrive -ErrorAction SilentlyContinue | Select-Object -First 1
13426$exe = $candidatePaths | Select-Object -First 1
13427if (-not $exe -and $proc) {
13428    try { $exe = $proc.Path } catch {}
13429}
13430if ($exe) {
13431    "Installed: Yes"
13432    "Executable: $exe"
13433    try { "Version: $((Get-Item $exe).VersionInfo.FileVersion)" } catch {}
13434} else {
13435    "Installed: Unknown"
13436}
13437if ($proc) {
13438    "Process: Running | PID: $($proc.Id)"
13439} else {
13440    "Process: Not running"
13441}
13442"#;
13443    match run_powershell(ps_client) {
13444        Ok(o) if !o.trim().is_empty() => {
13445            for line in o.lines().take(max_entries) {
13446                let l = line.trim();
13447                if !l.is_empty() {
13448                    out.push_str(&format!("- {l}\n"));
13449                }
13450            }
13451        }
13452        _ => out.push_str("- Could not inspect OneDrive client state\n"),
13453    }
13454
13455    out.push_str("\n=== OneDrive accounts ===\n");
13456    let ps_accounts = r#"
13457function MaskEmail([string]$Email) {
13458    if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
13459    $parts = $Email.Split('@', 2)
13460    $local = $parts[0]
13461    $domain = $parts[1]
13462    if ($local.Length -le 1) { return "*@$domain" }
13463    return ($local.Substring(0,1) + "***@" + $domain)
13464}
13465$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
13466if (Test-Path $base) {
13467    Get-ChildItem $base -ErrorAction SilentlyContinue |
13468        Sort-Object PSChildName |
13469        Select-Object -First 12 |
13470        ForEach-Object {
13471            $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13472            $kind = if ($_.PSChildName -eq 'Personal') { 'Personal' } else { 'Business' }
13473            $mail = MaskEmail ([string]$p.UserEmail)
13474            $root = if ([string]::IsNullOrWhiteSpace([string]$p.UserFolder)) { 'Unknown' } else { [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder) }
13475            $exists = if ($root -eq 'Unknown') { 'Unknown' } elseif (Test-Path $root) { 'Yes' } else { 'No' }
13476            "$kind | Email: $mail | SyncRoot: $root | Exists: $exists"
13477        }
13478} else {
13479    "No OneDrive accounts configured"
13480}
13481"#;
13482    match run_powershell(ps_accounts) {
13483        Ok(o) if !o.trim().is_empty() => {
13484            for line in o.lines().take(max_entries) {
13485                let l = line.trim();
13486                if !l.is_empty() {
13487                    out.push_str(&format!("- {l}\n"));
13488                }
13489            }
13490        }
13491        _ => out.push_str("- Could not read OneDrive account registry state\n"),
13492    }
13493
13494    out.push_str("\n=== OneDrive policy overrides ===\n");
13495    let ps_policy = r#"
13496$paths = @(
13497    'HKLM:\SOFTWARE\Policies\Microsoft\OneDrive',
13498    'HKCU:\SOFTWARE\Policies\Microsoft\OneDrive'
13499)
13500$names = @(
13501    'DisableFileSyncNGSC',
13502    'DisableLibrariesDefaultSaveToOneDrive',
13503    'KFMSilentOptIn',
13504    'KFMBlockOptIn',
13505    'SilentAccountConfig'
13506)
13507$found = $false
13508foreach ($path in $paths) {
13509    if (Test-Path $path) {
13510        $p = Get-ItemProperty $path -ErrorAction SilentlyContinue
13511        foreach ($name in $names) {
13512            $value = $p.$name
13513            if ($null -ne $value -and [string]$value -ne '') {
13514                "$path | $name=$value"
13515                $found = $true
13516            }
13517        }
13518    }
13519}
13520if (-not $found) { "No OneDrive policy overrides detected" }
13521"#;
13522    match run_powershell(ps_policy) {
13523        Ok(o) if !o.trim().is_empty() => {
13524            for line in o.lines().take(max_entries) {
13525                let l = line.trim();
13526                if !l.is_empty() {
13527                    out.push_str(&format!("- {l}\n"));
13528                }
13529            }
13530        }
13531        _ => out.push_str("- Could not read OneDrive policy state\n"),
13532    }
13533
13534    out.push_str("\n=== Known Folder Backup ===\n");
13535    let ps_kfm = r#"
13536$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
13537$roots = @()
13538if (Test-Path $base) {
13539    Get-ChildItem $base -ErrorAction SilentlyContinue | ForEach-Object {
13540        $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13541        if ($p.UserFolder) {
13542            $roots += [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder)
13543        }
13544    }
13545}
13546$roots = $roots | Select-Object -Unique
13547$shell = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders'
13548if (Test-Path $shell) {
13549    $props = Get-ItemProperty $shell -ErrorAction SilentlyContinue
13550    $folders = @(
13551        @{ Name='Desktop'; Value=$props.Desktop },
13552        @{ Name='Documents'; Value=$props.Personal },
13553        @{ Name='Pictures'; Value=$props.'My Pictures' }
13554    )
13555    foreach ($folder in $folders) {
13556        $path = [Environment]::ExpandEnvironmentVariables([string]$folder.Value)
13557        if ([string]::IsNullOrWhiteSpace($path)) { $path = 'Unknown' }
13558        $protected = $false
13559        foreach ($root in $roots) {
13560            if (-not [string]::IsNullOrWhiteSpace([string]$root) -and $path.ToLower().StartsWith($root.ToLower())) {
13561                $protected = $true
13562                break
13563            }
13564        }
13565        "$($folder.Name) | Path: $path | In OneDrive: $(if ($protected) { 'Yes' } else { 'No' })"
13566    }
13567} else {
13568    "Explorer shell folders unavailable"
13569}
13570"#;
13571    match run_powershell(ps_kfm) {
13572        Ok(o) if !o.trim().is_empty() => {
13573            for line in o.lines().take(max_entries) {
13574                let l = line.trim();
13575                if !l.is_empty() {
13576                    out.push_str(&format!("- {l}\n"));
13577                }
13578            }
13579        }
13580        _ => out.push_str("- Could not inspect Known Folder Backup state\n"),
13581    }
13582
13583    let mut findings: Vec<String> = Vec::new();
13584    if out.contains("Installed: Unknown") && !out.contains("Process: Running") {
13585        findings.push("OneDrive client installation could not be confirmed from standard paths in this session.".into());
13586    }
13587    if out.contains("No OneDrive accounts configured") {
13588        findings.push(
13589            "No OneDrive accounts are configured - sync cannot start until the user signs in."
13590                .into(),
13591        );
13592    }
13593    if out.contains("Process: Not running") && !out.contains("No OneDrive accounts configured") {
13594        findings.push("OneDrive accounts exist but the sync client is not running - sync may be paused until OneDrive starts.".into());
13595    }
13596    if out.contains("Exists: No") {
13597        findings.push("One or more configured OneDrive sync roots do not exist on disk - account linkage or folder redirection may be broken.".into());
13598    }
13599    if out.contains("DisableFileSyncNGSC=1") {
13600        findings
13601            .push("A OneDrive policy is disabling the sync client (DisableFileSyncNGSC=1).".into());
13602    }
13603    if out.contains("KFMBlockOptIn=1") {
13604        findings
13605            .push("A policy is blocking Known Folder Backup enrollment (KFMBlockOptIn=1).".into());
13606    }
13607    if out.contains("SyncRoot: C:\\") {
13608        let mut missing_kfm: Vec<&str> = Vec::new();
13609        for folder in ["Desktop", "Documents", "Pictures"] {
13610            if out.lines().any(|line| {
13611                line.contains(&format!("{folder} | Path:")) && line.contains("| In OneDrive: No")
13612            }) {
13613                missing_kfm.push(folder);
13614            }
13615        }
13616        if !missing_kfm.is_empty() {
13617            findings.push(format!(
13618                "Known Folder Backup is not protecting {} - those folders are outside the OneDrive sync root.",
13619                missing_kfm.join(", ")
13620            ));
13621        }
13622    }
13623
13624    let mut result = String::from("Host inspection: onedrive\n\n=== Findings ===\n");
13625    if findings.is_empty() {
13626        result.push_str("- No obvious OneDrive client, account, or policy blocker detected.\n");
13627    } else {
13628        for finding in &findings {
13629            result.push_str(&format!("- Finding: {finding}\n"));
13630        }
13631    }
13632    result.push('\n');
13633    result.push_str(&out);
13634    Ok(result)
13635}
13636
13637#[cfg(not(windows))]
13638fn inspect_onedrive(_max_entries: usize) -> Result<String, String> {
13639    Ok("Host inspection: onedrive\n\n=== Findings ===\n- OneDrive inspection is currently Windows-first. macOS/Linux support can be added later.\n".into())
13640}
13641
13642#[cfg(windows)]
13643fn inspect_browser_health(max_entries: usize) -> Result<String, String> {
13644    let mut out = String::from("=== Browser inventory ===\n");
13645
13646    let ps_inventory = r#"
13647$browsers = @(
13648    @{ Name='Edge'; Paths=@(
13649        (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'),
13650        (Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe')
13651    ); Profile=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data') },
13652    @{ Name='Chrome'; Paths=@(
13653        (Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe'),
13654        (Join-Path ${env:ProgramFiles(x86)} 'Google\Chrome\Application\chrome.exe'),
13655        (Join-Path $env:LOCALAPPDATA 'Google\Chrome\Application\chrome.exe')
13656    ); Profile=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data') },
13657    @{ Name='Firefox'; Paths=@(
13658        (Join-Path $env:ProgramFiles 'Mozilla Firefox\firefox.exe'),
13659        (Join-Path ${env:ProgramFiles(x86)} 'Mozilla Firefox\firefox.exe')
13660    ); Profile=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles') }
13661)
13662foreach ($browser in $browsers) {
13663    $exe = $browser.Paths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
13664    if ($exe) {
13665        $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
13666        $profileExists = if (Test-Path $browser.Profile) { 'Yes' } else { 'No' }
13667        "$($browser.Name) | Installed: Yes | Version: $version | Executable: $exe | ProfileRoot: $($browser.Profile) | ProfileExists: $profileExists"
13668    } else {
13669        "$($browser.Name) | Installed: No"
13670    }
13671}
13672$httpProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
13673$httpsProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
13674$startMenuInternet = (Get-ItemProperty 'HKLM:\SOFTWARE\Clients\StartMenuInternet' -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
13675"DefaultHTTP: $(if ($httpProgId) { $httpProgId } else { 'Unknown' })"
13676"DefaultHTTPS: $(if ($httpsProgId) { $httpsProgId } else { 'Unknown' })"
13677"StartMenuInternet: $(if ($startMenuInternet) { $startMenuInternet } else { 'Unknown' })"
13678"#;
13679    match run_powershell(ps_inventory) {
13680        Ok(o) if !o.trim().is_empty() => {
13681            for line in o.lines().take(max_entries + 6) {
13682                let l = line.trim();
13683                if !l.is_empty() {
13684                    out.push_str(&format!("- {l}\n"));
13685                }
13686            }
13687        }
13688        _ => out.push_str("- Could not inspect installed browser inventory\n"),
13689    }
13690
13691    out.push_str("\n=== Runtime state ===\n");
13692    let ps_runtime = r#"
13693$targets = 'msedge','chrome','firefox','msedgewebview2'
13694foreach ($name in $targets) {
13695    $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
13696    if ($procs) {
13697        $count = @($procs).Count
13698        $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
13699        "$name | Processes: $count | WorkingSetMB: $wsMb"
13700    } else {
13701        "$name | Processes: 0 | WorkingSetMB: 0"
13702    }
13703}
13704"#;
13705    match run_powershell(ps_runtime) {
13706        Ok(o) if !o.trim().is_empty() => {
13707            for line in o.lines().take(max_entries + 4) {
13708                let l = line.trim();
13709                if !l.is_empty() {
13710                    out.push_str(&format!("- {l}\n"));
13711                }
13712            }
13713        }
13714        _ => out.push_str("- Could not inspect browser runtime state\n"),
13715    }
13716
13717    out.push_str("\n=== WebView2 runtime ===\n");
13718    let ps_webview = r#"
13719$paths = @(
13720    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
13721    (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
13722) | Where-Object { $_ -and (Test-Path $_) }
13723$runtimeDir = $paths | ForEach-Object {
13724    Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
13725        Where-Object { $_.Name -match '^\d+\.' } |
13726        Sort-Object Name -Descending |
13727        Select-Object -First 1
13728} | Select-Object -First 1
13729if ($runtimeDir) {
13730    $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
13731    $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
13732    "Installed: Yes"
13733    "Version: $version"
13734    "Executable: $exe"
13735} else {
13736    "Installed: No"
13737}
13738$proc = Get-Process msedgewebview2 -ErrorAction SilentlyContinue
13739"ProcessCount: $(if ($proc) { @($proc).Count } else { 0 })"
13740"#;
13741    match run_powershell(ps_webview) {
13742        Ok(o) if !o.trim().is_empty() => {
13743            for line in o.lines().take(max_entries) {
13744                let l = line.trim();
13745                if !l.is_empty() {
13746                    out.push_str(&format!("- {l}\n"));
13747                }
13748            }
13749        }
13750        _ => out.push_str("- Could not inspect WebView2 runtime\n"),
13751    }
13752
13753    out.push_str("\n=== Policy and proxy surface ===\n");
13754    let ps_policy = r#"
13755$proxy = Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
13756$proxyEnabled = if ($null -ne $proxy.ProxyEnable) { $proxy.ProxyEnable } else { 'Unknown' }
13757$proxyServer = if ($proxy.ProxyServer) { $proxy.ProxyServer } else { 'Direct' }
13758$autoConfig = if ($proxy.AutoConfigURL) { $proxy.AutoConfigURL } else { 'None' }
13759$autoDetect = if ($null -ne $proxy.AutoDetect) { $proxy.AutoDetect } else { 'Unknown' }
13760"UserProxyEnabled: $proxyEnabled"
13761"UserProxyServer: $proxyServer"
13762"UserAutoConfigURL: $autoConfig"
13763"UserAutoDetect: $autoDetect"
13764$winhttp = (netsh winhttp show proxy 2>$null) -join ' '
13765if ($winhttp) {
13766    $normalized = ($winhttp -replace '\s+', ' ').Trim()
13767    $isDirect = $normalized -match 'Direct access \(no proxy server\)\.?$'
13768    "WinHTTPMode: $(if ($isDirect) { 'Direct' } else { 'Proxy' })"
13769    "WinHTTP: $normalized"
13770}
13771$policyTargets = @(
13772    @{ Name='Edge'; Path='HKLM:\SOFTWARE\Policies\Microsoft\Edge'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') },
13773    @{ Name='Chrome'; Path='HKLM:\SOFTWARE\Policies\Google\Chrome'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') }
13774)
13775foreach ($policy in $policyTargets) {
13776    if (Test-Path $policy.Path) {
13777        $item = Get-ItemProperty $policy.Path -ErrorAction SilentlyContinue
13778        foreach ($key in $policy.Keys) {
13779            $value = $item.$key
13780            if ($null -ne $value -and [string]$value -ne '') {
13781                if ($value -is [array]) {
13782                    "$($policy.Name)Policy | $key=$([string]::Join('; ', $value))"
13783                } else {
13784                    "$($policy.Name)Policy | $key=$value"
13785                }
13786            }
13787        }
13788    }
13789}
13790"#;
13791    match run_powershell(ps_policy) {
13792        Ok(o) if !o.trim().is_empty() => {
13793            for line in o.lines().take(max_entries + 8) {
13794                let l = line.trim();
13795                if !l.is_empty() {
13796                    out.push_str(&format!("- {l}\n"));
13797                }
13798            }
13799        }
13800        _ => out.push_str("- Could not inspect browser policy or proxy state\n"),
13801    }
13802
13803    out.push_str("\n=== Profile and cache pressure ===\n");
13804    let ps_profiles = r#"
13805$profiles = @(
13806    @{ Name='Edge'; Root=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data\Default\Extensions') },
13807    @{ Name='Chrome'; Root=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data\Default\Extensions') },
13808    @{ Name='Firefox'; Root=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles'); ExtensionRoot=$null }
13809)
13810foreach ($profile in $profiles) {
13811    if (Test-Path $profile.Root) {
13812        if ($profile.Name -eq 'Firefox') {
13813            $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue
13814        } else {
13815            $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue |
13816                Where-Object {
13817                    $_.Name -eq 'Default' -or
13818                    $_.Name -eq 'Guest Profile' -or
13819                    $_.Name -eq 'System Profile' -or
13820                    $_.Name -like 'Profile *'
13821                }
13822        }
13823        $profileCount = @($dirs).Count
13824        $sizeBytes = (Get-ChildItem $profile.Root -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
13825        if (-not $sizeBytes) { $sizeBytes = 0 }
13826        $sizeGb = [Math]::Round(($sizeBytes / 1GB), 2)
13827        $extCount = 'Unknown'
13828        if ($profile.ExtensionRoot -and (Test-Path $profile.ExtensionRoot)) {
13829            $extCount = @((Get-ChildItem $profile.ExtensionRoot -Directory -ErrorAction SilentlyContinue)).Count
13830        }
13831        "$($profile.Name) | ProfileRoot: $($profile.Root) | Profiles: $profileCount | SizeGB: $sizeGb | Extensions: $extCount"
13832    } else {
13833        "$($profile.Name) | ProfileRoot: Missing"
13834    }
13835}
13836"#;
13837    match run_powershell(ps_profiles) {
13838        Ok(o) if !o.trim().is_empty() => {
13839            for line in o.lines().take(max_entries + 4) {
13840                let l = line.trim();
13841                if !l.is_empty() {
13842                    out.push_str(&format!("- {l}\n"));
13843                }
13844            }
13845        }
13846        _ => out.push_str("- Could not inspect browser profile pressure\n"),
13847    }
13848
13849    out.push_str("\n=== Recent browser failures (7d) ===\n");
13850    let ps_failures = r#"
13851$cutoff = (Get-Date).AddDays(-7)
13852$targets = 'chrome.exe','msedge.exe','firefox.exe','msedgewebview2.exe'
13853$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 250 -ErrorAction SilentlyContinue |
13854    Where-Object {
13855        $msg = [string]$_.Message
13856        ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
13857        ($targets | Where-Object { $msg.ToLower().Contains($_.ToLower()) })
13858    } |
13859    Select-Object -First 6
13860if ($events) {
13861    foreach ($event in $events) {
13862        $msg = ($event.Message -replace '\s+', ' ')
13863        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
13864        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
13865    }
13866} else {
13867    "No recent browser crash or WER events detected"
13868}
13869"#;
13870    match run_powershell(ps_failures) {
13871        Ok(o) if !o.trim().is_empty() => {
13872            for line in o.lines().take(max_entries + 2) {
13873                let l = line.trim();
13874                if !l.is_empty() {
13875                    out.push_str(&format!("- {l}\n"));
13876                }
13877            }
13878        }
13879        _ => out.push_str("- Could not inspect recent browser failure events\n"),
13880    }
13881
13882    let mut findings: Vec<String> = Vec::new();
13883    if out.contains("Edge | Installed: No")
13884        && out.contains("Chrome | Installed: No")
13885        && out.contains("Firefox | Installed: No")
13886    {
13887        findings.push(
13888            "No supported browser install was detected from the standard Edge/Chrome/Firefox paths."
13889                .into(),
13890        );
13891    }
13892    if out.contains("DefaultHTTP: Unknown") || out.contains("DefaultHTTPS: Unknown") {
13893        findings.push(
13894            "Default browser or protocol associations could not be read cleanly - links may open inconsistently."
13895                .into(),
13896        );
13897    }
13898    if out.contains("UserProxyEnabled: 1") || out.contains("WinHTTPMode: Proxy") {
13899        findings.push(
13900            "Proxy settings are active for this user or machine - browser sign-in and web-app failures may be proxy or PAC related."
13901                .into(),
13902        );
13903    }
13904    if out.contains("EdgePolicy | Proxy")
13905        || out.contains("ChromePolicy | Proxy")
13906        || out.contains("ExtensionInstallForcelist=")
13907    {
13908        findings.push(
13909            "Browser policy overrides are present - forced proxy or extension policy may be influencing web-app behavior."
13910                .into(),
13911        );
13912    }
13913    for browser in ["msedge", "chrome", "firefox"] {
13914        let process_marker = format!("{browser} | Processes: ");
13915        if let Some(line) = out.lines().find(|line| line.contains(&process_marker)) {
13916            let count = line
13917                .split("| Processes: ")
13918                .nth(1)
13919                .and_then(|rest| rest.split(" |").next())
13920                .and_then(|value| value.trim().parse::<usize>().ok())
13921                .unwrap_or(0);
13922            let ws_mb = line
13923                .split("| WorkingSetMB: ")
13924                .nth(1)
13925                .and_then(|value| value.trim().parse::<f64>().ok())
13926                .unwrap_or(0.0);
13927            if count >= 25 {
13928                findings.push(format!(
13929                    "{browser} is running {count} processes - extension or tab pressure may be dragging browser responsiveness."
13930                ));
13931            } else if ws_mb >= 2500.0 {
13932                findings.push(format!(
13933                    "{browser} is consuming {ws_mb:.1} MB of working set - browser memory pressure may be driving slowness or tab crashes."
13934                ));
13935            }
13936        }
13937    }
13938    if out.contains("=== WebView2 runtime ===\n- Installed: No")
13939        || (out.contains("=== WebView2 runtime ===")
13940            && out.contains("- Installed: No")
13941            && out.contains("- ProcessCount: 0"))
13942    {
13943        findings.push(
13944            "WebView2 runtime is missing - modern Windows apps that embed Edge web content may fail or render badly."
13945                .into(),
13946        );
13947    }
13948    for browser in ["Edge", "Chrome", "Firefox"] {
13949        let prefix = format!("{browser} | ProfileRoot:");
13950        if let Some(line) = out.lines().find(|line| line.contains(&prefix)) {
13951            let size_gb = line
13952                .split("| SizeGB: ")
13953                .nth(1)
13954                .and_then(|rest| rest.split(" |").next())
13955                .and_then(|value| value.trim().parse::<f64>().ok())
13956                .unwrap_or(0.0);
13957            let ext_count = line
13958                .split("| Extensions: ")
13959                .nth(1)
13960                .and_then(|value| value.trim().parse::<usize>().ok())
13961                .unwrap_or(0);
13962            if size_gb >= 2.5 {
13963                findings.push(format!(
13964                    "{browser} profile data is {size_gb:.2} GB - cache or profile bloat may be hurting startup and web-app responsiveness."
13965                ));
13966            }
13967            if ext_count >= 20 {
13968                findings.push(format!(
13969                    "{browser} has {ext_count} extensions in the default profile - extension overload can slow page loads and trigger conflicts."
13970                ));
13971            }
13972        }
13973    }
13974    if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
13975        findings.push(
13976            "Recent browser crash evidence was found in the Application log - review the failure lines below for the browser or helper process that is faulting."
13977                .into(),
13978        );
13979    }
13980
13981    let mut result = String::from("Host inspection: browser_health\n\n=== Findings ===\n");
13982    if findings.is_empty() {
13983        result.push_str("- No obvious browser, proxy, or WebView2 health blocker detected.\n");
13984    } else {
13985        for finding in &findings {
13986            result.push_str(&format!("- Finding: {finding}\n"));
13987        }
13988    }
13989    result.push('\n');
13990    result.push_str(&out);
13991    Ok(result)
13992}
13993
13994#[cfg(not(windows))]
13995fn inspect_browser_health(_max_entries: usize) -> Result<String, String> {
13996    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())
13997}
13998
13999#[cfg(windows)]
14000fn inspect_outlook(max_entries: usize) -> Result<String, String> {
14001    let mut out = String::from("=== Outlook install inventory ===\n");
14002
14003    let ps_install = r#"
14004$installPaths = @(
14005    (Join-Path $env:ProgramFiles 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
14006    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
14007    (Join-Path $env:ProgramFiles 'Microsoft Office\Office16\OUTLOOK.EXE'),
14008    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office16\OUTLOOK.EXE'),
14009    (Join-Path $env:ProgramFiles 'Microsoft Office\Office15\OUTLOOK.EXE'),
14010    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office15\OUTLOOK.EXE')
14011)
14012$exe = $installPaths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14013if ($exe) {
14014    $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
14015    $productName = try { (Get-Item $exe).VersionInfo.ProductName } catch { 'Unknown' }
14016    "Installed: Yes"
14017    "Executable: $exe"
14018    "Version: $version"
14019    "Product: $productName"
14020} else {
14021    "Installed: No"
14022}
14023$newOutlook = Get-AppxPackage -Name 'Microsoft.OutlookForWindows' -ErrorAction SilentlyContinue
14024if ($newOutlook) {
14025    "NewOutlook: Installed | Version: $($newOutlook.Version)"
14026} else {
14027    "NewOutlook: Not installed"
14028}
14029"#;
14030    match run_powershell(ps_install) {
14031        Ok(o) if !o.trim().is_empty() => {
14032            for line in o.lines().take(max_entries + 4) {
14033                let l = line.trim();
14034                if !l.is_empty() {
14035                    out.push_str(&format!("- {l}\n"));
14036                }
14037            }
14038        }
14039        _ => out.push_str("- Could not inspect Outlook install paths\n"),
14040    }
14041
14042    out.push_str("\n=== Runtime state ===\n");
14043    let ps_runtime = r#"
14044$proc = Get-Process OUTLOOK -ErrorAction SilentlyContinue
14045if ($proc) {
14046    $count = @($proc).Count
14047    $wsMb = [Math]::Round((($proc | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14048    $cpuPct = try { [Math]::Round(($proc | Measure-Object CPU -Sum).Sum, 1) } catch { 0 }
14049    "Running: Yes | ProcessCount: $count | WorkingSetMB: $wsMb | CPUSeconds: $cpuPct"
14050} else {
14051    "Running: No"
14052}
14053"#;
14054    match run_powershell(ps_runtime) {
14055        Ok(o) if !o.trim().is_empty() => {
14056            for line in o.lines().take(4) {
14057                let l = line.trim();
14058                if !l.is_empty() {
14059                    out.push_str(&format!("- {l}\n"));
14060                }
14061            }
14062        }
14063        _ => out.push_str("- Could not inspect Outlook runtime state\n"),
14064    }
14065
14066    out.push_str("\n=== Mail profiles ===\n");
14067    let ps_profiles = r#"
14068$profileKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Profiles'
14069if (-not (Test-Path $profileKey)) {
14070    $profileKey = 'HKCU:\Software\Microsoft\Office\15.0\Outlook\Profiles'
14071}
14072if (Test-Path $profileKey) {
14073    $profiles = Get-ChildItem $profileKey -ErrorAction SilentlyContinue
14074    $count = @($profiles).Count
14075    "ProfileCount: $count"
14076    foreach ($p in $profiles | Select-Object -First 10) {
14077        "Profile: $($p.PSChildName)"
14078    }
14079} else {
14080    "ProfileCount: 0"
14081    "No Outlook profiles found in registry"
14082}
14083"#;
14084    match run_powershell(ps_profiles) {
14085        Ok(o) if !o.trim().is_empty() => {
14086            for line in o.lines().take(max_entries + 2) {
14087                let l = line.trim();
14088                if !l.is_empty() {
14089                    out.push_str(&format!("- {l}\n"));
14090                }
14091            }
14092        }
14093        _ => out.push_str("- Could not inspect Outlook mail profiles\n"),
14094    }
14095
14096    out.push_str("\n=== OST and PST data files ===\n");
14097    let ps_datafiles = r#"
14098$searchRoots = @(
14099    (Join-Path $env:LOCALAPPDATA 'Microsoft\Outlook'),
14100    (Join-Path $env:USERPROFILE 'Documents'),
14101    (Join-Path $env:USERPROFILE 'OneDrive\Documents')
14102) | Where-Object { $_ -and (Test-Path $_) }
14103$files = foreach ($root in $searchRoots) {
14104    Get-ChildItem $root -Include '*.ost','*.pst' -Recurse -ErrorAction SilentlyContinue -Force |
14105        Select-Object FullName,
14106            @{N='SizeMB';E={[Math]::Round($_.Length/1MB,1)}},
14107            @{N='Type';E={$_.Extension.TrimStart('.').ToUpper()}},
14108            LastWriteTime
14109}
14110if ($files) {
14111    foreach ($f in ($files | Sort-Object SizeMB -Descending | Select-Object -First 12)) {
14112        "$($f.Type) | $($f.FullName) | SizeMB: $($f.SizeMB) | LastWrite: $($f.LastWriteTime.ToString('yyyy-MM-dd'))"
14113    }
14114} else {
14115    "No OST or PST files found in standard locations"
14116}
14117"#;
14118    match run_powershell(ps_datafiles) {
14119        Ok(o) if !o.trim().is_empty() => {
14120            for line in o.lines().take(max_entries + 4) {
14121                let l = line.trim();
14122                if !l.is_empty() {
14123                    out.push_str(&format!("- {l}\n"));
14124                }
14125            }
14126        }
14127        _ => out.push_str("- Could not inspect OST/PST data files\n"),
14128    }
14129
14130    out.push_str("\n=== Add-in pressure ===\n");
14131    let ps_addins = r#"
14132$addinPaths = @(
14133    'HKLM:\SOFTWARE\Microsoft\Office\Outlook\Addins',
14134    'HKCU:\SOFTWARE\Microsoft\Office\Outlook\Addins',
14135    'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Office\Outlook\Addins'
14136)
14137$addins = foreach ($path in $addinPaths) {
14138    if (Test-Path $path) {
14139        Get-ChildItem $path -ErrorAction SilentlyContinue | ForEach-Object {
14140            $item = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
14141            $loadBehavior = $item.LoadBehavior
14142            $desc = if ($item.Description) { $item.Description } else { $_.PSChildName }
14143            [PSCustomObject]@{ Name=$desc; LoadBehavior=$loadBehavior; Key=$_.PSChildName }
14144        }
14145    }
14146}
14147$enabledCount = ($addins | Where-Object { $_.LoadBehavior -band 1 }).Count
14148$disabledCount = ($addins | Where-Object { $_.LoadBehavior -eq 0 }).Count
14149"TotalAddins: $(@($addins).Count) | Active: $enabledCount | Disabled: $disabledCount"
14150foreach ($a in ($addins | Sort-Object LoadBehavior -Descending | Select-Object -First 15)) {
14151    $state = switch ($a.LoadBehavior) {
14152        0 { 'Disabled' }
14153        2 { 'LoadOnStart(inactive)' }
14154        3 { 'ActiveOnStart' }
14155        8 { 'DemandLoad' }
14156        9 { 'ActiveDemand' }
14157        16 { 'ConnectedFirst' }
14158        default { "LoadBehavior=$($a.LoadBehavior)" }
14159    }
14160    "$($a.Name) | $state"
14161}
14162$crashedKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DoNotDisableAddinList'
14163$disabledByResiliency = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DisabledItems'
14164if (Test-Path $disabledByResiliency) {
14165    $dis = Get-ItemProperty $disabledByResiliency -ErrorAction SilentlyContinue
14166    $count = ($dis.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' }).Count
14167    if ($count -gt 0) { "ResiliencyDisabledItems: $count (add-ins crashed and were auto-disabled)" }
14168}
14169"#;
14170    match run_powershell(ps_addins) {
14171        Ok(o) if !o.trim().is_empty() => {
14172            for line in o.lines().take(max_entries + 8) {
14173                let l = line.trim();
14174                if !l.is_empty() {
14175                    out.push_str(&format!("- {l}\n"));
14176                }
14177            }
14178        }
14179        _ => out.push_str("- Could not inspect Outlook add-ins\n"),
14180    }
14181
14182    out.push_str("\n=== Authentication and cache friction ===\n");
14183    let ps_auth = r#"
14184$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14185$tokenCount = if (Test-Path $tokenCache) {
14186    @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
14187} else { 0 }
14188"TokenBrokerCacheFiles: $tokenCount"
14189$credentialManager = cmdkey /list 2>&1 | Select-String 'MicrosoftOffice|ADALCache|microsoftoffice|MsoOpenIdConnect'
14190$credsCount = @($credentialManager).Count
14191"OfficeCredentialsInVault: $credsCount"
14192$samlKey = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14193if (Test-Path $samlKey) {
14194    $id = Get-ItemProperty $samlKey -ErrorAction SilentlyContinue
14195    $connected = if ($id.ConnectedAccountWamOverride) { $id.ConnectedAccountWamOverride } else { 'Unknown' }
14196    $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
14197    "WAMOverride: $connected"
14198    "SignedInUserId: $signedIn"
14199}
14200$outlookReg = 'HKCU:\Software\Microsoft\Office\16.0\Outlook'
14201if (Test-Path $outlookReg) {
14202    $olk = Get-ItemProperty $outlookReg -ErrorAction SilentlyContinue
14203    if ($olk.DisableMAPI) { "DisableMAPI: $($olk.DisableMAPI)" }
14204}
14205"#;
14206    match run_powershell(ps_auth) {
14207        Ok(o) if !o.trim().is_empty() => {
14208            for line in o.lines().take(max_entries + 4) {
14209                let l = line.trim();
14210                if !l.is_empty() {
14211                    out.push_str(&format!("- {l}\n"));
14212                }
14213            }
14214        }
14215        _ => out.push_str("- Could not inspect Outlook auth state\n"),
14216    }
14217
14218    out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
14219    let ps_events = r#"
14220$cutoff = (Get-Date).AddDays(-7)
14221$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14222    Where-Object {
14223        $msg = [string]$_.Message
14224        ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting' -or $_.ProviderName -eq 'Outlook') -and
14225        ($msg.ToLower().Contains('outlook') -or $msg.ToLower().Contains('mso.dll') -or $msg.ToLower().Contains('outllib.dll') -or $msg.ToLower().Contains('olmapi32.dll'))
14226    } |
14227    Select-Object -First 8
14228if ($events) {
14229    foreach ($event in $events) {
14230        $msg = ($event.Message -replace '\s+', ' ')
14231        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14232        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14233    }
14234} else {
14235    "No recent Outlook crash or error events detected in Application log"
14236}
14237"#;
14238    match run_powershell(ps_events) {
14239        Ok(o) if !o.trim().is_empty() => {
14240            for line in o.lines().take(max_entries + 4) {
14241                let l = line.trim();
14242                if !l.is_empty() {
14243                    out.push_str(&format!("- {l}\n"));
14244                }
14245            }
14246        }
14247        _ => out.push_str("- Could not inspect Outlook event log evidence\n"),
14248    }
14249
14250    let mut findings: Vec<String> = Vec::new();
14251
14252    if out.contains("- Installed: No") && out.contains("- NewOutlook: Not installed") {
14253        findings.push(
14254            "Outlook is not installed — neither classic Office nor the new Outlook for Windows was found."
14255                .into(),
14256        );
14257    }
14258
14259    if let Some(line) = out.lines().find(|l| l.contains("WorkingSetMB:")) {
14260        let ws_mb = line
14261            .split("WorkingSetMB: ")
14262            .nth(1)
14263            .and_then(|r| r.split(" |").next())
14264            .and_then(|v| v.trim().parse::<f64>().ok())
14265            .unwrap_or(0.0);
14266        if ws_mb >= 1500.0 {
14267            findings.push(format!(
14268                "Outlook is consuming {ws_mb:.0} MB of RAM — add-in pressure, large OST files, or a corrupt profile may be driving memory growth."
14269            ));
14270        }
14271    }
14272
14273    let large_ost: Vec<String> = out
14274        .lines()
14275        .filter(|l| l.contains("SizeMB:") && l.contains("OST"))
14276        .filter_map(|l| {
14277            let mb = l
14278                .split("SizeMB: ")
14279                .nth(1)
14280                .and_then(|r| r.split(" |").next())
14281                .and_then(|v| v.trim().parse::<f64>().ok())
14282                .unwrap_or(0.0);
14283            if mb >= 10_000.0 {
14284                Some(format!("{mb:.0} MB OST file detected"))
14285            } else {
14286                None
14287            }
14288        })
14289        .collect();
14290    for msg in large_ost {
14291        findings.push(format!(
14292            "{msg} — large OST files can cause Outlook slowness, send/receive delays, and search index rebuild time."
14293        ));
14294    }
14295
14296    if let Some(line) = out.lines().find(|l| l.contains("TotalAddins:")) {
14297        let active_count = line
14298            .split("Active: ")
14299            .nth(1)
14300            .and_then(|r| r.split(" |").next())
14301            .and_then(|v| v.trim().parse::<usize>().ok())
14302            .unwrap_or(0);
14303        if active_count >= 8 {
14304            findings.push(format!(
14305                "{active_count} active Outlook add-ins detected — add-in overload is a common cause of slow Outlook startup, freezes, and crashes."
14306            ));
14307        }
14308    }
14309
14310    if out.contains("ResiliencyDisabledItems:") {
14311        findings.push(
14312            "Outlook's crash resiliency has auto-disabled one or more add-ins — look at the ResiliencyDisabledItems count and remove the offending add-in."
14313                .into(),
14314        );
14315    }
14316
14317    if out.contains("- ProfileCount: 0") || out.contains("- No Outlook profiles found") {
14318        findings.push(
14319            "No Outlook mail profiles were found in the registry — Outlook may not have been set up, or the profile may be corrupt."
14320                .into(),
14321        );
14322    }
14323
14324    if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14325        findings.push(
14326            "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)."
14327                .into(),
14328        );
14329    }
14330
14331    let mut result = String::from("Host inspection: outlook\n\n=== Findings ===\n");
14332    if findings.is_empty() {
14333        result.push_str("- No obvious Outlook health blocker detected.\n");
14334    } else {
14335        for finding in &findings {
14336            result.push_str(&format!("- Finding: {finding}\n"));
14337        }
14338    }
14339    result.push('\n');
14340    result.push_str(&out);
14341    Ok(result)
14342}
14343
14344#[cfg(not(windows))]
14345fn inspect_outlook(_max_entries: usize) -> Result<String, String> {
14346    Ok("Host inspection: outlook\n\n=== Findings ===\n- Outlook health inspection is Windows-only.\n".into())
14347}
14348
14349#[cfg(windows)]
14350fn inspect_teams(max_entries: usize) -> Result<String, String> {
14351    let mut out = String::from("=== Teams install inventory ===\n");
14352
14353    let ps_install = r#"
14354# Classic Teams (Teams 1.0)
14355$classicExe = @(
14356    (Join-Path $env:LOCALAPPDATA 'Microsoft\Teams\current\Teams.exe'),
14357    (Join-Path $env:ProgramFiles 'Microsoft\Teams\current\Teams.exe')
14358) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14359
14360if ($classicExe) {
14361    $ver = try { (Get-Item $classicExe).VersionInfo.FileVersion } catch { 'Unknown' }
14362    "ClassicTeams: Installed | Version: $ver | Path: $classicExe"
14363} else {
14364    "ClassicTeams: Not installed"
14365}
14366
14367# New Teams (Teams 2.0 / ms-teams.exe)
14368$newTeamsExe = @(
14369    (Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\ms-teams.exe'),
14370    (Join-Path $env:ProgramFiles 'WindowsApps\MSTeams_*\ms-teams.exe')
14371) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14372
14373$newTeamsPkg = Get-AppxPackage -Name 'MSTeams' -ErrorAction SilentlyContinue
14374if ($newTeamsPkg) {
14375    "NewTeams: Installed | Version: $($newTeamsPkg.Version) | PackageName: $($newTeamsPkg.PackageFullName)"
14376} elseif ($newTeamsExe) {
14377    $ver = try { (Get-Item $newTeamsExe).VersionInfo.FileVersion } catch { 'Unknown' }
14378    "NewTeams: Installed | Version: $ver | Path: $newTeamsExe"
14379} else {
14380    "NewTeams: Not installed"
14381}
14382
14383# Teams Machine-Wide Installer (MSI/per-machine)
14384$mwi = Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue |
14385    Where-Object { $_.DisplayName -like 'Teams Machine-Wide Installer*' } |
14386    Select-Object -First 1
14387if ($mwi) {
14388    "MachineWideInstaller: Installed | Version: $($mwi.DisplayVersion)"
14389} else {
14390    "MachineWideInstaller: Not found"
14391}
14392"#;
14393    match run_powershell(ps_install) {
14394        Ok(o) if !o.trim().is_empty() => {
14395            for line in o.lines().take(max_entries + 4) {
14396                let l = line.trim();
14397                if !l.is_empty() {
14398                    out.push_str(&format!("- {l}\n"));
14399                }
14400            }
14401        }
14402        _ => out.push_str("- Could not inspect Teams install paths\n"),
14403    }
14404
14405    out.push_str("\n=== Runtime state ===\n");
14406    let ps_runtime = r#"
14407$targets = @('Teams','ms-teams')
14408foreach ($name in $targets) {
14409    $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
14410    if ($procs) {
14411        $count = @($procs).Count
14412        $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14413        "$name | Running: Yes | Processes: $count | WorkingSetMB: $wsMb"
14414    } else {
14415        "$name | Running: No"
14416    }
14417}
14418"#;
14419    match run_powershell(ps_runtime) {
14420        Ok(o) if !o.trim().is_empty() => {
14421            for line in o.lines().take(6) {
14422                let l = line.trim();
14423                if !l.is_empty() {
14424                    out.push_str(&format!("- {l}\n"));
14425                }
14426            }
14427        }
14428        _ => out.push_str("- Could not inspect Teams runtime state\n"),
14429    }
14430
14431    out.push_str("\n=== Cache directory sizing ===\n");
14432    let ps_cache = r#"
14433$cachePaths = @(
14434    @{ Name='ClassicTeamsCache'; Path=(Join-Path $env:APPDATA 'Microsoft\Teams') },
14435    @{ Name='ClassicTeamsSquirrel'; Path=(Join-Path $env:LOCALAPPDATA 'Microsoft\Teams') },
14436    @{ Name='NewTeamsCache'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams') },
14437    @{ Name='NewTeamsAppData'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe') }
14438)
14439foreach ($entry in $cachePaths) {
14440    if (Test-Path $entry.Path) {
14441        $sizeBytes = (Get-ChildItem $entry.Path -Recurse -File -ErrorAction SilentlyContinue -Force | Measure-Object Length -Sum).Sum
14442        if (-not $sizeBytes) { $sizeBytes = 0 }
14443        $sizeMb = [Math]::Round($sizeBytes / 1MB, 1)
14444        "$($entry.Name) | Path: $($entry.Path) | SizeMB: $sizeMb"
14445    } else {
14446        "$($entry.Name) | Path: $($entry.Path) | Not found"
14447    }
14448}
14449"#;
14450    match run_powershell(ps_cache) {
14451        Ok(o) if !o.trim().is_empty() => {
14452            for line in o.lines().take(max_entries + 4) {
14453                let l = line.trim();
14454                if !l.is_empty() {
14455                    out.push_str(&format!("- {l}\n"));
14456                }
14457            }
14458        }
14459        _ => out.push_str("- Could not inspect Teams cache directories\n"),
14460    }
14461
14462    out.push_str("\n=== WebView2 runtime ===\n");
14463    let ps_webview = r#"
14464$paths = @(
14465    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
14466    (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
14467) | Where-Object { $_ -and (Test-Path $_) }
14468$runtimeDir = $paths | ForEach-Object {
14469    Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
14470        Where-Object { $_.Name -match '^\d+\.' } |
14471        Sort-Object Name -Descending |
14472        Select-Object -First 1
14473} | Select-Object -First 1
14474if ($runtimeDir) {
14475    $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14476    $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14477    "Installed: Yes | Version: $version"
14478} else {
14479    "Installed: No -- New Teams and some Office features require WebView2"
14480}
14481"#;
14482    match run_powershell(ps_webview) {
14483        Ok(o) if !o.trim().is_empty() => {
14484            for line in o.lines().take(4) {
14485                let l = line.trim();
14486                if !l.is_empty() {
14487                    out.push_str(&format!("- {l}\n"));
14488                }
14489            }
14490        }
14491        _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14492    }
14493
14494    out.push_str("\n=== Account and sign-in state ===\n");
14495    let ps_auth = r#"
14496# Classic Teams account registry
14497$classicAcct = 'HKCU:\Software\Microsoft\Office\Teams'
14498if (Test-Path $classicAcct) {
14499    $item = Get-ItemProperty $classicAcct -ErrorAction SilentlyContinue
14500    $email = if ($item.HomeUserUpn) { $item.HomeUserUpn } elseif ($item.LoggedInEmail) { $item.LoggedInEmail } else { 'Unknown' }
14501    "ClassicTeamsAccount: $email"
14502} else {
14503    "ClassicTeamsAccount: Not configured"
14504}
14505# WAM / token broker state for Teams
14506$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14507$tokenCount = if (Test-Path $tokenCache) {
14508    @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
14509} else { 0 }
14510"TokenBrokerCacheFiles: $tokenCount"
14511# Office identity
14512$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14513if (Test-Path $officeId) {
14514    $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
14515    $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
14516    "OfficeSignedInUserId: $signedIn"
14517}
14518# Check if Teams is in startup
14519$startupKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
14520$teamsRun = (Get-ItemProperty $startupKey -ErrorAction SilentlyContinue) | Select-Object -ExpandProperty 'com.squirrel.Teams.Teams' -ErrorAction SilentlyContinue
14521"TeamsInStartup: $(if ($teamsRun) { 'Yes (Classic)' } else { 'Not in user run key' })"
14522"#;
14523    match run_powershell(ps_auth) {
14524        Ok(o) if !o.trim().is_empty() => {
14525            for line in o.lines().take(max_entries + 4) {
14526                let l = line.trim();
14527                if !l.is_empty() {
14528                    out.push_str(&format!("- {l}\n"));
14529                }
14530            }
14531        }
14532        _ => out.push_str("- Could not inspect Teams account state\n"),
14533    }
14534
14535    out.push_str("\n=== Audio and video device binding ===\n");
14536    let ps_devices = r#"
14537# Teams stores device prefs in the settings file
14538$settingsPaths = @(
14539    (Join-Path $env:APPDATA 'Microsoft\Teams\desktop-config.json'),
14540    (Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams\app_settings.json')
14541)
14542$found = $false
14543foreach ($sp in $settingsPaths) {
14544    if (Test-Path $sp) {
14545        $found = $true
14546        $raw = try { Get-Content $sp -Raw -ErrorAction SilentlyContinue } catch { $null }
14547        if ($raw) {
14548            $json = try { $raw | ConvertFrom-Json -ErrorAction SilentlyContinue } catch { $null }
14549            if ($json) {
14550                $mic = if ($json.currentAudioDevice) { $json.currentAudioDevice } elseif ($json.audioDevice) { $json.audioDevice } else { 'Default' }
14551                $spk = if ($json.currentSpeakerDevice) { $json.currentSpeakerDevice } elseif ($json.speakerDevice) { $json.speakerDevice } else { 'Default' }
14552                $cam = if ($json.currentVideoDevice) { $json.currentVideoDevice } elseif ($json.videoDevice) { $json.videoDevice } else { 'Default' }
14553                "ConfigFile: $sp"
14554                "Microphone: $mic"
14555                "Speaker: $spk"
14556                "Camera: $cam"
14557            } else {
14558                "ConfigFile: $sp (not parseable as JSON)"
14559            }
14560        } else {
14561            "ConfigFile: $sp (empty)"
14562        }
14563        break
14564    }
14565}
14566if (-not $found) {
14567    "NoTeamsConfigFile: Teams device prefs not found -- Teams may not have been launched yet or uses system defaults"
14568}
14569"#;
14570    match run_powershell(ps_devices) {
14571        Ok(o) if !o.trim().is_empty() => {
14572            for line in o.lines().take(max_entries + 4) {
14573                let l = line.trim();
14574                if !l.is_empty() {
14575                    out.push_str(&format!("- {l}\n"));
14576                }
14577            }
14578        }
14579        _ => out.push_str("- Could not inspect Teams device binding\n"),
14580    }
14581
14582    out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
14583    let ps_events = r#"
14584$cutoff = (Get-Date).AddDays(-7)
14585$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14586    Where-Object {
14587        $msg = [string]$_.Message
14588        ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
14589        ($msg.ToLower().Contains('teams') -or $msg.ToLower().Contains('ms-teams') -or $msg.ToLower().Contains('msteams'))
14590    } |
14591    Select-Object -First 8
14592if ($events) {
14593    foreach ($event in $events) {
14594        $msg = ($event.Message -replace '\s+', ' ')
14595        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14596        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14597    }
14598} else {
14599    "No recent Teams crash or error events detected in Application log"
14600}
14601"#;
14602    match run_powershell(ps_events) {
14603        Ok(o) if !o.trim().is_empty() => {
14604            for line in o.lines().take(max_entries + 4) {
14605                let l = line.trim();
14606                if !l.is_empty() {
14607                    out.push_str(&format!("- {l}\n"));
14608                }
14609            }
14610        }
14611        _ => out.push_str("- Could not inspect Teams event log evidence\n"),
14612    }
14613
14614    let mut findings: Vec<String> = Vec::new();
14615
14616    let classic_installed = out.contains("- ClassicTeams: Installed");
14617    let new_installed = out.contains("- NewTeams: Installed");
14618    if !classic_installed && !new_installed {
14619        findings.push("Neither classic Teams nor new Teams is installed on this machine.".into());
14620    }
14621
14622    for name in ["Teams", "ms-teams"] {
14623        let marker = format!("{name} | Running: Yes | Processes:");
14624        if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
14625            let ws_mb = line
14626                .split("WorkingSetMB: ")
14627                .nth(1)
14628                .and_then(|v| v.trim().parse::<f64>().ok())
14629                .unwrap_or(0.0);
14630            if ws_mb >= 1000.0 {
14631                findings.push(format!(
14632                    "{name} is consuming {ws_mb:.0} MB of RAM — cache bloat or a large number of channels/meetings loaded may be driving memory growth."
14633                ));
14634            }
14635        }
14636    }
14637
14638    for (label, threshold_mb) in [
14639        ("ClassicTeamsCache", 500.0_f64),
14640        ("ClassicTeamsSquirrel", 2000.0),
14641        ("NewTeamsCache", 500.0),
14642        ("NewTeamsAppData", 3000.0),
14643    ] {
14644        let marker = format!("{label} |");
14645        if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
14646            let mb = line
14647                .split("SizeMB: ")
14648                .nth(1)
14649                .and_then(|v| v.trim().parse::<f64>().ok())
14650                .unwrap_or(0.0);
14651            if mb >= threshold_mb {
14652                findings.push(format!(
14653                    "{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."
14654                ));
14655            }
14656        }
14657    }
14658
14659    if out.contains("- Installed: No -- New Teams") {
14660        findings.push(
14661            "WebView2 runtime is missing — new Teams requires WebView2 for rendering. Install it from Microsoft's WebView2 page or via winget install Microsoft.EdgeWebView2Runtime."
14662                .into(),
14663        );
14664    }
14665
14666    if out.contains("- ClassicTeamsAccount: Not configured")
14667        && out.contains("- OfficeSignedInUserId: None")
14668    {
14669        findings.push(
14670            "No Teams account is configured and Office sign-in is absent — Teams will fail to load meetings or channels until the user signs in."
14671                .into(),
14672        );
14673    }
14674
14675    if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14676        findings.push(
14677            "Recent Teams crash evidence found in the Application event log — check the event lines below for the faulting module."
14678                .into(),
14679        );
14680    }
14681
14682    let mut result = String::from("Host inspection: teams\n\n=== Findings ===\n");
14683    if findings.is_empty() {
14684        result.push_str("- No obvious Teams health blocker detected.\n");
14685    } else {
14686        for finding in &findings {
14687            result.push_str(&format!("- Finding: {finding}\n"));
14688        }
14689    }
14690    result.push('\n');
14691    result.push_str(&out);
14692    Ok(result)
14693}
14694
14695#[cfg(not(windows))]
14696fn inspect_teams(_max_entries: usize) -> Result<String, String> {
14697    Ok(
14698        "Host inspection: teams\n\n=== Findings ===\n- Teams health inspection is Windows-only.\n"
14699            .into(),
14700    )
14701}
14702
14703#[cfg(windows)]
14704fn inspect_identity_auth(max_entries: usize) -> Result<String, String> {
14705    let mut out = String::from("=== Identity broker services ===\n");
14706
14707    let ps_services = r#"
14708$serviceNames = 'TokenBroker','wlidsvc','OneAuth'
14709foreach ($name in $serviceNames) {
14710    $svc = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
14711    if ($svc) {
14712        "$($svc.Name) | Status: $($svc.State) | StartMode: $($svc.StartMode)"
14713    } else {
14714        "$name | Not found"
14715    }
14716}
14717"#;
14718    match run_powershell(ps_services) {
14719        Ok(o) if !o.trim().is_empty() => {
14720            for line in o.lines().take(max_entries) {
14721                let l = line.trim();
14722                if !l.is_empty() {
14723                    out.push_str(&format!("- {l}\n"));
14724                }
14725            }
14726        }
14727        _ => out.push_str("- Could not inspect identity broker services\n"),
14728    }
14729
14730    out.push_str("\n=== Device registration ===\n");
14731    let ps_device = r#"
14732$dsreg = Get-Command dsregcmd.exe -ErrorAction SilentlyContinue
14733if ($dsreg) {
14734    try {
14735        $raw = & $dsreg.Source /status 2>$null
14736        $text = ($raw -join "`n")
14737        $keys = 'AzureAdJoined','WorkplaceJoined','DomainJoined','DeviceAuthStatus','TenantName','AzureAdPrt','WamDefaultSet'
14738        $seen = $false
14739        foreach ($key in $keys) {
14740            $match = [regex]::Match($text, '(?im)^\s*' + [regex]::Escape($key) + '\s*:\s*(.+)$')
14741            if ($match.Success) {
14742                "${key}: $($match.Groups[1].Value.Trim())"
14743                $seen = $true
14744            }
14745        }
14746        if (-not $seen) {
14747            "DeviceRegistration: dsregcmd returned no recognizable registration fields (common on personal or unmanaged devices)"
14748        }
14749    } catch {
14750        "DeviceRegistration: dsregcmd failed - $($_.Exception.Message)"
14751    }
14752} else {
14753    "DeviceRegistration: dsregcmd unavailable"
14754}
14755"#;
14756    match run_powershell(ps_device) {
14757        Ok(o) if !o.trim().is_empty() => {
14758            for line in o.lines().take(max_entries + 4) {
14759                let l = line.trim();
14760                if !l.is_empty() {
14761                    out.push_str(&format!("- {l}\n"));
14762                }
14763            }
14764        }
14765        _ => out.push_str(
14766            "- DeviceRegistration: Could not inspect device registration state in this session\n",
14767        ),
14768    }
14769
14770    out.push_str("\n=== Broker packages and caches ===\n");
14771    let ps_broker = r#"
14772$pkg = Get-AppxPackage -Name 'Microsoft.AAD.BrokerPlugin' -ErrorAction SilentlyContinue | Select-Object -First 1
14773if ($pkg) {
14774    "AADBrokerPlugin: Installed | Version: $($pkg.Version)"
14775} else {
14776    "AADBrokerPlugin: Not installed"
14777}
14778$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14779$tokenCount = if (Test-Path $tokenCache) { @(Get-ChildItem $tokenCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
14780"TokenBrokerCacheFiles: $tokenCount"
14781$identityCache = Join-Path $env:LOCALAPPDATA 'Microsoft\IdentityCache'
14782$identityCount = if (Test-Path $identityCache) { @(Get-ChildItem $identityCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
14783"IdentityCacheFiles: $identityCount"
14784$oneAuth = Join-Path $env:LOCALAPPDATA 'Microsoft\OneAuth'
14785$oneAuthCount = if (Test-Path $oneAuth) { @(Get-ChildItem $oneAuth -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
14786"OneAuthFiles: $oneAuthCount"
14787"#;
14788    match run_powershell(ps_broker) {
14789        Ok(o) if !o.trim().is_empty() => {
14790            for line in o.lines().take(max_entries + 4) {
14791                let l = line.trim();
14792                if !l.is_empty() {
14793                    out.push_str(&format!("- {l}\n"));
14794                }
14795            }
14796        }
14797        _ => out.push_str("- Could not inspect identity broker packages or caches\n"),
14798    }
14799
14800    out.push_str("\n=== Microsoft app account signals ===\n");
14801    let ps_accounts = r#"
14802function MaskEmail([string]$Email) {
14803    if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
14804    $parts = $Email.Split('@', 2)
14805    $local = $parts[0]
14806    $domain = $parts[1]
14807    if ($local.Length -le 1) { return "*@$domain" }
14808    return ($local.Substring(0,1) + "***@" + $domain)
14809}
14810$allAccounts = @()
14811$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14812if (Test-Path $officeId) {
14813    $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
14814    if ($id.SignedInUserId) {
14815        $allAccounts += [string]$id.SignedInUserId
14816        "OfficeSignedInUserId: $(MaskEmail ([string]$id.SignedInUserId))"
14817    } else {
14818        "OfficeSignedInUserId: None"
14819    }
14820} else {
14821    "OfficeSignedInUserId: Not configured"
14822}
14823$teamsAcct = 'HKCU:\Software\Microsoft\Office\Teams'
14824if (Test-Path $teamsAcct) {
14825    $item = Get-ItemProperty $teamsAcct -ErrorAction SilentlyContinue
14826    $email = if ($item.HomeUserUpn) { [string]$item.HomeUserUpn } elseif ($item.LoggedInEmail) { [string]$item.LoggedInEmail } else { '' }
14827    if (-not [string]::IsNullOrWhiteSpace($email)) {
14828        $allAccounts += $email
14829        "TeamsAccount: $(MaskEmail $email)"
14830    } else {
14831        "TeamsAccount: Unknown"
14832    }
14833} else {
14834    "TeamsAccount: Not configured"
14835}
14836$oneDriveBase = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
14837$oneDriveEmails = @()
14838if (Test-Path $oneDriveBase) {
14839    $oneDriveEmails = Get-ChildItem $oneDriveBase -ErrorAction SilentlyContinue |
14840        ForEach-Object {
14841            $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
14842            if ($p.UserEmail) { [string]$p.UserEmail }
14843        } |
14844        Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
14845        Sort-Object -Unique
14846}
14847$allAccounts += $oneDriveEmails
14848"OneDriveAccountCount: $(@($oneDriveEmails).Count)"
14849if (@($oneDriveEmails).Count -gt 0) {
14850    "OneDriveAccounts: $((@($oneDriveEmails) | ForEach-Object { MaskEmail $_ }) -join ', ')"
14851}
14852$distinct = @($allAccounts | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
14853"DistinctIdentityCount: $($distinct.Count)"
14854if ($distinct.Count -gt 0) {
14855    "IdentitySet: $((@($distinct) | ForEach-Object { MaskEmail $_ }) -join ', ')"
14856}
14857"#;
14858    match run_powershell(ps_accounts) {
14859        Ok(o) if !o.trim().is_empty() => {
14860            for line in o.lines().take(max_entries + 6) {
14861                let l = line.trim();
14862                if !l.is_empty() {
14863                    out.push_str(&format!("- {l}\n"));
14864                }
14865            }
14866        }
14867        _ => out.push_str("- Could not inspect Microsoft app identity state\n"),
14868    }
14869
14870    out.push_str("\n=== WebView2 auth dependency ===\n");
14871    let ps_webview = r#"
14872$paths = @(
14873    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
14874    (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
14875) | Where-Object { $_ -and (Test-Path $_) }
14876$runtimeDir = $paths | ForEach-Object {
14877    Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
14878        Where-Object { $_.Name -match '^\d+\.' } |
14879        Sort-Object Name -Descending |
14880        Select-Object -First 1
14881} | Select-Object -First 1
14882if ($runtimeDir) {
14883    $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14884    $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14885    "WebView2: Installed | Version: $version"
14886} else {
14887    "WebView2: Not installed"
14888}
14889"#;
14890    match run_powershell(ps_webview) {
14891        Ok(o) if !o.trim().is_empty() => {
14892            for line in o.lines().take(4) {
14893                let l = line.trim();
14894                if !l.is_empty() {
14895                    out.push_str(&format!("- {l}\n"));
14896                }
14897            }
14898        }
14899        _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14900    }
14901
14902    out.push_str("\n=== Recent auth-related events (24h) ===\n");
14903    let ps_events = r#"
14904try {
14905    $cutoff = (Get-Date).AddHours(-24)
14906    $events = @()
14907    if (Get-WinEvent -ListLog 'Microsoft-Windows-AAD/Operational' -ErrorAction SilentlyContinue) {
14908        $events += Get-WinEvent -FilterHashtable @{ LogName='Microsoft-Windows-AAD/Operational'; StartTime=$cutoff } -MaxEvents 30 -ErrorAction SilentlyContinue |
14909            Where-Object { $_.LevelDisplayName -in @('Error','Warning') } |
14910            Select-Object -First 4
14911    }
14912    $events += Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 80 -ErrorAction SilentlyContinue |
14913        Where-Object {
14914            ($_.LevelDisplayName -in @('Error','Warning')) -and (
14915                $_.ProviderName -match 'Outlook|Teams|OneDrive|Office|AAD|TokenBroker|Broker'
14916                -or $_.Message -match 'Outlook|Teams|OneDrive|sign-?in|authentication|TokenBroker|BrokerPlugin|AAD'
14917            )
14918        } |
14919        Select-Object -First 6
14920    $events = $events | Sort-Object TimeCreated -Descending | Select-Object -First 8
14921    "AuthEventCount: $(@($events).Count)"
14922    if ($events) {
14923        foreach ($e in $events) {
14924            $msg = if ([string]::IsNullOrWhiteSpace([string]$e.Message)) {
14925                'No message'
14926            } else {
14927                ($e.Message -replace '\r','' -split '\n')[0] -replace '\|','/'
14928            }
14929            "$($e.TimeCreated.ToString('MM-dd HH:mm')) | Provider: $($e.ProviderName) | Level: $($e.LevelDisplayName) | Id: $($e.Id) | $msg"
14930        }
14931    } else {
14932        "No auth-related warning/error events detected"
14933    }
14934} catch {
14935    "AuthEventStatus: Could not inspect auth-related events - $($_.Exception.Message)"
14936}
14937"#;
14938    match run_powershell(ps_events) {
14939        Ok(o) if !o.trim().is_empty() => {
14940            for line in o.lines().take(max_entries + 8) {
14941                let l = line.trim();
14942                if !l.is_empty() {
14943                    out.push_str(&format!("- {l}\n"));
14944                }
14945            }
14946        }
14947        _ => out
14948            .push_str("- AuthEventStatus: Could not inspect auth-related events in this session\n"),
14949    }
14950
14951    let parse_count = |prefix: &str| -> Option<u64> {
14952        out.lines().find_map(|line| {
14953            line.trim()
14954                .strip_prefix(prefix)
14955                .and_then(|value| value.trim().parse::<u64>().ok())
14956        })
14957    };
14958
14959    let distinct_identity_count = parse_count("- DistinctIdentityCount: ").unwrap_or(0);
14960    let auth_event_count = parse_count("- AuthEventCount: ").unwrap_or(0);
14961
14962    let mut findings: Vec<String> = Vec::new();
14963    if out.contains("TokenBroker | Status: Stopped")
14964        || out.contains("wlidsvc | Status: Stopped")
14965        || out.contains("OneAuth | Status: Stopped")
14966    {
14967        findings.push(
14968            "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."
14969                .into(),
14970        );
14971    }
14972    if out.contains("AADBrokerPlugin: Not installed") {
14973        findings.push(
14974            "Microsoft AAD Broker Plugin is missing - work/school account sign-in and token refresh can fail without the broker package."
14975                .into(),
14976        );
14977    }
14978    if out.contains("WebView2: Not installed") {
14979        findings.push(
14980            "WebView2 runtime is missing - modern Microsoft 365 sign-in surfaces may fail or render badly without it."
14981                .into(),
14982        );
14983    }
14984    if distinct_identity_count > 1 {
14985        findings.push(format!(
14986            "{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."
14987        ));
14988    }
14989    if (out.contains("AzureAdJoined: NO") || out.contains("WorkplaceJoined: NO"))
14990        && distinct_identity_count > 0
14991    {
14992        findings.push(
14993            "This machine shows Microsoft app identities but weak device-registration signals - organizational SSO, Conditional Access, or silent token refresh may be limited."
14994                .into(),
14995        );
14996    }
14997    if out.contains("DeviceRegistration: dsregcmd")
14998        || out.contains("DeviceRegistration: Could not inspect device registration state")
14999    {
15000        findings.push(
15001            "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."
15002                .into(),
15003        );
15004    }
15005    if auth_event_count > 0 {
15006        findings.push(format!(
15007            "{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."
15008        ));
15009    } else if out.contains("AuthEventStatus: Could not inspect auth-related events") {
15010        findings.push(
15011            "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."
15012                .into(),
15013        );
15014    }
15015
15016    let mut result = String::from("Host inspection: identity_auth\n\n=== Findings ===\n");
15017    if findings.is_empty() {
15018        result.push_str("- No obvious Microsoft 365 identity broker, token cache, or device-registration blocker detected.\n");
15019    } else {
15020        for finding in &findings {
15021            result.push_str(&format!("- Finding: {finding}\n"));
15022        }
15023    }
15024    result.push('\n');
15025    result.push_str(&out);
15026    Ok(result)
15027}
15028
15029#[cfg(not(windows))]
15030fn inspect_identity_auth(_max_entries: usize) -> Result<String, String> {
15031    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())
15032}
15033
15034#[cfg(windows)]
15035fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
15036    let mut out = String::from("=== File History ===\n");
15037
15038    let ps_fh = r#"
15039$svc = Get-Service fhsvc -ErrorAction SilentlyContinue
15040if ($svc) {
15041    "FileHistoryService: $($svc.Status) | StartType: $($svc.StartType)"
15042} else {
15043    "FileHistoryService: Not found"
15044}
15045# File History config in registry
15046$fhKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SPP\UserPolicy\S-1-5-21*\d5c93fba*'
15047$fhUser = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\FileHistory'
15048if (Test-Path $fhUser) {
15049    $fh = Get-ItemProperty $fhUser -ErrorAction SilentlyContinue
15050    $enabled = if ($fh.Enabled -eq 0) { 'Disabled' } elseif ($fh.Enabled -eq 1) { 'Enabled' } else { 'Unknown' }
15051    $target = if ($fh.TargetUrl) { $fh.TargetUrl } else { 'Not configured' }
15052    $lastBackup = if ($fh.ProtectedUpToTime) {
15053        try { [DateTime]::FromFileTime($fh.ProtectedUpToTime).ToString('yyyy-MM-dd HH:mm') } catch { 'Unknown' }
15054    } else { 'Never' }
15055    "Enabled: $enabled"
15056    "BackupDrive: $target"
15057    "LastBackup: $lastBackup"
15058} else {
15059    "Enabled: Not configured"
15060    "BackupDrive: Not configured"
15061    "LastBackup: Never"
15062}
15063"#;
15064    match run_powershell(ps_fh) {
15065        Ok(o) if !o.trim().is_empty() => {
15066            for line in o.lines().take(6) {
15067                let l = line.trim();
15068                if !l.is_empty() {
15069                    out.push_str(&format!("- {l}\n"));
15070                }
15071            }
15072        }
15073        _ => out.push_str("- Could not inspect File History state\n"),
15074    }
15075
15076    out.push_str("\n=== Windows Backup (wbadmin) ===\n");
15077    let ps_wbadmin = r#"
15078$svc = Get-Service wbengine -ErrorAction SilentlyContinue
15079"WindowsBackupEngine: $(if ($svc) { "$($svc.Status) | StartType: $($svc.StartType)" } else { 'Not found' })"
15080# Last backup from wbadmin
15081$raw = try { wbadmin get versions 2>&1 | Select-Object -First 30 } catch { $null }
15082if ($raw -and ($raw -join ' ') -notmatch 'no backup') {
15083    $lastDate = ($raw | Select-String 'Backup time:' | Select-Object -First 1).Line
15084    $lastTarget = ($raw | Select-String 'Backup target:' | Select-Object -First 1).Line
15085    if ($lastDate) { $lastDate.Trim() }
15086    if ($lastTarget) { $lastTarget.Trim() }
15087} else {
15088    "LastWbadminBackup: No backup versions found"
15089}
15090# Task-based backup
15091$task = Get-ScheduledTask -TaskPath '\Microsoft\Windows\WindowsBackup\' -ErrorAction SilentlyContinue
15092foreach ($t in $task) {
15093    "BackupTask: $($t.TaskName) | State: $($t.State)"
15094}
15095"#;
15096    match run_powershell(ps_wbadmin) {
15097        Ok(o) if !o.trim().is_empty() => {
15098            for line in o.lines().take(8) {
15099                let l = line.trim();
15100                if !l.is_empty() {
15101                    out.push_str(&format!("- {l}\n"));
15102                }
15103            }
15104        }
15105        _ => out.push_str("- Could not inspect Windows Backup state\n"),
15106    }
15107
15108    out.push_str("\n=== System Restore ===\n");
15109    let ps_sr = r#"
15110$drives = Get-WmiObject -Class Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction SilentlyContinue |
15111    Select-Object -ExpandProperty DeviceID
15112foreach ($drive in $drives) {
15113    $protection = try {
15114        (Get-ComputerRestorePoint -Drive "$drive\" -ErrorAction SilentlyContinue)
15115    } catch { $null }
15116    $srReg = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore"
15117    $rpConf = try {
15118        Get-ItemProperty "$srReg" -ErrorAction SilentlyContinue
15119    } catch { $null }
15120    # Check if SR is disabled for this drive
15121    $disabled = $false
15122    $vssService = Get-Service VSS -ErrorAction SilentlyContinue
15123    "Drive: $drive | VSSService: $(if ($vssService) { $vssService.Status } else { 'Not found' })"
15124}
15125# Most recent restore point
15126$points = try { Get-ComputerRestorePoint -ErrorAction SilentlyContinue } catch { $null }
15127if ($points) {
15128    $latest = $points | Sort-Object SequenceNumber -Descending | Select-Object -First 1
15129    $date = try { [Management.ManagementDateTimeConverter]::ToDateTime($latest.CreationTime).ToString('yyyy-MM-dd HH:mm') } catch { $latest.CreationTime }
15130    "MostRecentRestorePoint: $($latest.Description) | Created: $date"
15131} else {
15132    "MostRecentRestorePoint: None found"
15133}
15134$srEnabled = try {
15135    $regVal = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' -ErrorAction SilentlyContinue).RPSessionInterval
15136    if ($null -eq $regVal) { 'Enabled (default)' } elseif ($regVal -eq 0) { 'Disabled' } else { "Interval: $regVal" }
15137} catch { 'Unknown' }
15138"SystemRestoreState: $srEnabled"
15139"#;
15140    match run_powershell(ps_sr) {
15141        Ok(o) if !o.trim().is_empty() => {
15142            for line in o.lines().take(8) {
15143                let l = line.trim();
15144                if !l.is_empty() {
15145                    out.push_str(&format!("- {l}\n"));
15146                }
15147            }
15148        }
15149        _ => out.push_str("- Could not inspect System Restore state\n"),
15150    }
15151
15152    out.push_str("\n=== OneDrive backup (Known Folder Move) ===\n");
15153    let ps_kfm = r#"
15154$kfmKey = 'HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts'
15155if (Test-Path $kfmKey) {
15156    $accounts = Get-ChildItem $kfmKey -ErrorAction SilentlyContinue
15157    foreach ($acct in $accounts | Select-Object -First 3) {
15158        $props = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
15159        $email = $props.UserEmail
15160        $kfmDesktop = $props.'KFMSilentOptInDesktop'
15161        $kfmDocs = $props.'KFMSilentOptInDocuments'
15162        $kfmPics = $props.'KFMSilentOptInPictures'
15163        "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' })"
15164    }
15165} else {
15166    "OneDriveKFM: No OneDrive accounts found"
15167}
15168"#;
15169    match run_powershell(ps_kfm) {
15170        Ok(o) if !o.trim().is_empty() => {
15171            for line in o.lines().take(6) {
15172                let l = line.trim();
15173                if !l.is_empty() {
15174                    out.push_str(&format!("- {l}\n"));
15175                }
15176            }
15177        }
15178        _ => out.push_str("- Could not inspect OneDrive Known Folder Move state\n"),
15179    }
15180
15181    out.push_str("\n=== Recent backup failure events (7d) ===\n");
15182    let ps_events = r#"
15183$cutoff = (Get-Date).AddDays(-7)
15184$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
15185    Where-Object {
15186        $_.ProviderName -match 'backup|FileHistory|wbengine|Microsoft-Windows-Backup' -or
15187        ($_.Id -in @(49,50,517,521) -and $_.LogName -eq 'Application')
15188    } |
15189    Where-Object { $_.Level -le 3 } |
15190    Select-Object -First 6
15191if ($events) {
15192    foreach ($event in $events) {
15193        $msg = ($event.Message -replace '\s+', ' ')
15194        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
15195        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
15196    }
15197} else {
15198    "No recent backup failure events detected"
15199}
15200"#;
15201    match run_powershell(ps_events) {
15202        Ok(o) if !o.trim().is_empty() => {
15203            for line in o.lines().take(8) {
15204                let l = line.trim();
15205                if !l.is_empty() {
15206                    out.push_str(&format!("- {l}\n"));
15207                }
15208            }
15209        }
15210        _ => out.push_str("- Could not inspect backup failure events\n"),
15211    }
15212
15213    let mut findings: Vec<String> = Vec::new();
15214
15215    let fh_enabled = out.contains("- Enabled: Enabled");
15216    let fh_never =
15217        out.contains("- LastBackup: Never") || out.contains("- LastBackup: Not configured");
15218    let no_wbadmin = out.contains("No backup versions found");
15219    let no_restore_point = out.contains("MostRecentRestorePoint: None found");
15220
15221    if !fh_enabled && no_wbadmin {
15222        findings.push(
15223            "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(),
15224        );
15225    } else if fh_enabled && fh_never {
15226        findings.push(
15227            "File History is enabled but has never completed a backup — check that the backup drive is connected and accessible.".into(),
15228        );
15229    }
15230
15231    if no_restore_point {
15232        findings.push(
15233            "No System Restore points exist — if a driver or update goes wrong there is no local rollback point available.".into(),
15234        );
15235    }
15236
15237    if out.contains("- FileHistoryService: Stopped")
15238        || out.contains("- FileHistoryService: Not found")
15239    {
15240        findings.push(
15241            "File History service (fhsvc) is stopped or missing — File History backups cannot run until the service is started.".into(),
15242        );
15243    }
15244
15245    if out.contains("Application Error |")
15246        || out.contains("Microsoft-Windows-Backup |")
15247        || out.contains("wbengine |")
15248    {
15249        findings.push(
15250            "Recent backup failure events found in the Application log — check the event lines below for the specific error.".into(),
15251        );
15252    }
15253
15254    let mut result = String::from("Host inspection: windows_backup\n\n=== Findings ===\n");
15255    if findings.is_empty() {
15256        result.push_str("- No obvious backup health blocker detected.\n");
15257    } else {
15258        for finding in &findings {
15259            result.push_str(&format!("- Finding: {finding}\n"));
15260        }
15261    }
15262    result.push('\n');
15263    result.push_str(&out);
15264    Ok(result)
15265}
15266
15267#[cfg(not(windows))]
15268fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
15269    Ok("Host inspection: windows_backup\n\n=== Findings ===\n- Windows Backup inspection is Windows-only.\n".into())
15270}
15271
15272#[cfg(windows)]
15273fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
15274    let mut out = String::from("=== Windows Search service ===\n");
15275
15276    // Service state
15277    let ps_svc = r#"
15278$svc = Get-Service WSearch -ErrorAction SilentlyContinue
15279if ($svc) { "WSearch | Status: $($svc.Status) | StartType: $($svc.StartType)" }
15280else { "WSearch service not found" }
15281"#;
15282    match run_powershell(ps_svc) {
15283        Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
15284        Err(_) => out.push_str("- Could not query WSearch service\n"),
15285    }
15286
15287    // Indexer state via registry
15288    out.push_str("\n=== Indexer state ===\n");
15289    let ps_idx = r#"
15290$key = 'HKLM:\SOFTWARE\Microsoft\Windows Search'
15291$props = Get-ItemProperty $key -ErrorAction SilentlyContinue
15292if ($props) {
15293    "SetupCompletedSuccessfully: $($props.SetupCompletedSuccessfully)"
15294    "IsContentIndexingEnabled: $($props.IsContentIndexingEnabled)"
15295    "DataDirectory: $($props.DataDirectory)"
15296} else { "Registry key not found" }
15297"#;
15298    match run_powershell(ps_idx) {
15299        Ok(o) => {
15300            for line in o.lines() {
15301                let l = line.trim();
15302                if !l.is_empty() {
15303                    out.push_str(&format!("- {l}\n"));
15304                }
15305            }
15306        }
15307        Err(_) => out.push_str("- Could not read indexer registry\n"),
15308    }
15309
15310    // Indexed locations
15311    out.push_str("\n=== Indexed locations ===\n");
15312    let ps_locs = r#"
15313$comObj = New-Object -ComObject Microsoft.Search.Administration.CSearchManager -ErrorAction SilentlyContinue
15314if ($comObj) {
15315    $catalog = $comObj.GetCatalog('SystemIndex')
15316    $manager = $catalog.GetCrawlScopeManager()
15317    $rules = $manager.EnumerateRoots()
15318    while ($true) {
15319        try {
15320            $root = $rules.Next(1)
15321            if ($root.Count -eq 0) { break }
15322            $r = $root[0]
15323            "  $($r.RootURL) | Default: $($r.IsDefault) | Included: $($r.IsIncluded)"
15324        } catch { break }
15325    }
15326} else { "  COM admin interface not available (normal on non-admin sessions)" }
15327"#;
15328    match run_powershell(ps_locs) {
15329        Ok(o) if !o.trim().is_empty() => {
15330            for line in o.lines() {
15331                let l = line.trim_end();
15332                if !l.is_empty() {
15333                    out.push_str(&format!("{l}\n"));
15334                }
15335            }
15336        }
15337        _ => {
15338            // Fallback: read from registry
15339            let ps_reg = r#"
15340Get-ChildItem 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Search\Indexer\Sources' -ErrorAction SilentlyContinue |
15341ForEach-Object { "  $($_.PSChildName)" } | Select-Object -First 20
15342"#;
15343            match run_powershell(ps_reg) {
15344                Ok(o) if !o.trim().is_empty() => {
15345                    for line in o.lines() {
15346                        let l = line.trim_end();
15347                        if !l.is_empty() {
15348                            out.push_str(&format!("{l}\n"));
15349                        }
15350                    }
15351                }
15352                _ => out.push_str("  - Could not enumerate indexed locations\n"),
15353            }
15354        }
15355    }
15356
15357    // Recent indexing errors from event log
15358    out.push_str("\n=== Recent indexer errors (last 24h) ===\n");
15359    let ps_evts = r#"
15360Get-WinEvent -LogName 'Microsoft-Windows-Search/Operational' -MaxEvents 5 -ErrorAction SilentlyContinue |
15361Where-Object { $_.LevelDisplayName -eq 'Error' -or $_.LevelDisplayName -eq 'Warning' } |
15362ForEach-Object { "$($_.TimeCreated.ToString('HH:mm')) [$($_.LevelDisplayName)] $($_.Message.Substring(0, [Math]::Min(120, $_.Message.Length)))" }
15363"#;
15364    match run_powershell(ps_evts) {
15365        Ok(o) if !o.trim().is_empty() => {
15366            for line in o.lines() {
15367                let l = line.trim();
15368                if !l.is_empty() {
15369                    out.push_str(&format!("- {l}\n"));
15370                }
15371            }
15372        }
15373        _ => out.push_str("- No recent indexer errors found\n"),
15374    }
15375
15376    let mut findings: Vec<String> = Vec::new();
15377    if out.contains("Status: Stopped") {
15378        findings.push("Windows Search (WSearch) is stopped — search results will be slow or empty. Start the service: `Start-Service WSearch`.".into());
15379    }
15380    if out.contains("IsContentIndexingEnabled: 0")
15381        || out.contains("IsContentIndexingEnabled: False")
15382    {
15383        findings.push(
15384            "Content indexing is disabled — file content won't be searchable, only filenames."
15385                .into(),
15386        );
15387    }
15388    if out.contains("SetupCompletedSuccessfully: 0")
15389        || out.contains("SetupCompletedSuccessfully: False")
15390    {
15391        findings.push("Search indexer setup did not complete successfully — index may be corrupt. Rebuild: Settings > Search > Searching Windows > Advanced > Rebuild.".into());
15392    }
15393
15394    let mut result = String::from("Host inspection: search_index\n\n=== Findings ===\n");
15395    if findings.is_empty() {
15396        result.push_str("- Windows Search service and indexer appear healthy.\n");
15397        result.push_str("  If search still feels slow, the index may just be catching up — check indexing status in Settings > Search > Searching Windows.\n");
15398    } else {
15399        for f in &findings {
15400            result.push_str(&format!("- Finding: {f}\n"));
15401        }
15402    }
15403    result.push('\n');
15404    result.push_str(&out);
15405    Ok(result)
15406}
15407
15408#[cfg(not(windows))]
15409fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
15410    Ok("Host inspection: search_index\nSearch index inspection is Windows-only.".into())
15411}
15412
15413// ── inspect_display_config ────────────────────────────────────────────────────
15414
15415#[cfg(windows)]
15416fn inspect_display_config(max_entries: usize) -> Result<String, String> {
15417    let mut out = String::new();
15418
15419    // Active displays via CIM
15420    out.push_str("=== Active displays ===\n");
15421    let ps_displays = r#"
15422Get-CimInstance -ClassName CIM_VideoControllerResolution -ErrorAction SilentlyContinue |
15423Select-Object -First 20 |
15424ForEach-Object {
15425    "$($_.HorizontalResolution)x$($_.VerticalResolution) @ $($_.RefreshRate)Hz | Colors: $($_.NumberOfColors)"
15426}
15427"#;
15428    match run_powershell(ps_displays) {
15429        Ok(o) if !o.trim().is_empty() => {
15430            for line in o.lines().take(max_entries) {
15431                let l = line.trim();
15432                if !l.is_empty() {
15433                    out.push_str(&format!("- {l}\n"));
15434                }
15435            }
15436        }
15437        _ => out.push_str("- Could not enumerate display resolutions via CIM\n"),
15438    }
15439
15440    // GPU / video adapter
15441    out.push_str("\n=== Video adapters ===\n");
15442    let ps_gpu = r#"
15443Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | Select-Object -First 4 |
15444ForEach-Object {
15445    $res = "$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
15446    $hz  = "$($_.CurrentRefreshRate) Hz"
15447    $bits = "$($_.CurrentBitsPerPixel) bpp"
15448    "$($_.Name) | $res @ $hz | $bits | Driver: $($_.DriverVersion)"
15449}
15450"#;
15451    match run_powershell(ps_gpu) {
15452        Ok(o) if !o.trim().is_empty() => {
15453            for line in o.lines().take(max_entries) {
15454                let l = line.trim();
15455                if !l.is_empty() {
15456                    out.push_str(&format!("- {l}\n"));
15457                }
15458            }
15459        }
15460        _ => out.push_str("- Could not query video adapter info\n"),
15461    }
15462
15463    // Monitor names via Win32_DesktopMonitor
15464    out.push_str("\n=== Connected monitors ===\n");
15465    let ps_monitors = r#"
15466Get-CimInstance Win32_DesktopMonitor -ErrorAction SilentlyContinue | Select-Object -First 8 |
15467ForEach-Object { "$($_.Name) | Status: $($_.Status) | PnP: $($_.PNPDeviceID)" }
15468"#;
15469    match run_powershell(ps_monitors) {
15470        Ok(o) if !o.trim().is_empty() => {
15471            for line in o.lines().take(max_entries) {
15472                let l = line.trim();
15473                if !l.is_empty() {
15474                    out.push_str(&format!("- {l}\n"));
15475                }
15476            }
15477        }
15478        _ => out.push_str("- No monitor info available via WMI\n"),
15479    }
15480
15481    // DPI scaling
15482    out.push_str("\n=== DPI / scaling ===\n");
15483    let ps_dpi = r#"
15484Add-Type -TypeDefinition @'
15485using System; using System.Runtime.InteropServices;
15486public class DPI {
15487    [DllImport("user32")] public static extern IntPtr GetDC(IntPtr hwnd);
15488    [DllImport("gdi32")]  public static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
15489    [DllImport("user32")] public static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc);
15490}
15491'@ -ErrorAction SilentlyContinue
15492try {
15493    $hdc  = [DPI]::GetDC([IntPtr]::Zero)
15494    $dpiX = [DPI]::GetDeviceCaps($hdc, 88)
15495    $dpiY = [DPI]::GetDeviceCaps($hdc, 90)
15496    [DPI]::ReleaseDC([IntPtr]::Zero, $hdc) | Out-Null
15497    $scale = [Math]::Round($dpiX / 96.0 * 100)
15498    "DPI: ${dpiX}x${dpiY} | Scale: ${scale}%"
15499} catch { "DPI query unavailable" }
15500"#;
15501    match run_powershell(ps_dpi) {
15502        Ok(o) if !o.trim().is_empty() => {
15503            out.push_str(&format!("- {}\n", o.trim()));
15504        }
15505        _ => out.push_str("- DPI info unavailable\n"),
15506    }
15507
15508    let mut findings: Vec<String> = Vec::new();
15509    if out.contains("0x0") || out.contains("@ 0 Hz") {
15510        findings.push("One or more adapters report zero resolution or refresh rate — display may be asleep or misconfigured.".into());
15511    }
15512
15513    let mut result = String::from("Host inspection: display_config\n\n=== Findings ===\n");
15514    if findings.is_empty() {
15515        result.push_str("- Display configuration appears normal.\n");
15516    } else {
15517        for f in &findings {
15518            result.push_str(&format!("- Finding: {f}\n"));
15519        }
15520    }
15521    result.push('\n');
15522    result.push_str(&out);
15523    Ok(result)
15524}
15525
15526#[cfg(not(windows))]
15527fn inspect_display_config(_max_entries: usize) -> Result<String, String> {
15528    Ok("Host inspection: display_config\nDisplay config inspection is Windows-only.".into())
15529}
15530
15531// ── inspect_ntp ───────────────────────────────────────────────────────────────
15532
15533#[cfg(windows)]
15534fn inspect_ntp() -> Result<String, String> {
15535    let mut out = String::new();
15536
15537    // w32tm status
15538    out.push_str("=== Windows Time service ===\n");
15539    let ps_svc = r#"
15540$svc = Get-Service W32Time -ErrorAction SilentlyContinue
15541if ($svc) { "W32Time | Status: $($svc.Status) | StartType: $($svc.StartType)" }
15542else { "W32Time service not found" }
15543"#;
15544    match run_powershell(ps_svc) {
15545        Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
15546        Err(_) => out.push_str("- Could not query W32Time service\n"),
15547    }
15548
15549    // NTP source and last sync
15550    out.push_str("\n=== NTP source and sync status ===\n");
15551    let ps_sync = r#"
15552$q = w32tm /query /status 2>$null
15553if ($q) { $q } else { "w32tm query unavailable" }
15554"#;
15555    match run_powershell(ps_sync) {
15556        Ok(o) if !o.trim().is_empty() => {
15557            for line in o.lines() {
15558                let l = line.trim();
15559                if !l.is_empty() {
15560                    out.push_str(&format!("  {l}\n"));
15561                }
15562            }
15563        }
15564        _ => out.push_str("  - Could not query w32tm status\n"),
15565    }
15566
15567    // Configured NTP server
15568    out.push_str("\n=== Configured NTP servers ===\n");
15569    let ps_peers = r#"
15570w32tm /query /peers 2>$null | Select-Object -First 10
15571"#;
15572    match run_powershell(ps_peers) {
15573        Ok(o) if !o.trim().is_empty() => {
15574            for line in o.lines() {
15575                let l = line.trim();
15576                if !l.is_empty() {
15577                    out.push_str(&format!("  {l}\n"));
15578                }
15579            }
15580        }
15581        _ => {
15582            // Fallback: registry
15583            let ps_reg = r#"
15584(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Parameters' -Name NtpServer -ErrorAction SilentlyContinue).NtpServer
15585"#;
15586            match run_powershell(ps_reg) {
15587                Ok(o) if !o.trim().is_empty() => {
15588                    out.push_str(&format!("  NtpServer (registry): {}\n", o.trim()));
15589                }
15590                _ => out.push_str("  - Could not enumerate NTP peers\n"),
15591            }
15592        }
15593    }
15594
15595    let mut findings: Vec<String> = Vec::new();
15596    if out.contains("W32Time | Status: Stopped") {
15597        findings.push("Windows Time service is stopped — system clock will drift and may cause authentication or certificate failures. Start with: `Start-Service W32Time`.".into());
15598    }
15599    if out.contains("The computer did not resync") || out.contains("Error") {
15600        findings.push("w32tm reports a sync error — check NTP server reachability or run `w32tm /resync /force`.".into());
15601    }
15602
15603    let mut result = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15604    if findings.is_empty() {
15605        result.push_str("- Windows Time service and NTP sync appear healthy.\n");
15606    } else {
15607        for f in &findings {
15608            result.push_str(&format!("- Finding: {f}\n"));
15609        }
15610    }
15611    result.push('\n');
15612    result.push_str(&out);
15613    Ok(result)
15614}
15615
15616#[cfg(not(windows))]
15617fn inspect_ntp() -> Result<String, String> {
15618    // Linux/macOS: check timedatectl / chrony / ntpq
15619    let mut out = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15620
15621    let timedatectl = std::process::Command::new("timedatectl")
15622        .arg("status")
15623        .output();
15624
15625    if let Ok(o) = timedatectl {
15626        let text = String::from_utf8_lossy(&o.stdout);
15627        if text.contains("synchronized: yes") || text.contains("NTP synchronized: yes") {
15628            out.push_str("- NTP synchronized: yes\n\n=== timedatectl status ===\n");
15629        } else {
15630            out.push_str("- Finding: NTP not synchronized — run `timedatectl set-ntp true`\n\n=== timedatectl status ===\n");
15631        }
15632        for line in text.lines() {
15633            let l = line.trim();
15634            if !l.is_empty() {
15635                out.push_str(&format!("  {l}\n"));
15636            }
15637        }
15638        return Ok(out);
15639    }
15640
15641    // macOS fallback
15642    let sntp = std::process::Command::new("sntp")
15643        .args(["-d", "time.apple.com"])
15644        .output();
15645    if let Ok(o) = sntp {
15646        out.push_str("- NTP check via sntp:\n");
15647        out.push_str(&String::from_utf8_lossy(&o.stdout));
15648        return Ok(out);
15649    }
15650
15651    out.push_str("- NTP status unavailable (no timedatectl or sntp found)\n");
15652    Ok(out)
15653}
15654
15655// ── inspect_cpu_power ─────────────────────────────────────────────────────────
15656
15657#[cfg(windows)]
15658fn inspect_cpu_power() -> Result<String, String> {
15659    let mut out = String::new();
15660
15661    // Active power plan
15662    out.push_str("=== Active power plan ===\n");
15663    let ps_plan = r#"
15664$plan = powercfg /getactivescheme 2>$null
15665if ($plan) { $plan } else { "Could not query power scheme" }
15666"#;
15667    match run_powershell(ps_plan) {
15668        Ok(o) if !o.trim().is_empty() => out.push_str(&format!("- {}\n", o.trim())),
15669        _ => out.push_str("- Could not read active power plan\n"),
15670    }
15671
15672    // Processor min/max state and boost policy
15673    out.push_str("\n=== Processor performance policy ===\n");
15674    let ps_proc = r#"
15675$active = (powercfg /getactivescheme) -replace '.*GUID: ([a-f0-9-]+).*','$1'
15676$min = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMIN 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15677$max = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMAX 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15678$boost = powercfg /query $active SUB_PROCESSOR PERFBOOSTMODE 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15679if ($min)   { "Min processor state:  $(([Convert]::ToInt32(($min -split '0x')[1],16)))%" }
15680if ($max)   { "Max processor state:  $(([Convert]::ToInt32(($max -split '0x')[1],16)))%" }
15681if ($boost) {
15682    $bval = [Convert]::ToInt32(($boost -split '0x')[1],16)
15683    $bname = switch ($bval) { 0{'Disabled'} 1{'Enabled'} 2{'Aggressive'} 3{'Efficient Enabled'} 4{'Efficient Aggressive'} default{"Unknown ($bval)"} }
15684    "Turbo boost mode:     $bname"
15685}
15686"#;
15687    match run_powershell(ps_proc) {
15688        Ok(o) if !o.trim().is_empty() => {
15689            for line in o.lines() {
15690                let l = line.trim();
15691                if !l.is_empty() {
15692                    out.push_str(&format!("- {l}\n"));
15693                }
15694            }
15695        }
15696        _ => out.push_str("- Could not query processor performance settings\n"),
15697    }
15698
15699    // Current CPU frequency via WMI
15700    out.push_str("\n=== CPU frequency ===\n");
15701    let ps_freq = r#"
15702Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 4 |
15703ForEach-Object {
15704    $cur = $_.CurrentClockSpeed
15705    $max = $_.MaxClockSpeed
15706    $load = $_.LoadPercentage
15707    "$($_.Name.Trim()) | Current: ${cur} MHz | Max: ${max} MHz | Load: ${load}%"
15708}
15709"#;
15710    match run_powershell(ps_freq) {
15711        Ok(o) if !o.trim().is_empty() => {
15712            for line in o.lines() {
15713                let l = line.trim();
15714                if !l.is_empty() {
15715                    out.push_str(&format!("- {l}\n"));
15716                }
15717            }
15718        }
15719        _ => out.push_str("- Could not query CPU frequency via WMI\n"),
15720    }
15721
15722    // Throttle reason from ETW (quick check)
15723    out.push_str("\n=== Throttling indicators ===\n");
15724    let ps_throttle = r#"
15725$pwr = Get-CimInstance -Namespace root\wmi -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue
15726if ($pwr) {
15727    $pwr | Select-Object -First 4 | ForEach-Object {
15728        $c = [Math]::Round(($_.CurrentTemperature / 10.0) - 273.15, 1)
15729        "Thermal zone $($_.InstanceName): ${c}°C"
15730    }
15731} else { "Thermal zone WMI not available (normal on consumer hardware)" }
15732"#;
15733    match run_powershell(ps_throttle) {
15734        Ok(o) if !o.trim().is_empty() => {
15735            for line in o.lines() {
15736                let l = line.trim();
15737                if !l.is_empty() {
15738                    out.push_str(&format!("- {l}\n"));
15739                }
15740            }
15741        }
15742        _ => out.push_str("- Thermal zone info unavailable\n"),
15743    }
15744
15745    let mut findings: Vec<String> = Vec::new();
15746    if out.contains("Max processor state:  0%") || out.contains("Max processor state:  1%") {
15747        findings.push("Max processor state is near 0% — CPU is being hard-capped by the power plan. Check power plan settings.".into());
15748    }
15749    if out.contains("Turbo boost mode:     Disabled") {
15750        findings.push("Turbo Boost is disabled in the active power plan — CPU cannot exceed base clock speed.".into());
15751    }
15752    if out.contains("Min processor state:  100%") {
15753        findings.push("Min processor state is 100% — CPU is pinned at max clock. Good for performance, increases power/heat.".into());
15754    }
15755
15756    let mut result = String::from("Host inspection: cpu_power\n\n=== Findings ===\n");
15757    if findings.is_empty() {
15758        result.push_str("- CPU power and frequency settings appear normal.\n");
15759    } else {
15760        for f in &findings {
15761            result.push_str(&format!("- Finding: {f}\n"));
15762        }
15763    }
15764    result.push('\n');
15765    result.push_str(&out);
15766    Ok(result)
15767}
15768
15769#[cfg(windows)]
15770fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
15771    let mut out = String::new();
15772
15773    out.push_str("=== Credential vault summary ===\n");
15774    let ps_summary = r#"
15775$raw = cmdkey /list 2>&1
15776$lines = $raw -split "`n"
15777$total = ($lines | Where-Object { $_ -match "Target:" }).Count
15778"Total stored credentials: $total"
15779$windows = ($lines | Where-Object { $_ -match "Type: Windows" }).Count
15780$generic = ($lines | Where-Object { $_ -match "Type: Generic" }).Count
15781$cert    = ($lines | Where-Object { $_ -match "Type: Certificate" }).Count
15782"  Windows credentials: $windows"
15783"  Generic credentials: $generic"
15784"  Certificate-based:   $cert"
15785"#;
15786    match run_powershell(ps_summary) {
15787        Ok(o) => {
15788            for line in o.lines() {
15789                let l = line.trim();
15790                if !l.is_empty() {
15791                    out.push_str(&format!("- {l}\n"));
15792                }
15793            }
15794        }
15795        Err(e) => out.push_str(&format!("- Credential summary error: {e}\n")),
15796    }
15797
15798    out.push_str("\n=== Credential targets (up to 20) ===\n");
15799    let ps_list = r#"
15800$raw = cmdkey /list 2>&1
15801$entries = @(); $cur = @{}
15802foreach ($line in ($raw -split "`n")) {
15803    $l = $line.Trim()
15804    if     ($l -match "^Target:\s*(.+)")  { $cur = @{ Target=$Matches[1] } }
15805    elseif ($l -match "^Type:\s*(.+)"   -and $cur.Target) { $cur.Type=$Matches[1] }
15806    elseif ($l -match "^User:\s*(.+)"   -and $cur.Target) { $cur.User=$Matches[1]; $entries+=$cur; $cur=@{} }
15807}
15808$entries | Select-Object -Last 20 | ForEach-Object {
15809    "[$($_.Type)] $($_.Target)  (user: $($_.User))"
15810}
15811"#;
15812    match run_powershell(ps_list) {
15813        Ok(o) => {
15814            let lines: Vec<&str> = o
15815                .lines()
15816                .map(|l| l.trim())
15817                .filter(|l| !l.is_empty())
15818                .collect();
15819            if lines.is_empty() {
15820                out.push_str("- No credential entries found\n");
15821            } else {
15822                for l in &lines {
15823                    out.push_str(&format!("- {l}\n"));
15824                }
15825            }
15826        }
15827        Err(e) => out.push_str(&format!("- Credential list error: {e}\n")),
15828    }
15829
15830    let total_creds: usize = {
15831        let ps_count = r#"(cmdkey /list 2>&1 | Select-String "Target:").Count"#;
15832        run_powershell(ps_count)
15833            .ok()
15834            .and_then(|s| s.trim().parse().ok())
15835            .unwrap_or(0)
15836    };
15837
15838    let mut findings: Vec<String> = Vec::new();
15839    if total_creds > 30 {
15840        findings.push(format!(
15841            "{total_creds} stored credentials found — consider auditing for stale entries."
15842        ));
15843    }
15844
15845    let mut result = String::from("Host inspection: credentials\n\n=== Findings ===\n");
15846    if findings.is_empty() {
15847        result.push_str("- Credential store looks normal.\n");
15848    } else {
15849        for f in &findings {
15850            result.push_str(&format!("- Finding: {f}\n"));
15851        }
15852    }
15853    result.push('\n');
15854    result.push_str(&out);
15855    Ok(result)
15856}
15857
15858#[cfg(not(windows))]
15859fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
15860    Ok("Host inspection: credentials\n\n=== Findings ===\n- Credential Manager is Windows-only. Use `secret-tool` or `pass` on Linux.\n".into())
15861}
15862
15863#[cfg(windows)]
15864fn inspect_tpm() -> Result<String, String> {
15865    let mut out = String::new();
15866
15867    out.push_str("=== TPM state ===\n");
15868    let ps_tpm = r#"
15869function Emit-Field([string]$Name, $Value, [string]$Fallback = "Unknown") {
15870    $text = if ($null -eq $Value) { "" } else { [string]$Value }
15871    if ([string]::IsNullOrWhiteSpace($text)) { $text = $Fallback }
15872    "$Name$text"
15873}
15874$t = Get-Tpm -ErrorAction SilentlyContinue
15875if ($t) {
15876    Emit-Field "TpmPresent:          " $t.TpmPresent
15877    Emit-Field "TpmReady:            " $t.TpmReady
15878    Emit-Field "TpmEnabled:          " $t.TpmEnabled
15879    Emit-Field "TpmOwned:            " $t.TpmOwned
15880    Emit-Field "RestartPending:      " $t.RestartPending
15881    Emit-Field "ManufacturerIdTxt:   " $t.ManufacturerIdTxt
15882    Emit-Field "ManufacturerVersion: " $t.ManufacturerVersion
15883} else { "TPM module unavailable" }
15884"#;
15885    match run_powershell(ps_tpm) {
15886        Ok(o) => {
15887            for line in o.lines() {
15888                let l = line.trim();
15889                if !l.is_empty() {
15890                    out.push_str(&format!("- {l}\n"));
15891                }
15892            }
15893        }
15894        Err(e) => out.push_str(&format!("- Get-Tpm error: {e}\n")),
15895    }
15896
15897    out.push_str("\n=== TPM spec version (WMI) ===\n");
15898    let ps_spec = r#"
15899$wmi = Get-CimInstance -Namespace root\cimv2\security\microsofttpm -ClassName Win32_Tpm -ErrorAction SilentlyContinue
15900if ($wmi) {
15901    $spec = if ([string]::IsNullOrWhiteSpace([string]$wmi.SpecVersion)) { "Unknown" } else { [string]$wmi.SpecVersion }
15902    "SpecVersion:  $spec"
15903    "IsActivated:  $(if ($null -eq $wmi.IsActivated_InitialValue) { 'Unknown' } else { $wmi.IsActivated_InitialValue })"
15904    "IsEnabled:    $(if ($null -eq $wmi.IsEnabled_InitialValue) { 'Unknown' } else { $wmi.IsEnabled_InitialValue })"
15905    "IsOwned:      $(if ($null -eq $wmi.IsOwned_InitialValue) { 'Unknown' } else { $wmi.IsOwned_InitialValue })"
15906} else { "Win32_Tpm WMI class unavailable (may need elevation or no TPM)" }
15907"#;
15908    match run_powershell(ps_spec) {
15909        Ok(o) => {
15910            for line in o.lines() {
15911                let l = line.trim();
15912                if !l.is_empty() {
15913                    out.push_str(&format!("- {l}\n"));
15914                }
15915            }
15916        }
15917        Err(e) => out.push_str(&format!("- Win32_Tpm WMI error: {e}\n")),
15918    }
15919
15920    out.push_str("\n=== Secure Boot state ===\n");
15921    let ps_sb = r#"
15922try {
15923    $sb = Confirm-SecureBootUEFI -ErrorAction Stop
15924    if ($sb) { "Secure Boot: ENABLED" } else { "Secure Boot: DISABLED" }
15925} catch {
15926    $msg = $_.Exception.Message
15927    if ($msg -match "Access was denied" -or $msg -match "proper privileges") {
15928        "Secure Boot: Unknown (administrator privileges required)"
15929    } elseif ($msg -match "Cmdlet not supported on this platform") {
15930        "Secure Boot: N/A (Legacy BIOS or unsupported firmware)"
15931    } else {
15932        "Secure Boot: N/A ($msg)"
15933    }
15934}
15935"#;
15936    match run_powershell(ps_sb) {
15937        Ok(o) => {
15938            for line in o.lines() {
15939                let l = line.trim();
15940                if !l.is_empty() {
15941                    out.push_str(&format!("- {l}\n"));
15942                }
15943            }
15944        }
15945        Err(e) => out.push_str(&format!("- Secure Boot check error: {e}\n")),
15946    }
15947
15948    out.push_str("\n=== Firmware type ===\n");
15949    let ps_fw = r#"
15950$fw = (Get-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Control -Name "PEFirmwareType" -ErrorAction SilentlyContinue).PEFirmwareType
15951switch ($fw) {
15952    1 { "Firmware type: BIOS (Legacy)" }
15953    2 { "Firmware type: UEFI" }
15954    default {
15955        $bcd = bcdedit /enum firmware 2>$null
15956        if ($LASTEXITCODE -eq 0 -and $bcd) { "Firmware type: UEFI (bcdedit fallback)" }
15957        else { "Firmware type: Unknown or not set" }
15958    }
15959}
15960"#;
15961    match run_powershell(ps_fw) {
15962        Ok(o) => {
15963            for line in o.lines() {
15964                let l = line.trim();
15965                if !l.is_empty() {
15966                    out.push_str(&format!("- {l}\n"));
15967                }
15968            }
15969        }
15970        Err(e) => out.push_str(&format!("- Firmware type error: {e}\n")),
15971    }
15972
15973    let mut findings: Vec<String> = Vec::new();
15974    let mut indeterminate = false;
15975    if out.contains("TpmPresent:          False") {
15976        findings.push("No TPM detected — BitLocker hardware encryption and Windows 11 security features unavailable.".into());
15977    }
15978    if out.contains("TpmReady:            False") {
15979        findings.push(
15980            "TPM present but not ready — may need initialization in BIOS/UEFI settings.".into(),
15981        );
15982    }
15983    if out.contains("SpecVersion:  1.2") {
15984        findings.push("TPM 1.2 detected — Windows 11 requires TPM 2.0.".into());
15985    }
15986    if out.contains("Secure Boot: DISABLED") {
15987        findings.push("Secure Boot is disabled — recommended to enable in UEFI firmware for Windows 11 compliance.".into());
15988    }
15989    if out.contains("Firmware type: BIOS (Legacy)") {
15990        findings.push(
15991            "Legacy BIOS detected — Secure Boot and modern TPM require UEFI firmware.".into(),
15992        );
15993    }
15994
15995    if out.contains("TPM module unavailable")
15996        || out.contains("Win32_Tpm WMI class unavailable")
15997        || out.contains("Secure Boot: N/A")
15998        || out.contains("Secure Boot: Unknown")
15999        || out.contains("Firmware type: Unknown or not set")
16000        || out.contains("TpmPresent:          Unknown")
16001        || out.contains("TpmReady:            Unknown")
16002        || out.contains("TpmEnabled:          Unknown")
16003    {
16004        indeterminate = true;
16005    }
16006    if indeterminate {
16007        findings.push(
16008            "TPM / Secure Boot state could not be fully determined from this session - firmware mode, privileges, or Windows TPM providers may be limiting visibility."
16009                .into(),
16010        );
16011    }
16012
16013    let mut result = String::from("Host inspection: tpm\n\n=== Findings ===\n");
16014    if findings.is_empty() {
16015        result.push_str("- TPM and Secure Boot appear healthy.\n");
16016    } else {
16017        for f in &findings {
16018            result.push_str(&format!("- Finding: {f}\n"));
16019        }
16020    }
16021    result.push('\n');
16022    result.push_str(&out);
16023    Ok(result)
16024}
16025
16026#[cfg(not(windows))]
16027fn inspect_tpm() -> Result<String, String> {
16028    Ok(
16029        "Host inspection: tpm\n\n=== Findings ===\n- TPM/Secure Boot inspection is Windows-only.\n"
16030            .into(),
16031    )
16032}
16033
16034#[cfg(windows)]
16035fn inspect_latency() -> Result<String, String> {
16036    let mut out = String::new();
16037
16038    // Resolve default gateway from the routing table
16039    let ps_gw = r#"
16040$gw = (Get-NetRoute -DestinationPrefix "0.0.0.0/0" -ErrorAction SilentlyContinue |
16041       Sort-Object RouteMetric | Select-Object -First 1).NextHop
16042if ($gw) { $gw } else { "" }
16043"#;
16044    let gateway = run_powershell(ps_gw)
16045        .ok()
16046        .map(|s| s.trim().to_string())
16047        .filter(|s| !s.is_empty());
16048
16049    let targets: Vec<(&str, String)> = {
16050        let mut t = Vec::new();
16051        if let Some(ref gw) = gateway {
16052            t.push(("Default gateway", gw.clone()));
16053        }
16054        t.push(("Cloudflare DNS", "1.1.1.1".into()));
16055        t.push(("Google DNS", "8.8.8.8".into()));
16056        t
16057    };
16058
16059    let mut findings: Vec<String> = Vec::new();
16060
16061    for (label, host) in &targets {
16062        out.push_str(&format!("\n=== Ping: {label} ({host}) ===\n"));
16063        // Test-NetConnection gives RTT; -InformationLevel Quiet just returns bool, so use ping
16064        let ps_ping = format!(
16065            r#"
16066$r = Test-Connection -ComputerName "{host}" -Count 4 -ErrorAction SilentlyContinue
16067if ($r) {{
16068    $rtts = $r | ForEach-Object {{ $_.ResponseTime }}
16069    $min  = ($rtts | Measure-Object -Minimum).Minimum
16070    $max  = ($rtts | Measure-Object -Maximum).Maximum
16071    $avg  = [Math]::Round(($rtts | Measure-Object -Average).Average, 1)
16072    $loss = [Math]::Round((4 - $r.Count) / 4 * 100)
16073    "RTT min/avg/max: ${{min}}ms / ${{avg}}ms / ${{max}}ms"
16074    "Packet loss: ${{loss}}%"
16075    "Sent: 4  Received: $($r.Count)"
16076}} else {{
16077    "UNREACHABLE — 100% packet loss"
16078}}
16079"#
16080        );
16081        match run_powershell(&ps_ping) {
16082            Ok(o) => {
16083                let body = o.trim().to_string();
16084                for line in body.lines() {
16085                    let l = line.trim();
16086                    if !l.is_empty() {
16087                        out.push_str(&format!("- {l}\n"));
16088                    }
16089                }
16090                if body.contains("UNREACHABLE") {
16091                    findings.push(format!(
16092                        "{label} ({host}) is unreachable — possible routing or firewall issue."
16093                    ));
16094                } else if let Some(loss_line) = body.lines().find(|l| l.contains("Packet loss")) {
16095                    let pct: u32 = loss_line
16096                        .chars()
16097                        .filter(|c| c.is_ascii_digit())
16098                        .collect::<String>()
16099                        .parse()
16100                        .unwrap_or(0);
16101                    if pct >= 25 {
16102                        findings.push(format!("{label} ({host}): {pct}% packet loss detected — possible network instability."));
16103                    }
16104                    // High latency check
16105                    if let Some(rtt_line) = body.lines().find(|l| l.contains("RTT min/avg/max")) {
16106                        // parse avg from "RTT min/avg/max: Xms / Yms / Zms"
16107                        let parts: Vec<&str> = rtt_line.split('/').collect();
16108                        if parts.len() >= 2 {
16109                            let avg_str: String =
16110                                parts[1].chars().filter(|c| c.is_ascii_digit()).collect();
16111                            let avg: u32 = avg_str.parse().unwrap_or(0);
16112                            if avg > 150 {
16113                                findings.push(format!("{label} ({host}): high average RTT ({avg}ms) — check for congestion or routing issues."));
16114                            }
16115                        }
16116                    }
16117                }
16118            }
16119            Err(e) => out.push_str(&format!("- Ping error: {e}\n")),
16120        }
16121    }
16122
16123    let mut result = String::from("Host inspection: latency\n\n=== Findings ===\n");
16124    if findings.is_empty() {
16125        result.push_str("- Latency and reachability look normal.\n");
16126    } else {
16127        for f in &findings {
16128            result.push_str(&format!("- Finding: {f}\n"));
16129        }
16130    }
16131    result.push('\n');
16132    result.push_str(&out);
16133    Ok(result)
16134}
16135
16136#[cfg(not(windows))]
16137fn inspect_latency() -> Result<String, String> {
16138    let mut out = String::from("Host inspection: latency\n\n=== Findings ===\n");
16139    let targets = [("Cloudflare DNS", "1.1.1.1"), ("Google DNS", "8.8.8.8")];
16140    let mut findings: Vec<String> = Vec::new();
16141
16142    for (label, host) in &targets {
16143        out.push_str(&format!("\n=== Ping: {label} ({host}) ===\n"));
16144        let ping = std::process::Command::new("ping")
16145            .args(["-c", "4", "-W", "2", host])
16146            .output();
16147        match ping {
16148            Ok(o) => {
16149                let body = String::from_utf8_lossy(&o.stdout).into_owned();
16150                for line in body.lines() {
16151                    let l = line.trim();
16152                    if l.contains("ms") || l.contains("loss") || l.contains("transmitted") {
16153                        out.push_str(&format!("- {l}\n"));
16154                    }
16155                }
16156                if body.contains("100% packet loss") || body.contains("100.0% packet loss") {
16157                    findings.push(format!("{label} ({host}) is unreachable."));
16158                }
16159            }
16160            Err(e) => out.push_str(&format!("- ping error: {e}\n")),
16161        }
16162    }
16163
16164    if findings.is_empty() {
16165        out.insert_str(
16166            "Host inspection: latency\n\n=== Findings ===\n".len(),
16167            "- Latency and reachability look normal.\n",
16168        );
16169    } else {
16170        let mut prefix = String::new();
16171        for f in &findings {
16172            prefix.push_str(&format!("- Finding: {f}\n"));
16173        }
16174        out.insert_str(
16175            "Host inspection: latency\n\n=== Findings ===\n".len(),
16176            &prefix,
16177        );
16178    }
16179    Ok(out)
16180}
16181
16182#[cfg(windows)]
16183fn inspect_network_adapter() -> Result<String, String> {
16184    let mut out = String::new();
16185
16186    out.push_str("=== Network adapters ===\n");
16187    let ps_adapters = r#"
16188Get-NetAdapter | Sort-Object Status,Name | ForEach-Object {
16189    $speed = if ($_.LinkSpeed) { $_.LinkSpeed } else { "Unknown" }
16190    "$($_.Name) | Status: $($_.Status) | Speed: $speed | MAC: $($_.MacAddress) | Driver: $($_.DriverVersion)"
16191}
16192"#;
16193    match run_powershell(ps_adapters) {
16194        Ok(o) => {
16195            for line in o.lines() {
16196                let l = line.trim();
16197                if !l.is_empty() {
16198                    out.push_str(&format!("- {l}\n"));
16199                }
16200            }
16201        }
16202        Err(e) => out.push_str(&format!("- Adapter query error: {e}\n")),
16203    }
16204
16205    out.push_str("\n=== Duplex and negotiated speed ===\n");
16206    let ps_duplex = r#"
16207Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16208    $name = $_.Name
16209    $duplex = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
16210        Where-Object { $_.DisplayName -match "Duplex|Speed" } |
16211        Select-Object DisplayName, DisplayValue
16212    if ($duplex) {
16213        "--- $name ---"
16214        $duplex | ForEach-Object { "  $($_.DisplayName): $($_.DisplayValue)" }
16215    } else {
16216        "--- $name --- (no duplex/speed property exposed by driver)"
16217    }
16218}
16219"#;
16220    match run_powershell(ps_duplex) {
16221        Ok(o) => {
16222            let lines: Vec<&str> = o
16223                .lines()
16224                .map(|l| l.trim())
16225                .filter(|l| !l.is_empty())
16226                .collect();
16227            for l in &lines {
16228                out.push_str(&format!("- {l}\n"));
16229            }
16230        }
16231        Err(e) => out.push_str(&format!("- Duplex query error: {e}\n")),
16232    }
16233
16234    out.push_str("\n=== Offload and performance settings (Up adapters) ===\n");
16235    let ps_offload = r#"
16236Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16237    $name = $_.Name
16238    $props = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
16239        Where-Object { $_.DisplayName -match "Offload|RSS|Jumbo|Buffer|Flow|Interrupt|Checksum|Large Send" } |
16240        Select-Object DisplayName, DisplayValue
16241    if ($props) {
16242        "--- $name ---"
16243        $props | ForEach-Object { "  $($_.DisplayName): $($_.DisplayValue)" }
16244    }
16245}
16246"#;
16247    match run_powershell(ps_offload) {
16248        Ok(o) => {
16249            let lines: Vec<&str> = o
16250                .lines()
16251                .map(|l| l.trim())
16252                .filter(|l| !l.is_empty())
16253                .collect();
16254            if lines.is_empty() {
16255                out.push_str(
16256                    "- No offload settings exposed by driver (common on virtual/Wi-Fi adapters)\n",
16257                );
16258            } else {
16259                for l in &lines {
16260                    out.push_str(&format!("- {l}\n"));
16261                }
16262            }
16263        }
16264        Err(e) => out.push_str(&format!("- Offload query error: {e}\n")),
16265    }
16266
16267    out.push_str("\n=== Adapter error counters ===\n");
16268    let ps_errors = r#"
16269Get-NetAdapterStatistics | ForEach-Object {
16270    $errs = $_.ReceivedPacketErrors + $_.OutboundPacketErrors + $_.ReceivedDiscardedPackets + $_.OutboundDiscardedPackets
16271    if ($errs -gt 0) {
16272        "$($_.Name) | RX errors: $($_.ReceivedPacketErrors) | TX errors: $($_.OutboundPacketErrors) | RX discards: $($_.ReceivedDiscardedPackets) | TX discards: $($_.OutboundDiscardedPackets)"
16273    }
16274}
16275"#;
16276    match run_powershell(ps_errors) {
16277        Ok(o) => {
16278            let lines: Vec<&str> = o
16279                .lines()
16280                .map(|l| l.trim())
16281                .filter(|l| !l.is_empty())
16282                .collect();
16283            if lines.is_empty() {
16284                out.push_str("- No adapter errors or discards detected.\n");
16285            } else {
16286                for l in &lines {
16287                    out.push_str(&format!("- {l}\n"));
16288                }
16289            }
16290        }
16291        Err(e) => out.push_str(&format!("- Error counter query: {e}\n")),
16292    }
16293
16294    out.push_str("\n=== Wake-on-LAN and power settings ===\n");
16295    let ps_wol = r#"
16296Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16297    $wol = Get-NetAdapterPowerManagement -Name $_.Name -ErrorAction SilentlyContinue
16298    if ($wol) {
16299        "$($_.Name) | WakeOnMagicPacket: $($wol.WakeOnMagicPacket) | AllowComputerToTurnOffDevice: $($wol.AllowComputerToTurnOffDevice)"
16300    }
16301}
16302"#;
16303    match run_powershell(ps_wol) {
16304        Ok(o) => {
16305            let lines: Vec<&str> = o
16306                .lines()
16307                .map(|l| l.trim())
16308                .filter(|l| !l.is_empty())
16309                .collect();
16310            if lines.is_empty() {
16311                out.push_str("- Power management data unavailable for active adapters.\n");
16312            } else {
16313                for l in &lines {
16314                    out.push_str(&format!("- {l}\n"));
16315                }
16316            }
16317        }
16318        Err(e) => out.push_str(&format!("- WoL query error: {e}\n")),
16319    }
16320
16321    let mut findings: Vec<String> = Vec::new();
16322    // Check for error-prone adapters
16323    if out.contains("RX errors:") || out.contains("TX errors:") {
16324        findings
16325            .push("Adapter errors detected — check cabling, driver, or duplex mismatch.".into());
16326    }
16327    // Check for half-duplex (rare but still seen on older switches)
16328    if out.contains("Half") {
16329        findings.push("Half-duplex adapter detected — likely a duplex mismatch with the switch; set both sides to full-duplex.".into());
16330    }
16331
16332    let mut result = String::from("Host inspection: network_adapter\n\n=== Findings ===\n");
16333    if findings.is_empty() {
16334        result.push_str("- Network adapter configuration looks normal.\n");
16335    } else {
16336        for f in &findings {
16337            result.push_str(&format!("- Finding: {f}\n"));
16338        }
16339    }
16340    result.push('\n');
16341    result.push_str(&out);
16342    Ok(result)
16343}
16344
16345#[cfg(not(windows))]
16346fn inspect_network_adapter() -> Result<String, String> {
16347    let mut out = String::from("Host inspection: network_adapter\n\n=== Findings ===\n- Network adapter inspection running on Unix.\n\n");
16348
16349    out.push_str("=== Network adapters (ip link) ===\n");
16350    let ip_link = std::process::Command::new("ip")
16351        .args(["link", "show"])
16352        .output();
16353    if let Ok(o) = ip_link {
16354        for line in String::from_utf8_lossy(&o.stdout).lines() {
16355            let l = line.trim();
16356            if !l.is_empty() {
16357                out.push_str(&format!("- {l}\n"));
16358            }
16359        }
16360    }
16361
16362    out.push_str("\n=== Adapter statistics (ip -s link) ===\n");
16363    let ip_stats = std::process::Command::new("ip")
16364        .args(["-s", "link", "show"])
16365        .output();
16366    if let Ok(o) = ip_stats {
16367        for line in String::from_utf8_lossy(&o.stdout).lines() {
16368            let l = line.trim();
16369            if l.contains("RX") || l.contains("TX") || l.contains("errors") || l.contains("dropped")
16370            {
16371                out.push_str(&format!("- {l}\n"));
16372            }
16373        }
16374    }
16375    Ok(out)
16376}
16377
16378#[cfg(windows)]
16379fn inspect_dhcp() -> Result<String, String> {
16380    let mut out = String::new();
16381
16382    out.push_str("=== DHCP lease details (per adapter) ===\n");
16383    let ps_dhcp = r#"
16384$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
16385    Where-Object { $_.IPEnabled -eq $true }
16386foreach ($a in $adapters) {
16387    "--- $($a.Description) ---"
16388    "  DHCP Enabled:      $($a.DHCPEnabled)"
16389    if ($a.DHCPEnabled) {
16390        "  DHCP Server:       $($a.DHCPServer)"
16391        $obtained = $a.ConvertToDateTime($a.DHCPLeaseObtained) 2>$null
16392        $expires  = $a.ConvertToDateTime($a.DHCPLeaseExpires)  2>$null
16393        "  Lease Obtained:    $obtained"
16394        "  Lease Expires:     $expires"
16395    }
16396    "  IP Address:        $($a.IPAddress -join ', ')"
16397    "  Subnet Mask:       $($a.IPSubnet -join ', ')"
16398    "  Default Gateway:   $($a.DefaultIPGateway -join ', ')"
16399    "  DNS Servers:       $($a.DNSServerSearchOrder -join ', ')"
16400    "  MAC Address:       $($a.MACAddress)"
16401    ""
16402}
16403"#;
16404    match run_powershell(ps_dhcp) {
16405        Ok(o) => {
16406            for line in o.lines() {
16407                let l = line.trim_end();
16408                if !l.is_empty() {
16409                    out.push_str(&format!("{l}\n"));
16410                }
16411            }
16412        }
16413        Err(e) => out.push_str(&format!("- DHCP query error: {e}\n")),
16414    }
16415
16416    // Findings: check for expired or very-soon-expiring leases
16417    let mut findings: Vec<String> = Vec::new();
16418    let ps_expiry = r#"
16419$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object { $_.DHCPEnabled -and $_.IPEnabled }
16420foreach ($a in $adapters) {
16421    try {
16422        $exp = $a.ConvertToDateTime($a.DHCPLeaseExpires)
16423        $now = Get-Date
16424        $hrs = ($exp - $now).TotalHours
16425        if ($hrs -lt 0) { "$($a.Description): EXPIRED" }
16426        elseif ($hrs -lt 2) { "$($a.Description): expires in $([Math]::Round($hrs,1)) hours" }
16427    } catch {}
16428}
16429"#;
16430    if let Ok(o) = run_powershell(ps_expiry) {
16431        for line in o.lines() {
16432            let l = line.trim();
16433            if !l.is_empty() {
16434                if l.contains("EXPIRED") {
16435                    findings.push(format!("DHCP lease EXPIRED on adapter: {l}"));
16436                } else if l.contains("expires in") {
16437                    findings.push(format!("DHCP lease expiring soon — {l}"));
16438                }
16439            }
16440        }
16441    }
16442
16443    let mut result = String::from("Host inspection: dhcp\n\n=== Findings ===\n");
16444    if findings.is_empty() {
16445        result.push_str("- DHCP leases look healthy.\n");
16446    } else {
16447        for f in &findings {
16448            result.push_str(&format!("- Finding: {f}\n"));
16449        }
16450    }
16451    result.push('\n');
16452    result.push_str(&out);
16453    Ok(result)
16454}
16455
16456#[cfg(not(windows))]
16457fn inspect_dhcp() -> Result<String, String> {
16458    let mut out = String::from(
16459        "Host inspection: dhcp\n\n=== Findings ===\n- DHCP lease inspection running on Unix.\n\n",
16460    );
16461    out.push_str("=== DHCP leases (dhclient / NetworkManager) ===\n");
16462    for path in &["/var/lib/dhcp/dhclient.leases", "/var/lib/NetworkManager"] {
16463        if std::path::Path::new(path).exists() {
16464            let cat = std::process::Command::new("cat").arg(path).output();
16465            if let Ok(o) = cat {
16466                let text = String::from_utf8_lossy(&o.stdout);
16467                for line in text.lines().take(40) {
16468                    let l = line.trim();
16469                    if l.contains("lease")
16470                        || l.contains("expire")
16471                        || l.contains("server")
16472                        || l.contains("address")
16473                    {
16474                        out.push_str(&format!("- {l}\n"));
16475                    }
16476                }
16477            }
16478        }
16479    }
16480    // Also try ip addr for current IPs
16481    let ip = std::process::Command::new("ip")
16482        .args(["addr", "show"])
16483        .output();
16484    if let Ok(o) = ip {
16485        out.push_str("\n=== Current IP addresses (ip addr) ===\n");
16486        for line in String::from_utf8_lossy(&o.stdout).lines() {
16487            let l = line.trim();
16488            if l.starts_with("inet") || l.contains("dynamic") {
16489                out.push_str(&format!("- {l}\n"));
16490            }
16491        }
16492    }
16493    Ok(out)
16494}
16495
16496#[cfg(windows)]
16497fn inspect_mtu() -> Result<String, String> {
16498    let mut out = String::new();
16499
16500    out.push_str("=== Per-adapter MTU (IPv4) ===\n");
16501    let ps_mtu = r#"
16502Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv4" } |
16503    Sort-Object ConnectionState, InterfaceAlias |
16504    ForEach-Object {
16505        "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
16506    }
16507"#;
16508    match run_powershell(ps_mtu) {
16509        Ok(o) => {
16510            for line in o.lines() {
16511                let l = line.trim();
16512                if !l.is_empty() {
16513                    out.push_str(&format!("- {l}\n"));
16514                }
16515            }
16516        }
16517        Err(e) => out.push_str(&format!("- MTU query error: {e}\n")),
16518    }
16519
16520    out.push_str("\n=== Per-adapter MTU (IPv6) ===\n");
16521    let ps_mtu6 = r#"
16522Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv6" } |
16523    Sort-Object ConnectionState, InterfaceAlias |
16524    ForEach-Object {
16525        "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
16526    }
16527"#;
16528    match run_powershell(ps_mtu6) {
16529        Ok(o) => {
16530            for line in o.lines() {
16531                let l = line.trim();
16532                if !l.is_empty() {
16533                    out.push_str(&format!("- {l}\n"));
16534                }
16535            }
16536        }
16537        Err(e) => out.push_str(&format!("- IPv6 MTU query error: {e}\n")),
16538    }
16539
16540    out.push_str("\n=== Path MTU discovery (ping DF-bit to 8.8.8.8) ===\n");
16541    // Send a 1472-byte payload (1500 - 28 IP+ICMP headers) to test standard Ethernet MTU
16542    let ps_pmtu = r#"
16543$sizes = @(1472, 1400, 1280, 576)
16544$result = $null
16545foreach ($s in $sizes) {
16546    $r = Test-Connection -ComputerName "8.8.8.8" -Count 1 -BufferSize $s -ErrorAction SilentlyContinue
16547    if ($r) { $result = $s; break }
16548}
16549if ($result) { "Largest successful payload: $result bytes (path MTU >= $($result + 28) bytes)" }
16550else { "All test sizes failed — path MTU may be very restricted or ICMP is blocked" }
16551"#;
16552    match run_powershell(ps_pmtu) {
16553        Ok(o) => {
16554            for line in o.lines() {
16555                let l = line.trim();
16556                if !l.is_empty() {
16557                    out.push_str(&format!("- {l}\n"));
16558                }
16559            }
16560        }
16561        Err(e) => out.push_str(&format!("- Path MTU test error: {e}\n")),
16562    }
16563
16564    let mut findings: Vec<String> = Vec::new();
16565    if out.contains("MTU: 576 bytes") {
16566        findings.push("576-byte MTU detected — severely restricted path, likely a misconfigured VPN or legacy link.".into());
16567    }
16568    if out.contains("MTU: 1280 bytes") && !out.contains("IPv6") {
16569        findings.push(
16570            "1280-byte MTU on an IPv4 interface is unusually low — check VPN or PPPoE config."
16571                .into(),
16572        );
16573    }
16574    if out.contains("All test sizes failed") {
16575        findings.push("Path MTU test failed — ICMP may be blocked by firewall or all tested sizes exceed the path limit.".into());
16576    }
16577
16578    let mut result = String::from("Host inspection: mtu\n\n=== Findings ===\n");
16579    if findings.is_empty() {
16580        result.push_str("- MTU configuration looks normal.\n");
16581    } else {
16582        for f in &findings {
16583            result.push_str(&format!("- Finding: {f}\n"));
16584        }
16585    }
16586    result.push('\n');
16587    result.push_str(&out);
16588    Ok(result)
16589}
16590
16591#[cfg(not(windows))]
16592fn inspect_mtu() -> Result<String, String> {
16593    let mut out = String::from(
16594        "Host inspection: mtu\n\n=== Findings ===\n- MTU inspection running on Unix.\n\n",
16595    );
16596
16597    out.push_str("=== Per-interface MTU (ip link) ===\n");
16598    let ip = std::process::Command::new("ip")
16599        .args(["link", "show"])
16600        .output();
16601    if let Ok(o) = ip {
16602        for line in String::from_utf8_lossy(&o.stdout).lines() {
16603            let l = line.trim();
16604            if l.contains("mtu") || l.starts_with("\\d") {
16605                out.push_str(&format!("- {l}\n"));
16606            }
16607        }
16608    }
16609
16610    out.push_str("\n=== Path MTU test (ping -M do to 8.8.8.8) ===\n");
16611    let ping = std::process::Command::new("ping")
16612        .args(["-c", "1", "-M", "do", "-s", "1472", "8.8.8.8"])
16613        .output();
16614    match ping {
16615        Ok(o) => {
16616            let body = String::from_utf8_lossy(&o.stdout);
16617            for line in body.lines() {
16618                let l = line.trim();
16619                if !l.is_empty() {
16620                    out.push_str(&format!("- {l}\n"));
16621                }
16622            }
16623        }
16624        Err(e) => out.push_str(&format!("- Ping error: {e}\n")),
16625    }
16626    Ok(out)
16627}
16628
16629#[cfg(not(windows))]
16630fn inspect_cpu_power() -> Result<String, String> {
16631    let mut out = String::from("Host inspection: cpu_power\n\n=== Findings ===\n- CPU power inspection running on Unix.\n\n");
16632
16633    // Linux: cpufreq-info or /sys/devices/system/cpu
16634    out.push_str("=== CPU frequency (Linux) ===\n");
16635    let cat_scaling = std::process::Command::new("cat")
16636        .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq")
16637        .output();
16638    if let Ok(o) = cat_scaling {
16639        let khz: u64 = String::from_utf8_lossy(&o.stdout)
16640            .trim()
16641            .parse()
16642            .unwrap_or(0);
16643        if khz > 0 {
16644            out.push_str(&format!("- Current: {} MHz\n", khz / 1000));
16645        }
16646    }
16647    let cat_max = std::process::Command::new("cat")
16648        .arg("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq")
16649        .output();
16650    if let Ok(o) = cat_max {
16651        let khz: u64 = String::from_utf8_lossy(&o.stdout)
16652            .trim()
16653            .parse()
16654            .unwrap_or(0);
16655        if khz > 0 {
16656            out.push_str(&format!("- Max: {} MHz\n", khz / 1000));
16657        }
16658    }
16659    let governor = std::process::Command::new("cat")
16660        .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
16661        .output();
16662    if let Ok(o) = governor {
16663        let g = String::from_utf8_lossy(&o.stdout);
16664        let g = g.trim();
16665        if !g.is_empty() {
16666            out.push_str(&format!("- Governor: {g}\n"));
16667        }
16668    }
16669    Ok(out)
16670}
16671
16672// ── IPv6 ────────────────────────────────────────────────────────────────────
16673
16674#[cfg(windows)]
16675fn inspect_ipv6() -> Result<String, String> {
16676    let script = r#"
16677$result = [System.Text.StringBuilder]::new()
16678
16679# Per-adapter IPv6 addresses
16680$result.AppendLine("=== IPv6 addresses per adapter ===") | Out-Null
16681$adapters = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16682    Where-Object { $_.IPAddress -notmatch '^::1$' } |
16683    Sort-Object InterfaceAlias
16684foreach ($a in $adapters) {
16685    $prefix = $a.PrefixOrigin
16686    $suffix = $a.SuffixOrigin
16687    $scope  = $a.AddressState
16688    $result.AppendLine("  [$($a.InterfaceAlias)] $($a.IPAddress)/$($a.PrefixLength)  origin=$prefix/$suffix  state=$scope") | Out-Null
16689}
16690if (-not $adapters) { $result.AppendLine("  No global/link-local IPv6 addresses found.") | Out-Null }
16691
16692# Default gateway IPv6
16693$result.AppendLine("") | Out-Null
16694$result.AppendLine("=== IPv6 default gateway ===") | Out-Null
16695$gw6 = Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue
16696if ($gw6) {
16697    foreach ($g in $gw6) {
16698        $result.AppendLine("  [$($g.InterfaceAlias)] via $($g.NextHop)  metric=$($g.RouteMetric)") | Out-Null
16699    }
16700} else {
16701    $result.AppendLine("  No IPv6 default gateway configured.") | Out-Null
16702}
16703
16704# DHCPv6 lease info
16705$result.AppendLine("") | Out-Null
16706$result.AppendLine("=== DHCPv6 / prefix delegation ===") | Out-Null
16707$dhcpv6 = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16708    Where-Object { $_.PrefixOrigin -eq 'Dhcp' -or $_.SuffixOrigin -eq 'Dhcp' }
16709if ($dhcpv6) {
16710    foreach ($d in $dhcpv6) {
16711        $result.AppendLine("  [$($d.InterfaceAlias)] $($d.IPAddress) (DHCPv6-assigned)") | Out-Null
16712    }
16713} else {
16714    $result.AppendLine("  No DHCPv6-assigned addresses found (SLAAC or static in use).") | Out-Null
16715}
16716
16717# Privacy extensions
16718$result.AppendLine("") | Out-Null
16719$result.AppendLine("=== Privacy extensions (RFC 4941) ===") | Out-Null
16720try {
16721    $priv = netsh interface ipv6 show privacy
16722    $result.AppendLine(($priv -join "`n")) | Out-Null
16723} catch {
16724    $result.AppendLine("  Could not retrieve privacy extension state.") | Out-Null
16725}
16726
16727# Tunnel adapters
16728$result.AppendLine("") | Out-Null
16729$result.AppendLine("=== Tunnel adapters ===") | Out-Null
16730$tunnels = Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceDescription -match 'Teredo|6to4|ISATAP|Tunnel' }
16731if ($tunnels) {
16732    foreach ($t in $tunnels) {
16733        $result.AppendLine("  $($t.Name): $($t.InterfaceDescription)  Status=$($t.Status)") | Out-Null
16734    }
16735} else {
16736    $result.AppendLine("  No Teredo/6to4/ISATAP tunnel adapters found.") | Out-Null
16737}
16738
16739# Findings
16740$findings = [System.Collections.Generic.List[string]]::new()
16741$globalAddrs = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16742    Where-Object { $_.IPAddress -match '^2[0-9a-f]{3}:' -or $_.IPAddress -match '^fc|^fd' }
16743if (-not $globalAddrs) { $findings.Add("No global unicast IPv6 address assigned — IPv6 internet access may be unavailable.") }
16744$noGw6 = -not (Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue)
16745if ($noGw6) { $findings.Add("No IPv6 default gateway — IPv6 routing not active.") }
16746
16747$result.AppendLine("") | Out-Null
16748$result.AppendLine("=== Findings ===") | Out-Null
16749if ($findings.Count -eq 0) {
16750    $result.AppendLine("- IPv6 configuration looks healthy.") | Out-Null
16751} else {
16752    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16753}
16754
16755Write-Output $result.ToString()
16756"#;
16757    let out = run_powershell(script)?;
16758    Ok(format!("Host inspection: ipv6\n\n{out}"))
16759}
16760
16761#[cfg(not(windows))]
16762fn inspect_ipv6() -> Result<String, String> {
16763    let mut out = String::from("Host inspection: ipv6\n\n=== IPv6 addresses (ip -6 addr) ===\n");
16764    if let Ok(o) = std::process::Command::new("ip")
16765        .args(["-6", "addr", "show"])
16766        .output()
16767    {
16768        out.push_str(&String::from_utf8_lossy(&o.stdout));
16769    }
16770    out.push_str("\n=== IPv6 routes (ip -6 route) ===\n");
16771    if let Ok(o) = std::process::Command::new("ip")
16772        .args(["-6", "route"])
16773        .output()
16774    {
16775        out.push_str(&String::from_utf8_lossy(&o.stdout));
16776    }
16777    Ok(out)
16778}
16779
16780// ── TCP Parameters ──────────────────────────────────────────────────────────
16781
16782#[cfg(windows)]
16783fn inspect_tcp_params() -> Result<String, String> {
16784    let script = r#"
16785$result = [System.Text.StringBuilder]::new()
16786
16787# Autotuning and global TCP settings
16788$result.AppendLine("=== TCP global settings (netsh) ===") | Out-Null
16789try {
16790    $global = netsh interface tcp show global
16791    foreach ($line in $global) {
16792        $l = $line.Trim()
16793        if ($l -and $l -notmatch '^---' -and $l -notmatch '^TCP Global') {
16794            $result.AppendLine("  $l") | Out-Null
16795        }
16796    }
16797} catch {
16798    $result.AppendLine("  Could not retrieve TCP global settings.") | Out-Null
16799}
16800
16801# Supplemental params via Get-NetTCPSetting
16802$result.AppendLine("") | Out-Null
16803$result.AppendLine("=== TCP settings profiles ===") | Out-Null
16804try {
16805    $tcpSettings = Get-NetTCPSetting -ErrorAction SilentlyContinue
16806    foreach ($s in $tcpSettings) {
16807        $result.AppendLine("  Profile: $($s.SettingName)") | Out-Null
16808        $result.AppendLine("    CongestionProvider:      $($s.CongestionProvider)") | Out-Null
16809        $result.AppendLine("    InitialCongestionWindow: $($s.InitialCongestionWindowMss) MSS") | Out-Null
16810        $result.AppendLine("    AutoTuningLevelLocal:    $($s.AutoTuningLevelLocal)") | Out-Null
16811        $result.AppendLine("    ScalingHeuristics:       $($s.ScalingHeuristics)") | Out-Null
16812        $result.AppendLine("    DynamicPortRangeStart:   $($s.DynamicPortRangeStartPort)") | Out-Null
16813        $result.AppendLine("    DynamicPortRangeEnd:     $($s.DynamicPortRangeStartPort + $s.DynamicPortRangeNumberOfPorts - 1)") | Out-Null
16814        $result.AppendLine("") | Out-Null
16815    }
16816} catch {
16817    $result.AppendLine("  Get-NetTCPSetting unavailable.") | Out-Null
16818}
16819
16820# Chimney offload state
16821$result.AppendLine("=== TCP Chimney offload ===") | Out-Null
16822try {
16823    $chimney = netsh interface tcp show chimney
16824    $result.AppendLine(($chimney -join "`n  ")) | Out-Null
16825} catch {
16826    $result.AppendLine("  Could not retrieve chimney state.") | Out-Null
16827}
16828
16829# ECN state
16830$result.AppendLine("") | Out-Null
16831$result.AppendLine("=== ECN capability ===") | Out-Null
16832try {
16833    $ecn = netsh interface tcp show ecncapability
16834    $result.AppendLine(($ecn -join "`n  ")) | Out-Null
16835} catch {
16836    $result.AppendLine("  Could not retrieve ECN state.") | Out-Null
16837}
16838
16839# Findings
16840$findings = [System.Collections.Generic.List[string]]::new()
16841try {
16842    $ts = Get-NetTCPSetting -SettingName 'Internet' -ErrorAction SilentlyContinue
16843    if ($ts -and $ts.AutoTuningLevelLocal -eq 'Disabled') {
16844        $findings.Add("TCP autotuning is DISABLED on the Internet profile — may limit throughput on high-latency links.")
16845    }
16846    if ($ts -and $ts.CongestionProvider -ne 'CUBIC' -and $ts.CongestionProvider -ne 'NewReno' -and $ts.CongestionProvider) {
16847        $findings.Add("Non-standard congestion provider: $($ts.CongestionProvider)")
16848    }
16849} catch {}
16850
16851$result.AppendLine("") | Out-Null
16852$result.AppendLine("=== Findings ===") | Out-Null
16853if ($findings.Count -eq 0) {
16854    $result.AppendLine("- TCP parameters look normal.") | Out-Null
16855} else {
16856    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16857}
16858
16859Write-Output $result.ToString()
16860"#;
16861    let out = run_powershell(script)?;
16862    Ok(format!("Host inspection: tcp_params\n\n{out}"))
16863}
16864
16865#[cfg(not(windows))]
16866fn inspect_tcp_params() -> Result<String, String> {
16867    let mut out = String::from("Host inspection: tcp_params\n\n=== TCP kernel parameters ===\n");
16868    for key in &[
16869        "net.ipv4.tcp_congestion_control",
16870        "net.ipv4.tcp_rmem",
16871        "net.ipv4.tcp_wmem",
16872        "net.ipv4.tcp_window_scaling",
16873        "net.ipv4.tcp_ecn",
16874        "net.ipv4.tcp_timestamps",
16875    ] {
16876        if let Ok(o) = std::process::Command::new("sysctl").arg(key).output() {
16877            out.push_str(&format!(
16878                "  {}\n",
16879                String::from_utf8_lossy(&o.stdout).trim()
16880            ));
16881        }
16882    }
16883    Ok(out)
16884}
16885
16886// ── WLAN Profiles ───────────────────────────────────────────────────────────
16887
16888#[cfg(windows)]
16889fn inspect_wlan_profiles() -> Result<String, String> {
16890    let script = r#"
16891$result = [System.Text.StringBuilder]::new()
16892
16893# List all saved profiles
16894$result.AppendLine("=== Saved wireless profiles ===") | Out-Null
16895try {
16896    $profilesRaw = netsh wlan show profiles
16897    $profiles = $profilesRaw | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
16898        $_.Matches[0].Groups[1].Value.Trim()
16899    }
16900
16901    if (-not $profiles) {
16902        $result.AppendLine("  No saved wireless profiles found.") | Out-Null
16903    } else {
16904        foreach ($p in $profiles) {
16905            $result.AppendLine("") | Out-Null
16906            $result.AppendLine("  Profile: $p") | Out-Null
16907            # Get detail for each profile
16908            $detail = netsh wlan show profile name="$p" key=clear 2>$null
16909            $auth      = ($detail | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
16910            $cipher    = ($detail | Select-String 'Cipher\s*:\s*(.+)') | Select-Object -First 1
16911            $conn      = ($detail | Select-String 'Connection mode\s*:\s*(.+)') | Select-Object -First 1
16912            $autoConn  = ($detail | Select-String 'Connect automatically\s*:\s*(.+)') | Select-Object -First 1
16913            if ($auth)     { $result.AppendLine("    Authentication:    $($auth.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16914            if ($cipher)   { $result.AppendLine("    Cipher:            $($cipher.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16915            if ($conn)     { $result.AppendLine("    Connection mode:   $($conn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16916            if ($autoConn) { $result.AppendLine("    Auto-connect:      $($autoConn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16917        }
16918    }
16919} catch {
16920    $result.AppendLine("  netsh wlan unavailable (no wireless adapter or WLAN service not running).") | Out-Null
16921}
16922
16923# Currently connected SSID
16924$result.AppendLine("") | Out-Null
16925$result.AppendLine("=== Currently connected ===") | Out-Null
16926try {
16927    $conn = netsh wlan show interfaces
16928    $ssid   = ($conn | Select-String 'SSID\s*:\s*(?!BSSID)(.+)') | Select-Object -First 1
16929    $bssid  = ($conn | Select-String 'BSSID\s*:\s*(.+)') | Select-Object -First 1
16930    $signal = ($conn | Select-String 'Signal\s*:\s*(.+)') | Select-Object -First 1
16931    $radio  = ($conn | Select-String 'Radio type\s*:\s*(.+)') | Select-Object -First 1
16932    if ($ssid)   { $result.AppendLine("  SSID:       $($ssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16933    if ($bssid)  { $result.AppendLine("  BSSID:      $($bssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16934    if ($signal) { $result.AppendLine("  Signal:     $($signal.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16935    if ($radio)  { $result.AppendLine("  Radio type: $($radio.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16936    if (-not $ssid) { $result.AppendLine("  Not connected to any wireless network.") | Out-Null }
16937} catch {
16938    $result.AppendLine("  Could not query wireless interface state.") | Out-Null
16939}
16940
16941# Findings
16942$findings = [System.Collections.Generic.List[string]]::new()
16943try {
16944    $allDetail = netsh wlan show profiles 2>$null
16945    $profileNames = $allDetail | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
16946        $_.Matches[0].Groups[1].Value.Trim()
16947    }
16948    foreach ($pn in $profileNames) {
16949        $det = netsh wlan show profile name="$pn" key=clear 2>$null
16950        $authLine = ($det | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
16951        if ($authLine) {
16952            $authVal = $authLine.Matches[0].Groups[1].Value.Trim()
16953            if ($authVal -match 'Open|WEP|None') {
16954                $findings.Add("Profile '$pn' uses weak/open authentication: $authVal")
16955            }
16956        }
16957    }
16958} catch {}
16959
16960$result.AppendLine("") | Out-Null
16961$result.AppendLine("=== Findings ===") | Out-Null
16962if ($findings.Count -eq 0) {
16963    $result.AppendLine("- All saved wireless profiles use acceptable authentication.") | Out-Null
16964} else {
16965    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16966}
16967
16968Write-Output $result.ToString()
16969"#;
16970    let out = run_powershell(script)?;
16971    Ok(format!("Host inspection: wlan_profiles\n\n{out}"))
16972}
16973
16974#[cfg(not(windows))]
16975fn inspect_wlan_profiles() -> Result<String, String> {
16976    let mut out =
16977        String::from("Host inspection: wlan_profiles\n\n=== Saved wireless profiles ===\n");
16978    // Try nmcli (NetworkManager)
16979    if let Ok(o) = std::process::Command::new("nmcli")
16980        .args(["-t", "-f", "NAME,TYPE,DEVICE", "connection", "show"])
16981        .output()
16982    {
16983        for line in String::from_utf8_lossy(&o.stdout).lines() {
16984            if line.contains("wireless") || line.contains("wifi") {
16985                out.push_str(&format!("  {line}\n"));
16986            }
16987        }
16988    } else {
16989        out.push_str("  nmcli not available.\n");
16990    }
16991    Ok(out)
16992}
16993
16994// ── IPSec ───────────────────────────────────────────────────────────────────
16995
16996#[cfg(windows)]
16997fn inspect_ipsec() -> Result<String, String> {
16998    let script = r#"
16999$result = [System.Text.StringBuilder]::new()
17000
17001# IPSec rules (firewall-integrated)
17002$result.AppendLine("=== IPSec connection security rules ===") | Out-Null
17003try {
17004    $rules = Get-NetIPsecRule -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq 'True' }
17005    if ($rules) {
17006        foreach ($r in $rules) {
17007            $result.AppendLine("  [$($r.DisplayName)]") | Out-Null
17008            $result.AppendLine("    Mode:       $($r.Mode)") | Out-Null
17009            $result.AppendLine("    Action:     $($r.Action)") | Out-Null
17010            $result.AppendLine("    InProfile:  $($r.Profile)") | Out-Null
17011        }
17012    } else {
17013        $result.AppendLine("  No enabled IPSec connection security rules found.") | Out-Null
17014    }
17015} catch {
17016    $result.AppendLine("  Get-NetIPsecRule unavailable.") | Out-Null
17017}
17018
17019# Active main-mode SAs
17020$result.AppendLine("") | Out-Null
17021$result.AppendLine("=== Active IPSec main-mode SAs ===") | Out-Null
17022try {
17023    $mmSAs = Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue
17024    if ($mmSAs) {
17025        foreach ($sa in $mmSAs) {
17026            $result.AppendLine("  Local: $($sa.LocalAddress)  <-->  Remote: $($sa.RemoteAddress)") | Out-Null
17027            $result.AppendLine("    AuthMethod: $($sa.LocalFirstId)  Cipher: $($sa.Cipher)") | Out-Null
17028        }
17029    } else {
17030        $result.AppendLine("  No active main-mode IPSec SAs.") | Out-Null
17031    }
17032} catch {
17033    $result.AppendLine("  Get-NetIPsecMainModeSA unavailable.") | Out-Null
17034}
17035
17036# Active quick-mode SAs
17037$result.AppendLine("") | Out-Null
17038$result.AppendLine("=== Active IPSec quick-mode SAs ===") | Out-Null
17039try {
17040    $qmSAs = Get-NetIPsecQuickModeSA -ErrorAction SilentlyContinue
17041    if ($qmSAs) {
17042        foreach ($sa in $qmSAs) {
17043            $result.AppendLine("  Local: $($sa.LocalAddress)  <-->  Remote: $($sa.RemoteAddress)") | Out-Null
17044            $result.AppendLine("    Encapsulation: $($sa.EncapsulationMode)  Protocol: $($sa.TransportLayerProtocol)") | Out-Null
17045        }
17046    } else {
17047        $result.AppendLine("  No active quick-mode IPSec SAs.") | Out-Null
17048    }
17049} catch {
17050    $result.AppendLine("  Get-NetIPsecQuickModeSA unavailable.") | Out-Null
17051}
17052
17053# IKE service state
17054$result.AppendLine("") | Out-Null
17055$result.AppendLine("=== IKE / IPSec Policy Agent service ===") | Out-Null
17056$ikeAgentSvc = Get-Service -Name 'PolicyAgent' -ErrorAction SilentlyContinue
17057if ($ikeAgentSvc) {
17058    $result.AppendLine("  PolicyAgent (IPSec Policy Agent): $($ikeAgentSvc.Status)") | Out-Null
17059} else {
17060    $result.AppendLine("  PolicyAgent service not found.") | Out-Null
17061}
17062
17063# Findings
17064$findings = [System.Collections.Generic.List[string]]::new()
17065$mmSACount = 0
17066try { $mmSACount = (Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue | Measure-Object).Count } catch {}
17067if ($mmSACount -gt 0) {
17068    $findings.Add("$mmSACount active IPSec main-mode SA(s) — IPSec tunnel is active.")
17069}
17070
17071$result.AppendLine("") | Out-Null
17072$result.AppendLine("=== Findings ===") | Out-Null
17073if ($findings.Count -eq 0) {
17074    $result.AppendLine("- No active IPSec SAs detected (no IPSec tunnel currently established).") | Out-Null
17075} else {
17076    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17077}
17078
17079Write-Output $result.ToString()
17080"#;
17081    let out = run_powershell(script)?;
17082    Ok(format!("Host inspection: ipsec\n\n{out}"))
17083}
17084
17085#[cfg(not(windows))]
17086fn inspect_ipsec() -> Result<String, String> {
17087    let mut out = String::from("Host inspection: ipsec\n\n=== IPSec SAs (ip xfrm state) ===\n");
17088    if let Ok(o) = std::process::Command::new("ip")
17089        .args(["xfrm", "state"])
17090        .output()
17091    {
17092        let body = String::from_utf8_lossy(&o.stdout);
17093        if body.trim().is_empty() {
17094            out.push_str("  No active IPSec SAs.\n");
17095        } else {
17096            out.push_str(&body);
17097        }
17098    }
17099    out.push_str("\n=== IPSec policies (ip xfrm policy) ===\n");
17100    if let Ok(o) = std::process::Command::new("ip")
17101        .args(["xfrm", "policy"])
17102        .output()
17103    {
17104        let body = String::from_utf8_lossy(&o.stdout);
17105        if body.trim().is_empty() {
17106            out.push_str("  No IPSec policies.\n");
17107        } else {
17108            out.push_str(&body);
17109        }
17110    }
17111    Ok(out)
17112}
17113
17114// ── NetBIOS ──────────────────────────────────────────────────────────────────
17115
17116#[cfg(windows)]
17117fn inspect_netbios() -> Result<String, String> {
17118    let script = r#"
17119$result = [System.Text.StringBuilder]::new()
17120
17121# NetBIOS node type and WINS per adapter
17122$result.AppendLine("=== NetBIOS configuration per adapter ===") | Out-Null
17123try {
17124    $adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17125        Where-Object { $_.IPEnabled -eq $true }
17126    foreach ($a in $adapters) {
17127        $nodeType = switch ($a.TcpipNetbiosOptions) {
17128            0 { "EnableNetBIOSViaDHCP" }
17129            1 { "Enabled" }
17130            2 { "Disabled" }
17131            default { "Unknown ($($a.TcpipNetbiosOptions))" }
17132        }
17133        $result.AppendLine("  [$($a.Description)]") | Out-Null
17134        $result.AppendLine("    NetBIOS over TCP/IP: $nodeType") | Out-Null
17135        if ($a.WINSPrimaryServer) {
17136            $result.AppendLine("    WINS Primary:        $($a.WINSPrimaryServer)") | Out-Null
17137        }
17138        if ($a.WINSSecondaryServer) {
17139            $result.AppendLine("    WINS Secondary:      $($a.WINSSecondaryServer)") | Out-Null
17140        }
17141    }
17142} catch {
17143    $result.AppendLine("  Could not query NetBIOS adapter config.") | Out-Null
17144}
17145
17146# nbtstat -n — registered local NetBIOS names
17147$result.AppendLine("") | Out-Null
17148$result.AppendLine("=== Registered NetBIOS names (nbtstat -n) ===") | Out-Null
17149try {
17150    $nbt = nbtstat -n 2>$null
17151    foreach ($line in $nbt) {
17152        $l = $line.Trim()
17153        if ($l -and $l -notmatch '^Node|^Host|^Registered|^-{3}') {
17154            $result.AppendLine("  $l") | Out-Null
17155        }
17156    }
17157} catch {
17158    $result.AppendLine("  nbtstat not available.") | Out-Null
17159}
17160
17161# NetBIOS session table
17162$result.AppendLine("") | Out-Null
17163$result.AppendLine("=== Active NetBIOS sessions (nbtstat -s) ===") | Out-Null
17164try {
17165    $sessions = nbtstat -s 2>$null | Where-Object { $_.Trim() -ne '' }
17166    if ($sessions) {
17167        foreach ($s in $sessions) { $result.AppendLine("  $($s.Trim())") | Out-Null }
17168    } else {
17169        $result.AppendLine("  No active NetBIOS sessions.") | Out-Null
17170    }
17171} catch {
17172    $result.AppendLine("  Could not query NetBIOS sessions.") | Out-Null
17173}
17174
17175# Findings
17176$findings = [System.Collections.Generic.List[string]]::new()
17177try {
17178    $enabled = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17179        Where-Object { $_.IPEnabled -and $_.TcpipNetbiosOptions -ne 2 }
17180    if ($enabled) {
17181        $findings.Add("NetBIOS over TCP/IP is enabled on $($enabled.Count) adapter(s) — potential attack surface if not required.")
17182    }
17183    $wins = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17184        Where-Object { $_.WINSPrimaryServer }
17185    if ($wins) {
17186        $findings.Add("WINS server configured: $($wins[0].WINSPrimaryServer) — verify this is intentional.")
17187    }
17188} catch {}
17189
17190$result.AppendLine("") | Out-Null
17191$result.AppendLine("=== Findings ===") | Out-Null
17192if ($findings.Count -eq 0) {
17193    $result.AppendLine("- NetBIOS configuration looks standard.") | Out-Null
17194} else {
17195    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17196}
17197
17198Write-Output $result.ToString()
17199"#;
17200    let out = run_powershell(script)?;
17201    Ok(format!("Host inspection: netbios\n\n{out}"))
17202}
17203
17204#[cfg(not(windows))]
17205fn inspect_netbios() -> Result<String, String> {
17206    let mut out = String::from("Host inspection: netbios\n\n=== NetBIOS (nmblookup) ===\n");
17207    if let Ok(o) = std::process::Command::new("nmblookup")
17208        .arg("-A")
17209        .arg("localhost")
17210        .output()
17211    {
17212        out.push_str(&String::from_utf8_lossy(&o.stdout));
17213    } else {
17214        out.push_str("  nmblookup not available (Samba not installed).\n");
17215    }
17216    Ok(out)
17217}
17218
17219// ── NIC Teaming ──────────────────────────────────────────────────────────────
17220
17221#[cfg(windows)]
17222fn inspect_nic_teaming() -> Result<String, String> {
17223    let script = r#"
17224$result = [System.Text.StringBuilder]::new()
17225
17226# Team inventory
17227$result.AppendLine("=== NIC teams ===") | Out-Null
17228try {
17229    $teams = Get-NetLbfoTeam -ErrorAction SilentlyContinue
17230    if ($teams) {
17231        foreach ($t in $teams) {
17232            $result.AppendLine("  Team: $($t.Name)") | Out-Null
17233            $result.AppendLine("    Mode:            $($t.TeamingMode)") | Out-Null
17234            $result.AppendLine("    LB Algorithm:    $($t.LoadBalancingAlgorithm)") | Out-Null
17235            $result.AppendLine("    Status:          $($t.Status)") | Out-Null
17236            $result.AppendLine("    Members:         $($t.Members -join ', ')") | Out-Null
17237            $result.AppendLine("    VLANs:           $($t.TransmitLinkSpeed / 1000000) Mbps TX / $($t.ReceiveLinkSpeed / 1000000) Mbps RX") | Out-Null
17238        }
17239    } else {
17240        $result.AppendLine("  No NIC teams configured on this machine.") | Out-Null
17241    }
17242} catch {
17243    $result.AppendLine("  Get-NetLbfoTeam unavailable (feature may not be installed).") | Out-Null
17244}
17245
17246# Team members detail
17247$result.AppendLine("") | Out-Null
17248$result.AppendLine("=== Team member detail ===") | Out-Null
17249try {
17250    $members = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue
17251    if ($members) {
17252        foreach ($m in $members) {
17253            $result.AppendLine("  [$($m.Team)] $($m.Name)  Role=$($m.AdministrativeMode)  Status=$($m.OperationalStatus)") | Out-Null
17254        }
17255    } else {
17256        $result.AppendLine("  No team members found.") | Out-Null
17257    }
17258} catch {
17259    $result.AppendLine("  Could not query team members.") | Out-Null
17260}
17261
17262# Findings
17263$findings = [System.Collections.Generic.List[string]]::new()
17264try {
17265    $degraded = Get-NetLbfoTeam -ErrorAction SilentlyContinue | Where-Object { $_.Status -ne 'Up' }
17266    if ($degraded) {
17267        foreach ($d in $degraded) { $findings.Add("Team '$($d.Name)' is in degraded state: $($d.Status)") }
17268    }
17269    $downMembers = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue | Where-Object { $_.OperationalStatus -ne 'Active' }
17270    if ($downMembers) {
17271        foreach ($m in $downMembers) { $findings.Add("Team member '$($m.Name)' in team '$($m.Team)' is not Active: $($m.OperationalStatus)") }
17272    }
17273} catch {}
17274
17275$result.AppendLine("") | Out-Null
17276$result.AppendLine("=== Findings ===") | Out-Null
17277if ($findings.Count -eq 0) {
17278    $result.AppendLine("- NIC teaming state looks healthy (or no teams configured).") | Out-Null
17279} else {
17280    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17281}
17282
17283Write-Output $result.ToString()
17284"#;
17285    let out = run_powershell(script)?;
17286    Ok(format!("Host inspection: nic_teaming\n\n{out}"))
17287}
17288
17289#[cfg(not(windows))]
17290fn inspect_nic_teaming() -> Result<String, String> {
17291    let mut out = String::from("Host inspection: nic_teaming\n\n=== Bond interfaces ===\n");
17292    if let Ok(o) = std::process::Command::new("cat")
17293        .arg("/proc/net/bonding/bond0")
17294        .output()
17295    {
17296        if o.status.success() {
17297            out.push_str(&String::from_utf8_lossy(&o.stdout));
17298        } else {
17299            out.push_str("  No bond0 interface found.\n");
17300        }
17301    }
17302    if let Ok(o) = std::process::Command::new("ip")
17303        .args(["link", "show", "type", "bond"])
17304        .output()
17305    {
17306        let body = String::from_utf8_lossy(&o.stdout);
17307        if !body.trim().is_empty() {
17308            out.push_str("\n=== Bond links (ip link) ===\n");
17309            out.push_str(&body);
17310        }
17311    }
17312    Ok(out)
17313}
17314
17315// ── SNMP ─────────────────────────────────────────────────────────────────────
17316
17317#[cfg(windows)]
17318fn inspect_snmp() -> Result<String, String> {
17319    let script = r#"
17320$result = [System.Text.StringBuilder]::new()
17321
17322# SNMP service state
17323$result.AppendLine("=== SNMP service state ===") | Out-Null
17324$svc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
17325if ($svc) {
17326    $result.AppendLine("  SNMP Agent service: $($svc.Status) (Startup: $($svc.StartType))") | Out-Null
17327} else {
17328    $result.AppendLine("  SNMP Agent service not installed.") | Out-Null
17329}
17330
17331$svcTrap = Get-Service -Name 'SNMPTRAP' -ErrorAction SilentlyContinue
17332if ($svcTrap) {
17333    $result.AppendLine("  SNMP Trap service:  $($svcTrap.Status) (Startup: $($svcTrap.StartType))") | Out-Null
17334}
17335
17336# Community strings (presence only — values redacted)
17337$result.AppendLine("") | Out-Null
17338$result.AppendLine("=== SNMP community strings (presence only) ===") | Out-Null
17339try {
17340    $communities = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
17341    if ($communities) {
17342        $names = $communities.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Name
17343        if ($names) {
17344            foreach ($n in $names) {
17345                $result.AppendLine("  Community: '$n'  (value redacted)") | Out-Null
17346            }
17347        } else {
17348            $result.AppendLine("  No community strings configured.") | Out-Null
17349        }
17350    } else {
17351        $result.AppendLine("  Registry key not found (SNMP may not be configured).") | Out-Null
17352    }
17353} catch {
17354    $result.AppendLine("  Could not read community strings (SNMP not configured or access denied).") | Out-Null
17355}
17356
17357# Permitted managers
17358$result.AppendLine("") | Out-Null
17359$result.AppendLine("=== Permitted SNMP managers ===") | Out-Null
17360try {
17361    $managers = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\PermittedManagers' -ErrorAction SilentlyContinue
17362    if ($managers) {
17363        $mgrs = $managers.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Value
17364        if ($mgrs) {
17365            foreach ($m in $mgrs) { $result.AppendLine("  $m") | Out-Null }
17366        } else {
17367            $result.AppendLine("  No permitted managers configured (accepts from any host).") | Out-Null
17368        }
17369    } else {
17370        $result.AppendLine("  No manager restrictions configured.") | Out-Null
17371    }
17372} catch {
17373    $result.AppendLine("  Could not read permitted managers.") | Out-Null
17374}
17375
17376# Findings
17377$findings = [System.Collections.Generic.List[string]]::new()
17378$snmpSvc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
17379if ($snmpSvc -and $snmpSvc.Status -eq 'Running') {
17380    $findings.Add("SNMP Agent is running — verify community strings and permitted managers are locked down.")
17381    try {
17382        $comms = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
17383        $publicExists = $comms.PSObject.Properties | Where-Object { $_.Name -eq 'public' }
17384        if ($publicExists) { $findings.Add("Community string 'public' is configured — this is a well-known default and a security risk.") }
17385    } catch {}
17386}
17387
17388$result.AppendLine("") | Out-Null
17389$result.AppendLine("=== Findings ===") | Out-Null
17390if ($findings.Count -eq 0) {
17391    $result.AppendLine("- SNMP agent is not running (or not installed). No exposure.") | Out-Null
17392} else {
17393    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17394}
17395
17396Write-Output $result.ToString()
17397"#;
17398    let out = run_powershell(script)?;
17399    Ok(format!("Host inspection: snmp\n\n{out}"))
17400}
17401
17402#[cfg(not(windows))]
17403fn inspect_snmp() -> Result<String, String> {
17404    let mut out = String::from("Host inspection: snmp\n\n=== SNMP daemon state ===\n");
17405    for svc in &["snmpd", "snmp"] {
17406        if let Ok(o) = std::process::Command::new("systemctl")
17407            .args(["is-active", svc])
17408            .output()
17409        {
17410            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
17411            out.push_str(&format!("  {svc}: {status}\n"));
17412        }
17413    }
17414    out.push_str("\n=== snmpd.conf community strings (presence check) ===\n");
17415    if let Ok(o) = std::process::Command::new("grep")
17416        .args(["-i", "community", "/etc/snmp/snmpd.conf"])
17417        .output()
17418    {
17419        if o.status.success() {
17420            for line in String::from_utf8_lossy(&o.stdout).lines() {
17421                out.push_str(&format!("  {line}\n"));
17422            }
17423        } else {
17424            out.push_str("  /etc/snmp/snmpd.conf not found or no community lines.\n");
17425        }
17426    }
17427    Ok(out)
17428}
17429
17430// ── Port Test ─────────────────────────────────────────────────────────────────
17431
17432#[cfg(windows)]
17433fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
17434    let target_host = host.unwrap_or("8.8.8.8");
17435    let target_port = port.unwrap_or(443);
17436
17437    let script = format!(
17438        r#"
17439$result = [System.Text.StringBuilder]::new()
17440$result.AppendLine("=== Port reachability test ===") | Out-Null
17441$result.AppendLine("  Target: {target_host}:{target_port}") | Out-Null
17442$result.AppendLine("") | Out-Null
17443
17444try {{
17445    $test = Test-NetConnection -ComputerName '{target_host}' -Port {target_port} -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
17446    if ($test) {{
17447        $status = if ($test.TcpTestSucceeded) {{ "OPEN (reachable)" }} else {{ "CLOSED or FILTERED" }}
17448        $result.AppendLine("  Result:          $status") | Out-Null
17449        $result.AppendLine("  Remote address:  $($test.RemoteAddress)") | Out-Null
17450        $result.AppendLine("  Remote port:     $($test.RemotePort)") | Out-Null
17451        if ($test.PingSucceeded) {{
17452            $result.AppendLine("  ICMP ping:       Succeeded ($($test.PingReplyDetails.RoundtripTime) ms)") | Out-Null
17453        }} else {{
17454            $result.AppendLine("  ICMP ping:       Failed (host may block ICMP)") | Out-Null
17455        }}
17456        $result.AppendLine("  Interface used:  $($test.InterfaceAlias)") | Out-Null
17457        $result.AppendLine("  Source address:  $($test.SourceAddress.IPAddress)") | Out-Null
17458
17459        $result.AppendLine("") | Out-Null
17460        $result.AppendLine("=== Findings ===") | Out-Null
17461        if ($test.TcpTestSucceeded) {{
17462            $result.AppendLine("- Port {target_port} on {target_host} is OPEN — TCP handshake succeeded.") | Out-Null
17463        }} else {{
17464            $result.AppendLine("- Port {target_port} on {target_host} is CLOSED or FILTERED — TCP handshake failed.") | Out-Null
17465            $result.AppendLine("  Check: firewall rules, route to host, or service not listening on that port.") | Out-Null
17466        }}
17467    }}
17468}} catch {{
17469    $result.AppendLine("  Test-NetConnection failed: $($_.Exception.Message)") | Out-Null
17470}}
17471
17472Write-Output $result.ToString()
17473"#
17474    );
17475    let out = run_powershell(&script)?;
17476    Ok(format!("Host inspection: port_test\n\n{out}"))
17477}
17478
17479#[cfg(not(windows))]
17480fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
17481    let target_host = host.unwrap_or("8.8.8.8");
17482    let target_port = port.unwrap_or(443);
17483    let mut out = format!("Host inspection: port_test\n\n=== Port reachability test ===\n  Target: {target_host}:{target_port}\n\n");
17484    // nc -zv with timeout
17485    let nc = std::process::Command::new("nc")
17486        .args(["-zv", "-w", "3", target_host, &target_port.to_string()])
17487        .output();
17488    match nc {
17489        Ok(o) => {
17490            let stderr = String::from_utf8_lossy(&o.stderr);
17491            let stdout = String::from_utf8_lossy(&o.stdout);
17492            let body = if !stdout.trim().is_empty() {
17493                stdout.as_ref()
17494            } else {
17495                stderr.as_ref()
17496            };
17497            out.push_str(&format!("  {}\n", body.trim()));
17498            out.push_str("\n=== Findings ===\n");
17499            if o.status.success() {
17500                out.push_str(&format!("- Port {target_port} on {target_host} is OPEN.\n"));
17501            } else {
17502                out.push_str(&format!(
17503                    "- Port {target_port} on {target_host} is CLOSED or FILTERED.\n"
17504                ));
17505            }
17506        }
17507        Err(e) => out.push_str(&format!("  nc not available: {e}\n")),
17508    }
17509    Ok(out)
17510}
17511
17512// ── Network Profile ───────────────────────────────────────────────────────────
17513
17514#[cfg(windows)]
17515fn inspect_network_profile() -> Result<String, String> {
17516    let script = r#"
17517$result = [System.Text.StringBuilder]::new()
17518
17519$result.AppendLine("=== Network location profiles ===") | Out-Null
17520try {
17521    $profiles = Get-NetConnectionProfile -ErrorAction SilentlyContinue
17522    if ($profiles) {
17523        foreach ($p in $profiles) {
17524            $result.AppendLine("  Interface: $($p.InterfaceAlias)") | Out-Null
17525            $result.AppendLine("    Network name:    $($p.Name)") | Out-Null
17526            $result.AppendLine("    Category:        $($p.NetworkCategory)") | Out-Null
17527            $result.AppendLine("    IPv4 conn:       $($p.IPv4Connectivity)") | Out-Null
17528            $result.AppendLine("    IPv6 conn:       $($p.IPv6Connectivity)") | Out-Null
17529            $result.AppendLine("") | Out-Null
17530        }
17531    } else {
17532        $result.AppendLine("  No network connection profiles found.") | Out-Null
17533    }
17534} catch {
17535    $result.AppendLine("  Could not query network profiles.") | Out-Null
17536}
17537
17538# Findings
17539$findings = [System.Collections.Generic.List[string]]::new()
17540try {
17541    $pub = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'Public' }
17542    if ($pub) {
17543        foreach ($p in $pub) {
17544            $findings.Add("Interface '$($p.InterfaceAlias)' is set to Public — firewall restrictions are maximum. Change to Private if this is a trusted network.")
17545        }
17546    }
17547    $domain = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'DomainAuthenticated' }
17548    if ($domain) {
17549        foreach ($d in $domain) {
17550            $findings.Add("Interface '$($d.InterfaceAlias)' is domain-authenticated — domain GPO firewall rules apply.")
17551        }
17552    }
17553} catch {}
17554
17555$result.AppendLine("=== Findings ===") | Out-Null
17556if ($findings.Count -eq 0) {
17557    $result.AppendLine("- Network profiles look normal.") | Out-Null
17558} else {
17559    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17560}
17561
17562Write-Output $result.ToString()
17563"#;
17564    let out = run_powershell(script)?;
17565    Ok(format!("Host inspection: network_profile\n\n{out}"))
17566}
17567
17568#[cfg(not(windows))]
17569fn inspect_network_profile() -> Result<String, String> {
17570    let mut out = String::from(
17571        "Host inspection: network_profile\n\n=== Network manager connection profiles ===\n",
17572    );
17573    if let Ok(o) = std::process::Command::new("nmcli")
17574        .args([
17575            "-t",
17576            "-f",
17577            "NAME,TYPE,STATE,DEVICE",
17578            "connection",
17579            "show",
17580            "--active",
17581        ])
17582        .output()
17583    {
17584        out.push_str(&String::from_utf8_lossy(&o.stdout));
17585    } else {
17586        out.push_str("  nmcli not available.\n");
17587    }
17588    Ok(out)
17589}
17590
17591// ── Storage Spaces ────────────────────────────────────────────────────────────
17592
17593#[cfg(windows)]
17594fn inspect_storage_spaces() -> Result<String, String> {
17595    let script = r#"
17596$result = [System.Text.StringBuilder]::new()
17597
17598# Storage Pools
17599try {
17600    $pools = Get-StoragePool -IsPrimordial $false -ErrorAction SilentlyContinue
17601    if ($pools) {
17602        $result.AppendLine("=== Storage Pools ===") | Out-Null
17603        foreach ($pool in $pools) {
17604            $health = $pool.HealthStatus
17605            $oper   = $pool.OperationalStatus
17606            $sizGB  = [math]::Round($pool.Size / 1GB, 1)
17607            $allocGB= [math]::Round($pool.AllocatedSize / 1GB, 1)
17608            $result.AppendLine("  Pool: $($pool.FriendlyName)  Size: ${sizGB}GB  Allocated: ${allocGB}GB  Health: $health  Status: $oper") | Out-Null
17609        }
17610        $result.AppendLine("") | Out-Null
17611    } else {
17612        $result.AppendLine("=== Storage Pools ===") | Out-Null
17613        $result.AppendLine("  No Storage Spaces pools configured.") | Out-Null
17614        $result.AppendLine("") | Out-Null
17615    }
17616} catch {
17617    $result.AppendLine("=== Storage Pools ===") | Out-Null
17618    $result.AppendLine("  Unable to query storage pools (may require elevation).") | Out-Null
17619    $result.AppendLine("") | Out-Null
17620}
17621
17622# Virtual Disks
17623try {
17624    $vdisks = Get-VirtualDisk -ErrorAction SilentlyContinue
17625    if ($vdisks) {
17626        $result.AppendLine("=== Virtual Disks ===") | Out-Null
17627        foreach ($vd in $vdisks) {
17628            $health  = $vd.HealthStatus
17629            $oper    = $vd.OperationalStatus
17630            $layout  = $vd.ResiliencySettingName
17631            $sizGB   = [math]::Round($vd.Size / 1GB, 1)
17632            $result.AppendLine("  VDisk: $($vd.FriendlyName)  Layout: $layout  Size: ${sizGB}GB  Health: $health  Status: $oper") | Out-Null
17633        }
17634        $result.AppendLine("") | Out-Null
17635    } else {
17636        $result.AppendLine("=== Virtual Disks ===") | Out-Null
17637        $result.AppendLine("  No Storage Spaces virtual disks configured.") | Out-Null
17638        $result.AppendLine("") | Out-Null
17639    }
17640} catch {
17641    $result.AppendLine("=== Virtual Disks ===") | Out-Null
17642    $result.AppendLine("  Unable to query virtual disks.") | Out-Null
17643    $result.AppendLine("") | Out-Null
17644}
17645
17646# Physical Disks in pools
17647try {
17648    $pdisks = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.Usage -ne 'Journal' }
17649    if ($pdisks) {
17650        $result.AppendLine("=== Physical Disks ===") | Out-Null
17651        foreach ($pd in $pdisks) {
17652            $sizGB  = [math]::Round($pd.Size / 1GB, 1)
17653            $health = $pd.HealthStatus
17654            $usage  = $pd.Usage
17655            $media  = $pd.MediaType
17656            $result.AppendLine("  $($pd.FriendlyName)  ${sizGB}GB  $media  Usage: $usage  Health: $health") | Out-Null
17657        }
17658        $result.AppendLine("") | Out-Null
17659    }
17660} catch {}
17661
17662# Findings
17663$findings = @()
17664try {
17665    $unhealthy = Get-StoragePool -IsPrimordial $false -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -ne 'Healthy' }
17666    foreach ($p in $unhealthy) { $findings += "WARN: Pool '$($p.FriendlyName)' health is $($p.HealthStatus)" }
17667    $degraded = Get-VirtualDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -ne 'Healthy' }
17668    foreach ($v in $degraded) { $findings += "WARN: VDisk '$($v.FriendlyName)' health is $($v.HealthStatus) — check for failed drives" }
17669    $failedPd = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -eq 'Unhealthy' -or $_.OperationalStatus -eq 'Lost Communication' }
17670    foreach ($d in $failedPd) { $findings += "CRITICAL: Physical disk '$($d.FriendlyName)' is $($d.HealthStatus) / $($d.OperationalStatus)" }
17671} catch {}
17672
17673if ($findings.Count -gt 0) {
17674    $result.AppendLine("=== Findings ===") | Out-Null
17675    foreach ($f in $findings) { $result.AppendLine("  $f") | Out-Null }
17676} else {
17677    $result.AppendLine("=== Findings ===") | Out-Null
17678    $result.AppendLine("  All storage pool components healthy (or no Storage Spaces configured).") | Out-Null
17679}
17680
17681Write-Output $result.ToString().TrimEnd()
17682"#;
17683    let out = run_powershell(script)?;
17684    Ok(format!("Host inspection: storage_spaces\n\n{out}"))
17685}
17686
17687#[cfg(not(windows))]
17688fn inspect_storage_spaces() -> Result<String, String> {
17689    let mut out = String::from("Host inspection: storage_spaces\n\n");
17690    // Linux: check mdadm software RAID
17691    let mdstat = std::fs::read_to_string("/proc/mdstat").unwrap_or_default();
17692    if !mdstat.is_empty() {
17693        out.push_str("=== Software RAID (/proc/mdstat) ===\n");
17694        out.push_str(&mdstat);
17695    } else {
17696        out.push_str("No mdadm software RAID detected (/proc/mdstat not found or empty).\n");
17697    }
17698    // Check LVM
17699    if let Ok(o) = Command::new("lvs")
17700        .args(["--noheadings", "-o", "lv_name,vg_name,lv_size,lv_attr"])
17701        .output()
17702    {
17703        let lvs = String::from_utf8_lossy(&o.stdout).to_string();
17704        if !lvs.trim().is_empty() {
17705            out.push_str("\n=== LVM Logical Volumes ===\n");
17706            out.push_str(&lvs);
17707        }
17708    }
17709    Ok(out)
17710}
17711
17712// ── Defender Quarantine / Threat History ─────────────────────────────────────
17713
17714#[cfg(windows)]
17715fn inspect_defender_quarantine(max_entries: usize) -> Result<String, String> {
17716    let limit = max_entries.min(50);
17717    let script = format!(
17718        r#"
17719$result = [System.Text.StringBuilder]::new()
17720
17721# Current threat detections (active + quarantined)
17722try {{
17723    $threats = Get-MpThreatDetection -ErrorAction SilentlyContinue | Sort-Object InitialDetectionTime -Descending | Select-Object -First {limit}
17724    if ($threats) {{
17725        $result.AppendLine("=== Recent Threat Detections (last {limit}) ===") | Out-Null
17726        foreach ($t in $threats) {{
17727            $name    = (Get-MpThreat -ThreatID $t.ThreatID -ErrorAction SilentlyContinue).ThreatName
17728            if (-not $name) {{ $name = "ID:$($t.ThreatID)" }}
17729            $time    = $t.InitialDetectionTime.ToString("yyyy-MM-dd HH:mm")
17730            $action  = $t.ActionSuccess
17731            $status  = $t.CurrentThreatExecutionStatusID
17732            $result.AppendLine("  [$time] $name  ActionSuccess:$action  Status:$status") | Out-Null
17733        }}
17734        $result.AppendLine("") | Out-Null
17735    }} else {{
17736        $result.AppendLine("=== Recent Threat Detections ===") | Out-Null
17737        $result.AppendLine("  No threat detections on record — Defender history is clean.") | Out-Null
17738        $result.AppendLine("") | Out-Null
17739    }}
17740}} catch {{
17741    $result.AppendLine("=== Recent Threat Detections ===") | Out-Null
17742    $result.AppendLine("  Unable to query threat detections: $_") | Out-Null
17743    $result.AppendLine("") | Out-Null
17744}}
17745
17746# Quarantine items
17747try {{
17748    $quarantine = Get-MpThreat -ErrorAction SilentlyContinue | Where-Object {{ $_.IsActive -eq $false }} | Select-Object -First {limit}
17749    if ($quarantine) {{
17750        $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
17751        foreach ($q in $quarantine) {{
17752            $result.AppendLine("  $($q.ThreatName)  Severity:$($q.SeverityID)  Category:$($q.CategoryID)  Active:$($q.IsActive)") | Out-Null
17753        }}
17754        $result.AppendLine("") | Out-Null
17755    }} else {{
17756        $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
17757        $result.AppendLine("  No quarantined threats found.") | Out-Null
17758        $result.AppendLine("") | Out-Null
17759    }}
17760}} catch {{
17761    $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
17762    $result.AppendLine("  Unable to query quarantine list: $_") | Out-Null
17763    $result.AppendLine("") | Out-Null
17764}}
17765
17766# Defender scan stats
17767try {{
17768    $status = Get-MpComputerStatus -ErrorAction SilentlyContinue
17769    if ($status) {{
17770        $lastScan   = $status.QuickScanStartTime
17771        $lastFull   = $status.FullScanStartTime
17772        $sigDate    = $status.AntivirusSignatureLastUpdated
17773        $result.AppendLine("=== Defender Scan Summary ===") | Out-Null
17774        $result.AppendLine("  Last quick scan : $lastScan") | Out-Null
17775        $result.AppendLine("  Last full scan  : $lastFull") | Out-Null
17776        $result.AppendLine("  Signature date  : $sigDate") | Out-Null
17777    }}
17778}} catch {{}}
17779
17780Write-Output $result.ToString().TrimEnd()
17781"#,
17782        limit = limit
17783    );
17784    let out = run_powershell(&script)?;
17785    Ok(format!("Host inspection: defender_quarantine\n\n{out}"))
17786}
17787
17788// ── inspect_domain_health ─────────────────────────────────────────────────────
17789
17790#[cfg(windows)]
17791fn inspect_domain_health() -> Result<String, String> {
17792    let script = r#"
17793$result = [System.Text.StringBuilder]::new()
17794
17795# Domain membership
17796try {
17797    $cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
17798    $joined = $cs.PartOfDomain
17799    $domain = $cs.Domain
17800    $result.AppendLine("=== Domain Membership ===") | Out-Null
17801    $result.AppendLine("  Join Status : $(if ($joined) { 'DOMAIN JOINED' } else { 'WORKGROUP' })") | Out-Null
17802    if ($joined) { $result.AppendLine("  Domain      : $domain") | Out-Null }
17803    $result.AppendLine("  Computer    : $($cs.Name)") | Out-Null
17804} catch {
17805    $result.AppendLine("  Domain membership check failed: $_") | Out-Null
17806}
17807
17808# dsregcmd device registration state
17809try {
17810    $dsreg = dsregcmd /status 2>&1 | Where-Object { $_ -match '(AzureAdJoined|DomainJoined|WorkplaceJoined|TenantName|DeviceId|MdmUrl)' }
17811    if ($dsreg) {
17812        $result.AppendLine("") | Out-Null
17813        $result.AppendLine("=== Device Registration (dsregcmd) ===") | Out-Null
17814        foreach ($line in $dsreg) { $result.AppendLine("  $($line.Trim())") | Out-Null }
17815    }
17816} catch {}
17817
17818# DC discovery via nltest
17819$result.AppendLine("") | Out-Null
17820$result.AppendLine("=== Domain Controller Discovery ===") | Out-Null
17821try {
17822    $nl = nltest /dsgetdc:. 2>&1
17823    $dc_name = $null
17824    foreach ($line in $nl) {
17825        if ($line -match '(DC:|Address:|Dom Guid|DomainName)') {
17826            $result.AppendLine("  $($line.Trim())") | Out-Null
17827        }
17828        if ($line -match 'DC: \\\\(.+)') { $dc_name = $Matches[1].Trim() }
17829    }
17830    if ($dc_name) {
17831        $result.AppendLine("") | Out-Null
17832        $result.AppendLine("=== DC Port Connectivity (DC: $dc_name) ===") | Out-Null
17833        foreach ($entry in @(@{p=389;n='LDAP'},@{p=636;n='LDAPS'},@{p=88;n='Kerberos'},@{p=3268;n='GC-LDAP'})) {
17834            try {
17835                $tcp = New-Object System.Net.Sockets.TcpClient
17836                $conn = $tcp.BeginConnect($dc_name, $entry.p, $null, $null)
17837                $ok = $conn.AsyncWaitHandle.WaitOne(1200, $false)
17838                $tcp.Close()
17839                $status = if ($ok) { 'OPEN' } else { 'TIMEOUT' }
17840            } catch { $status = 'FAILED' }
17841            $result.AppendLine("  Port $($entry.p) ($($entry.n)): $status") | Out-Null
17842        }
17843    }
17844} catch {
17845    $result.AppendLine("  nltest unavailable (not domain-joined or missing RSAT): $_") | Out-Null
17846}
17847
17848# Last GPO machine refresh time
17849try {
17850    $gpoKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Group Policy\State\Machine\Extension-List\{00000000-0000-0000-0000-000000000000}'
17851    if (Test-Path $gpoKey) {
17852        $gpo = Get-ItemProperty $gpoKey -ErrorAction SilentlyContinue
17853        $result.AppendLine("") | Out-Null
17854        $result.AppendLine("=== Group Policy Last Refresh ===") | Out-Null
17855        $result.AppendLine("  Machine GPO last applied: $($gpo.EndTime)") | Out-Null
17856    }
17857} catch {}
17858
17859Write-Output $result.ToString().TrimEnd()
17860"#;
17861    let out = run_powershell(script)?;
17862    Ok(format!("Host inspection: domain_health\n\n{out}"))
17863}
17864
17865#[cfg(not(windows))]
17866fn inspect_domain_health() -> Result<String, String> {
17867    let mut out = String::from("Host inspection: domain_health\n\n");
17868    for cmd_args in &[vec!["realm", "list"], vec!["sssd", "--version"]] {
17869        if let Ok(o) = Command::new(cmd_args[0]).args(&cmd_args[1..]).output() {
17870            let s = String::from_utf8_lossy(&o.stdout);
17871            if !s.trim().is_empty() {
17872                out.push_str(&format!("$ {}\n{}\n", cmd_args.join(" "), s.trim_end()));
17873            }
17874        }
17875    }
17876    if out.trim_end().ends_with("domain_health") {
17877        out.push_str("Not domain-joined or realm/sssd not installed.\n");
17878    }
17879    Ok(out)
17880}
17881
17882// ── inspect_service_dependencies ─────────────────────────────────────────────
17883
17884#[cfg(windows)]
17885fn inspect_service_dependencies(max_entries: usize) -> Result<String, String> {
17886    let limit = max_entries.min(60);
17887    let script = format!(
17888        r#"
17889$result = [System.Text.StringBuilder]::new()
17890$result.AppendLine("=== Service Dependency Graph ===") | Out-Null
17891$result.AppendLine("Format: [Status] ServiceName — requires: ... | needed by: ...") | Out-Null
17892$result.AppendLine("") | Out-Null
17893$svc = Get-Service | Where-Object {{ $_.DependentServices.Count -gt 0 -or $_.RequiredServices.Count -gt 0 }} | Sort-Object Name | Select-Object -First {limit}
17894foreach ($s in $svc) {{
17895    $req  = if ($s.RequiredServices.Count  -gt 0) {{ "requires: $($s.RequiredServices.Name  -join ', ')" }} else {{ "" }}
17896    $dep  = if ($s.DependentServices.Count -gt 0) {{ "needed by: $($s.DependentServices.Name -join ', ')" }} else {{ "" }}
17897    $parts = @($req, $dep) | Where-Object {{ $_ }}
17898    if ($parts) {{
17899        $result.AppendLine("  [$($s.Status)] $($s.Name) — $($parts -join ' | ')") | Out-Null
17900    }}
17901}}
17902Write-Output $result.ToString().TrimEnd()
17903"#,
17904        limit = limit
17905    );
17906    let out = run_powershell(&script)?;
17907    Ok(format!("Host inspection: service_dependencies\n\n{out}"))
17908}
17909
17910#[cfg(not(windows))]
17911fn inspect_service_dependencies(_max_entries: usize) -> Result<String, String> {
17912    let out = Command::new("systemctl")
17913        .args(["list-dependencies", "--no-pager", "--plain"])
17914        .output()
17915        .ok()
17916        .and_then(|o| String::from_utf8(o.stdout).ok())
17917        .unwrap_or_else(|| "systemctl not available.\n".to_string());
17918    Ok(format!(
17919        "Host inspection: service_dependencies\n\n{}",
17920        out.trim_end()
17921    ))
17922}
17923
17924// ── inspect_wmi_health ────────────────────────────────────────────────────────
17925
17926#[cfg(windows)]
17927fn inspect_wmi_health() -> Result<String, String> {
17928    let script = r#"
17929$result = [System.Text.StringBuilder]::new()
17930$result.AppendLine("=== WMI Repository Health ===") | Out-Null
17931
17932# Basic WMI query test
17933try {
17934    $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
17935    $result.AppendLine("  Query (Win32_OperatingSystem): OK") | Out-Null
17936    $result.AppendLine("  OS: $($os.Caption) Build $($os.BuildNumber)") | Out-Null
17937} catch {
17938    $result.AppendLine("  Query FAILED: $_") | Out-Null
17939    $result.AppendLine("  FINDING: WMI may be corrupt. Run winmgmt /verifyrepository") | Out-Null
17940}
17941
17942# Repository integrity
17943try {
17944    $verify = & winmgmt /verifyrepository 2>&1
17945    $result.AppendLine("  winmgmt /verifyrepository: $verify") | Out-Null
17946} catch {
17947    $result.AppendLine("  winmgmt check unavailable: $_") | Out-Null
17948}
17949
17950# WMI service state
17951$svc = Get-Service winmgmt -ErrorAction SilentlyContinue
17952if ($svc) {
17953    $result.AppendLine("  Service (winmgmt): $($svc.Status) / $($svc.StartType)") | Out-Null
17954}
17955
17956# Repository folder size
17957$repPath = "$env:SystemRoot\System32\wbem\Repository"
17958if (Test-Path $repPath) {
17959    $bytes = (Get-ChildItem $repPath -Recurse -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
17960    $mb = [math]::Round($bytes / 1MB, 1)
17961    $result.AppendLine("  Repository size: $mb MB  ($repPath)") | Out-Null
17962    if ($mb -gt 200) {
17963        $result.AppendLine("  FINDING: Repository is unusually large (>200 MB). May indicate corruption or bloat.") | Out-Null
17964    }
17965}
17966
17967$result.AppendLine("") | Out-Null
17968$result.AppendLine("=== Recovery Steps (if corrupt) ===") | Out-Null
17969$result.AppendLine("  1. net stop winmgmt") | Out-Null
17970$result.AppendLine("  2. winmgmt /salvagerepository   (try first)") | Out-Null
17971$result.AppendLine("  3. winmgmt /resetrepository     (last resort — loses custom namespaces)") | Out-Null
17972$result.AppendLine("  4. net start winmgmt") | Out-Null
17973
17974Write-Output $result.ToString().TrimEnd()
17975"#;
17976    let out = run_powershell(script)?;
17977    Ok(format!("Host inspection: wmi_health\n\n{out}"))
17978}
17979
17980#[cfg(not(windows))]
17981fn inspect_wmi_health() -> Result<String, String> {
17982    Ok("Host inspection: wmi_health\n\nWMI is Windows-only. On Linux use 'systemctl status' for service health.\n".to_string())
17983}
17984
17985// ── inspect_local_security_policy ────────────────────────────────────────────
17986
17987#[cfg(windows)]
17988fn inspect_local_security_policy() -> Result<String, String> {
17989    let script = r#"
17990$result = [System.Text.StringBuilder]::new()
17991$result.AppendLine("=== Local Account & Password Policy ===") | Out-Null
17992$na = net accounts 2>&1
17993foreach ($line in $na) {
17994    if ($line -match '(password|lockout|observation|force|maximum|minimum|length|duration)') {
17995        $result.AppendLine("  $($line.Trim())") | Out-Null
17996    }
17997}
17998
17999$result.AppendLine("") | Out-Null
18000$result.AppendLine("=== LAN Manager / NTLM Authentication Level ===") | Out-Null
18001try {
18002    $lmLevel = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name LmCompatibilityLevel -ErrorAction SilentlyContinue).LmCompatibilityLevel
18003    if ($null -eq $lmLevel) { $lmLevel = 3 }
18004    $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'}
18005    $result.AppendLine("  LmCompatibilityLevel: $lmLevel — $($map[$lmLevel])") | Out-Null
18006    if ($lmLevel -lt 3) {
18007        $result.AppendLine("  FINDING: LmCompatibilityLevel < 3 allows weak LM/NTLM. Recommend level 3 or higher.") | Out-Null
18008    }
18009} catch {}
18010
18011$result.AppendLine("") | Out-Null
18012$result.AppendLine("=== UAC Settings ===") | Out-Null
18013try {
18014    $uac = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -ErrorAction SilentlyContinue
18015    if ($uac) {
18016        $result.AppendLine("  UAC Enabled             : $($uac.EnableLUA)   (1=on, 0=disabled)") | Out-Null
18017        $behavMap = @{0='No prompt (risk)'; 1='Prompt for creds'; 2='Prompt for consent'; 5='Secure consent (default)'}
18018        $bval = $uac.ConsentPromptBehaviorAdmin
18019        $result.AppendLine("  Admin Prompt Behavior   : $bval — $($behavMap[$bval])") | Out-Null
18020        if ($uac.EnableLUA -eq 0) {
18021            $result.AppendLine("  FINDING: UAC is disabled. Processes run with full admin rights without prompting.") | Out-Null
18022        }
18023    }
18024} catch {}
18025
18026Write-Output $result.ToString().TrimEnd()
18027"#;
18028    let out = run_powershell(script)?;
18029    Ok(format!("Host inspection: local_security_policy\n\n{out}"))
18030}
18031
18032#[cfg(not(windows))]
18033fn inspect_local_security_policy() -> Result<String, String> {
18034    let mut out = String::from("Host inspection: local_security_policy\n\n");
18035    if let Ok(content) = std::fs::read_to_string("/etc/login.defs") {
18036        out.push_str("=== /etc/login.defs ===\n");
18037        for line in content.lines() {
18038            let t = line.trim();
18039            if !t.is_empty() && !t.starts_with('#') {
18040                out.push_str(&format!("  {t}\n"));
18041            }
18042        }
18043    }
18044    Ok(out)
18045}
18046
18047// ── inspect_usb_history ───────────────────────────────────────────────────────
18048
18049#[cfg(windows)]
18050fn inspect_usb_history(max_entries: usize) -> Result<String, String> {
18051    let limit = max_entries.min(50);
18052    let script = format!(
18053        r#"
18054$result = [System.Text.StringBuilder]::new()
18055$result.AppendLine("=== USB Device History (USBSTOR Registry) ===") | Out-Null
18056$usbPath = 'HKLM:\SYSTEM\CurrentControlSet\Enum\USBSTOR'
18057if (Test-Path $usbPath) {{
18058    $count = 0
18059    $seen = @{{}}
18060    $classes = Get-ChildItem $usbPath -ErrorAction SilentlyContinue
18061    foreach ($class in $classes) {{
18062        $instances = Get-ChildItem $class.PSPath -ErrorAction SilentlyContinue
18063        foreach ($inst in $instances) {{
18064            if ($count -ge {limit}) {{ break }}
18065            try {{
18066                $props = Get-ItemProperty $inst.PSPath -ErrorAction SilentlyContinue
18067                $fn = if ($props.FriendlyName) {{ $props.FriendlyName }} else {{ $class.PSChildName }}
18068                if (-not $seen[$fn]) {{
18069                    $seen[$fn] = $true
18070                    $result.AppendLine("  $fn") | Out-Null
18071                    $count++
18072                }}
18073            }} catch {{}}
18074        }}
18075    }}
18076    if ($count -eq 0) {{
18077        $result.AppendLine("  No USB storage devices found in registry.") | Out-Null
18078    }} else {{
18079        $result.AppendLine("") | Out-Null
18080        $result.AppendLine("  ($count unique devices; requires elevation for full history)") | Out-Null
18081    }}
18082}} else {{
18083    $result.AppendLine("  USBSTOR key not found. Requires elevation or USB storage policy may block it.") | Out-Null
18084}}
18085Write-Output $result.ToString().TrimEnd()
18086"#,
18087        limit = limit
18088    );
18089    let out = run_powershell(&script)?;
18090    Ok(format!("Host inspection: usb_history\n\n{out}"))
18091}
18092
18093#[cfg(not(windows))]
18094fn inspect_usb_history(_max_entries: usize) -> Result<String, String> {
18095    let mut out = String::from("Host inspection: usb_history\n\n");
18096    if let Ok(o) = Command::new("journalctl")
18097        .args(["-k", "--no-pager", "-q", "--since", "30 days ago"])
18098        .output()
18099    {
18100        let s = String::from_utf8_lossy(&o.stdout);
18101        let usb_lines: Vec<&str> = s
18102            .lines()
18103            .filter(|l| l.to_ascii_lowercase().contains("usb"))
18104            .take(30)
18105            .collect();
18106        if !usb_lines.is_empty() {
18107            out.push_str("=== Recent USB kernel events (journalctl) ===\n");
18108            for line in usb_lines {
18109                out.push_str(&format!("  {line}\n"));
18110            }
18111        }
18112    } else {
18113        out.push_str("USB history via journalctl not available.\n");
18114    }
18115    Ok(out)
18116}
18117
18118// ── inspect_print_spooler ─────────────────────────────────────────────────────
18119
18120#[cfg(windows)]
18121fn inspect_print_spooler() -> Result<String, String> {
18122    let script = r#"
18123$result = [System.Text.StringBuilder]::new()
18124
18125$svc = Get-Service Spooler -ErrorAction SilentlyContinue
18126$result.AppendLine("=== Print Spooler Service ===") | Out-Null
18127if ($svc) {
18128    $result.AppendLine("  Status     : $($svc.Status)") | Out-Null
18129    $result.AppendLine("  Start Type : $($svc.StartType)") | Out-Null
18130} else {
18131    $result.AppendLine("  Spooler service not found.") | Out-Null
18132}
18133
18134# PrintNightmare mitigations (CVE-2021-34527)
18135$result.AppendLine("") | Out-Null
18136$result.AppendLine("=== PrintNightmare Hardening (CVE-2021-34527) ===") | Out-Null
18137try {
18138    $val = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Print' -Name RpcAuthnLevelPrivacyEnabled -ErrorAction SilentlyContinue).RpcAuthnLevelPrivacyEnabled
18139    if ($val -eq 1) {
18140        $result.AppendLine("  RpcAuthnLevelPrivacyEnabled: 1 — HARDENED (good)") | Out-Null
18141    } else {
18142        $result.AppendLine("  RpcAuthnLevelPrivacyEnabled: $val — NOT hardened") | Out-Null
18143        $result.AppendLine("  FINDING: PrintNightmare RPC mitigation not applied. Set to 1 via registry.") | Out-Null
18144    }
18145} catch { $result.AppendLine("  Mitigation key not readable: $_") | Out-Null }
18146
18147try {
18148    $pnpPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Printers\PointAndPrint'
18149    if (Test-Path $pnpPath) {
18150        $pnp = Get-ItemProperty $pnpPath -ErrorAction SilentlyContinue
18151        $result.AppendLine("  RestrictDriverInstallationToAdministrators: $($pnp.RestrictDriverInstallationToAdministrators)") | Out-Null
18152        $result.AppendLine("  NoWarningNoElevationOnInstall              : $($pnp.NoWarningNoElevationOnInstall)") | Out-Null
18153        if ($pnp.NoWarningNoElevationOnInstall -eq 1) {
18154            $result.AppendLine("  FINDING: Point and Print allows silent driver install without elevation (risk).") | Out-Null
18155        }
18156    } else {
18157        $result.AppendLine("  No Point and Print policy (using Windows defaults).") | Out-Null
18158    }
18159} catch {}
18160
18161# Pending print jobs
18162$result.AppendLine("") | Out-Null
18163$result.AppendLine("=== Print Queue ===") | Out-Null
18164try {
18165    $jobs = Get-PrintJob -ErrorAction SilentlyContinue
18166    if ($jobs) {
18167        foreach ($j in $jobs | Select-Object -First 5) {
18168            $result.AppendLine("  $($j.DocumentName) — $($j.JobStatus)") | Out-Null
18169        }
18170    } else {
18171        $result.AppendLine("  No pending print jobs.") | Out-Null
18172    }
18173} catch {
18174    $result.AppendLine("  Print queue check requires elevation.") | Out-Null
18175}
18176
18177Write-Output $result.ToString().TrimEnd()
18178"#;
18179    let out = run_powershell(script)?;
18180    Ok(format!("Host inspection: print_spooler\n\n{out}"))
18181}
18182
18183#[cfg(not(windows))]
18184fn inspect_print_spooler() -> Result<String, String> {
18185    let mut out = String::from("Host inspection: print_spooler\n\n");
18186    if let Ok(o) = Command::new("lpstat").args(["-s"]).output() {
18187        let s = String::from_utf8_lossy(&o.stdout);
18188        if !s.trim().is_empty() {
18189            out.push_str("=== CUPS Status (lpstat -s) ===\n");
18190            out.push_str(s.trim_end());
18191            out.push('\n');
18192        }
18193    } else {
18194        out.push_str("CUPS not detected (lpstat not found).\n");
18195    }
18196    Ok(out)
18197}
18198
18199#[cfg(not(windows))]
18200fn inspect_defender_quarantine(_max_entries: usize) -> Result<String, String> {
18201    let mut out = String::from("Host inspection: defender_quarantine\n\n");
18202    out.push_str("Windows Defender is Windows-only.\n");
18203    // Check ClamAV on Linux/macOS
18204    if let Ok(o) = Command::new("clamscan").arg("--version").output() {
18205        if o.status.success() {
18206            out.push_str("\nClamAV detected. Run `clamscan -r --infected /path` for a scan.\n");
18207            if let Ok(log) = std::fs::read_to_string("/var/log/clamav/clamav.log") {
18208                out.push_str("\n=== ClamAV Recent Log ===\n");
18209                for line in log.lines().rev().take(20) {
18210                    out.push_str(&format!("  {line}\n"));
18211                }
18212            }
18213        }
18214    } else {
18215        out.push_str("No AV tool detected (ClamAV not found).\n");
18216    }
18217    Ok(out)
18218}