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        "public_ip" | "external_ip" | "myip" => inspect_public_ip().await,
99        "ssl_cert" | "tls_cert" | "cert_audit" | "website_cert" => {
100            let host = args.get("host").and_then(|v| v.as_str()).unwrap_or("google.com");
101            inspect_ssl_cert(host)
102        }
103        "proxy" | "proxy_settings" => inspect_proxy(),
104        "firewall_rules" | "firewall-rules" => inspect_firewall_rules(max_entries),
105        "traceroute" | "tracert" | "trace_route" | "trace" => {
106            let host = args
107                .get("host")
108                .and_then(|v| v.as_str())
109                .unwrap_or("8.8.8.8")
110                .to_string();
111            inspect_traceroute(&host, max_entries)
112        }
113        "dns_cache" | "dnscache" | "dns-cache" => inspect_dns_cache(max_entries),
114        "arp" | "arp_table" => inspect_arp(),
115        "route_table" | "routes" | "routing_table" => inspect_route_table(max_entries),
116        "os_config" | "system_config" => inspect_os_config(),
117        "resource_load" | "performance" | "system_load" | "performance_diagnosis" => inspect_resource_load(),
118        "env" | "environment" | "environment_variables" | "env_vars" => inspect_env(max_entries),
119        "hosts_file" | "hosts" | "etc_hosts" => inspect_hosts_file(),
120        "docker" | "containers" | "docker_status" => inspect_docker(max_entries),
121        "docker_filesystems" | "docker_mounts" | "docker_storage" | "container_mounts" => {
122            inspect_docker_filesystems(max_entries)
123        }
124        "wsl" | "wsl_distros" | "subsystem" => inspect_wsl(),
125        "wsl_filesystems" | "wsl_storage" | "wsl_mounts" => inspect_wsl_filesystems(max_entries),
126        "ssh" | "ssh_config" | "ssh_status" => inspect_ssh(),
127        "installed_software" | "installed" | "programs" | "software" | "packages" => inspect_installed_software(max_entries),
128        "git_config" | "git_global" => inspect_git_config(),
129        "databases" | "database" | "db_services" | "db" => inspect_databases(),
130        "user_accounts" | "users" | "local_users" | "accounts" => inspect_user_accounts(max_entries),
131        "audit_policy" | "audit" | "auditpol" => inspect_audit_policy(),
132        "shares" | "smb_shares" | "network_shares" | "mapped_drives" => inspect_shares(max_entries),
133        "dns_servers" | "dns_config" | "dns_resolver" | "nameservers" => inspect_dns_servers(),
134        "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => inspect_bitlocker(),
135        "rdp" | "remote_desktop" | "rdp_status" => inspect_rdp(),
136        "shadow_copies" | "vss" | "volume_shadow" | "backups" | "snapshots" => inspect_shadow_copies(),
137        "pagefile" | "page_file" | "virtual_memory" | "swap" => inspect_pagefile(),
138        "windows_features" | "optional_features" | "installed_features" | "features" => inspect_windows_features(max_entries),
139        "printers" | "printer" | "print_queue" | "printing" => inspect_printers(max_entries),
140        "winrm" | "remote_management" | "psremoting" => inspect_winrm(),
141        "network_stats" | "adapter_stats" | "nic_stats" | "interface_stats" => inspect_network_stats(max_entries),
142        "udp_ports" | "udp_listeners" | "udp" => inspect_udp_ports(max_entries),
143        "gpo" | "group_policy" | "applied_policies" => inspect_gpo(),
144        "certificates" | "certs" | "ssl_certs" => inspect_certificates(max_entries),
145        "integrity" | "sfc" | "dism" | "system_health_deep" => inspect_integrity(),
146        "domain" | "active_directory" | "ad_context" | "workgroup" => inspect_domain(),
147        "device_health" | "hardware_errors" | "yellow_bangs" => inspect_device_health(),
148        "drivers" | "system_drivers" | "driver_list" => inspect_drivers(max_entries),
149        "peripherals" | "usb" | "input_devices" | "connected_hardware" => inspect_peripherals(max_entries),
150        "sessions" | "logins" | "active_sessions" => inspect_sessions(max_entries),
151        "data_audit" | "csv_audit" | "file_audit" => {
152            let path = resolve_optional_path(args)?;
153            inspect_data_audit(path, max_entries).await
154        }
155        "repo_doctor" => {
156            let path = resolve_optional_path(args)?;
157            inspect_repo_doctor(path, max_entries)
158        }
159        "directory" => {
160            let raw_path = args
161                .get("path")
162                .and_then(|v| v.as_str())
163                .ok_or_else(|| {
164                    "Missing required argument: 'path' for inspect_host(topic: \"directory\")"
165                        .to_string()
166                })?;
167            let resolved = resolve_path(raw_path)?;
168            inspect_directory("Directory", resolved, max_entries).await
169        }
170        "disk_benchmark" | "stress_test" | "io_intensity" => {
171            let path = resolve_optional_path(args)?;
172            inspect_disk_benchmark(path).await
173        }
174        "permissions" | "acl" | "access_control" => {
175            let path = resolve_optional_path(args)?;
176            inspect_permissions(path, max_entries)
177        }
178        "login_history" | "logon_history" | "user_logins" => {
179            inspect_login_history(max_entries)
180        }
181        "share_access" | "unc_access" | "remote_share" => {
182            let path = resolve_path(args.get("path").and_then(|v| v.as_str()).unwrap_or(""))?;
183            inspect_share_access(path)
184        }
185        "registry_audit" | "persistence" | "integrity_audit" => inspect_registry_audit(),
186        "thermal" | "throttling" | "overheating" => inspect_thermal(),
187        "activation" | "license_status" | "slmgr" => inspect_activation(),
188        "patch_history" | "hotfixes" | "recent_patches" => inspect_patch_history(max_entries),
189        "ad_user" | "ad" | "domain_user" => {
190            let identity = parse_name_filter(args).unwrap_or_default();
191            inspect_ad_user(&identity)
192        }
193        "dns_lookup" | "dig" | "nslookup" => {
194            let name = parse_name_filter(args).unwrap_or_default();
195            let record_type = args.get("type").and_then(|v| v.as_str()).unwrap_or("A");
196            inspect_dns_lookup(&name, record_type)
197        }
198        "hyperv" | "hyper-v" | "vms" => inspect_hyperv(),
199        "ip_config" | "ip_detail" => inspect_ip_config(),
200        "dhcp" | "dhcp_lease" | "lease" | "dhcp_detail" => inspect_dhcp(),
201        "mtu" | "path_mtu" | "pmtu" | "frame_size" | "mtu_discovery" => inspect_mtu(),
202        "ipv6" | "ipv6_status" | "ipv6_address" | "ipv6_prefix" | "ipv6_config" | "slaac" | "dhcpv6" => inspect_ipv6(),
203        "tcp_params" | "tcp_settings" | "tcp_autotuning" | "tcp_config" | "tcp_tuning" | "tcp_window" => inspect_tcp_params(),
204        "wlan_profiles" | "wifi_profiles" | "wireless_profiles" | "saved_wifi" | "saved_networks" => inspect_wlan_profiles(),
205        "ipsec" | "ipsec_sa" | "ipsec_policy" | "ipsec_rules" | "ipsec_tunnel" | "ike" => inspect_ipsec(),
206        "netbios" | "netbios_status" | "wins" | "nbtstat" | "netbios_config" => inspect_netbios(),
207        "nic_teaming" | "nic_team" | "teaming" | "lacp" | "bonding" | "link_aggregation" => inspect_nic_teaming(),
208        "snmp" | "snmp_agent" | "snmp_service" | "snmp_config" => inspect_snmp(),
209        "port_test" | "port_check" | "test_port" | "check_port" | "tcp_test" | "reachable" => {
210            let pt_host = args.get("host").and_then(|v| v.as_str()).map(|s| s.to_string());
211            let pt_port = args.get("port").and_then(|v| v.as_u64()).map(|p| p as u16);
212            inspect_port_test(pt_host.as_deref(), pt_port)
213        }
214        "network_profile" | "network_location" | "net_profile" | "network_category" => inspect_network_profile(),
215        "overclocker" | "thermal_deep" | "clocks" | "voltage" => inspect_overclocker().await,
216        "display_config" | "display" | "monitor" | "monitors" | "resolution" | "refresh_rate" | "screen" => {
217            inspect_display_config(max_entries)
218        }
219        "ntp" | "time_sync" | "time_synchronization" | "clock_sync" | "w32tm" | "clock" => {
220            inspect_ntp()
221        }
222        "cpu_power" | "turbo_boost" | "cpu_frequency" | "cpu_freq" | "processor_power" | "boost" => {
223            inspect_cpu_power()
224        }
225        "credentials" | "credential_manager" | "saved_passwords" | "stored_credentials" => {
226            inspect_credentials(max_entries)
227        }
228        "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
229            inspect_tpm()
230        }
231        "latency" | "ping" | "ping_test" | "rtt" | "packet_loss" | "reachability" => {
232            inspect_latency()
233        }
234        "network_adapter" | "nic" | "nic_settings" | "adapter_settings" | "nic_offload" | "nic_advanced" => {
235            inspect_network_adapter()
236        }
237        "event_query" | "event_log" | "events" | "event_search" | "eventlog" => {
238            let event_id = args.get("event_id").and_then(|v| v.as_u64()).map(|n| n as u32);
239            let log_name = args.get("log").and_then(|v| v.as_str()).map(|s| s.to_string());
240            let source = args.get("source").and_then(|v| v.as_str()).map(|s| s.to_string());
241            let hours = args.get("hours").and_then(|v| v.as_u64()).unwrap_or(24) as u32;
242            let level = args.get("level").and_then(|v| v.as_str()).map(|s| s.to_string());
243            inspect_event_query(event_id, log_name.as_deref(), source.as_deref(), hours, level.as_deref(), max_entries)
244        }
245        "app_crashes" | "application_crashes" | "app_errors" | "application_errors" | "faulting_application" => {
246            let process_filter = args.get("process").and_then(|v| v.as_str()).map(|s| s.to_string());
247            inspect_app_crashes(process_filter.as_deref(), max_entries)
248        }
249        "mdm_enrollment" | "mdm" | "intune" | "intune_enrollment" | "device_enrollment" | "autopilot" => {
250            inspect_mdm_enrollment()
251        }
252        "storage_spaces" | "storage_pool" | "storage_pools" | "virtual_disk" | "virtual_disks" | "windows_raid" => {
253            inspect_storage_spaces()
254        }
255        "defender_quarantine" | "quarantine" | "threat_history" | "malware_history" | "defender_history" | "detected_threats" => {
256            inspect_defender_quarantine(max_entries)
257        }
258        "domain_health" | "dc_connectivity" | "ad_connectivity" | "kerberos_health" => {
259            inspect_domain_health()
260        }
261        "service_dependencies" | "svc_deps" | "service_deps" => {
262            inspect_service_dependencies(max_entries)
263        }
264        "wmi_health" | "wmi_repository" | "wmi_status" => {
265            inspect_wmi_health()
266        }
267        "local_security_policy" | "password_policy" | "account_policy" | "ntlm_policy" | "lm_policy" => {
268            inspect_local_security_policy()
269        }
270        "usb_history" | "usb_devices" | "usb_forensics" | "usbstor" => {
271            inspect_usb_history(max_entries)
272        }
273        "print_spooler" | "spooler" | "printnightmare" | "print_security" | "printer_security" => {
274            inspect_print_spooler()
275        }
276        other => Err(format!(
277            "Unknown inspect_host topic '{}'. Use one of: summary, toolchains, path, env_doctor, fix_plan, network, lan_discovery, audio, bluetooth, camera, sign_in, installer_health, onedrive, browser_health, identity_auth, outlook, teams, windows_backup, search_index, display_config, ntp, cpu_power, credentials, tpm, latency, network_adapter, dhcp, mtu, ipv6, tcp_params, wlan_profiles, ipsec, netbios, nic_teaming, snmp, port_test, public_ip, ssl_cert, data_audit, network_profile, services, processes, desktop, downloads, directory, disk_benchmark, disk, ports, repo_doctor, log_check, startup_items, health_report, storage, hardware, updates, security, pending_reboot, disk_health, battery, recent_crashes, app_crashes, scheduled_tasks, dev_conflicts, connectivity, wifi, connections, vpn, proxy, firewall_rules, traceroute, dns_cache, arp, route_table, os_config, resource_load, env, hosts_file, docker, docker_filesystems, wsl, wsl_filesystems, ssh, installed_software, git_config, databases, user_accounts, audit_policy, shares, dns_servers, bitlocker, rdp, shadow_copies, pagefile, windows_features, printers, winrm, network_stats, udp_ports, gpo, certificates, integrity, domain, domain_health, device_health, drivers, peripherals, sessions, permissions, login_history, share_access, registry_audit, thermal, activation, patch_history, ad_user, dns_lookup, hyperv, ip_config, overclocker, event_query, mdm_enrollment, storage_spaces, defender_quarantine, service_dependencies, wmi_health, local_security_policy, usb_history, print_spooler.",
278            other
279        )),
280
281    };
282
283    result.map(|body| annotate_privilege_limited_output(topic.as_str(), body))
284}
285
286fn annotate_privilege_limited_output(topic: &str, body: String) -> String {
287    let Some(scope) = admin_sensitive_topic_scope(topic) else {
288        return body;
289    };
290    let lower = body.to_lowercase();
291    let privilege_limited = lower.contains("access denied")
292        || lower.contains("administrator privilege is required")
293        || lower.contains("administrator privileges required")
294        || lower.contains("requires administrator")
295        || lower.contains("requires elevation")
296        || lower.contains("non-admin session")
297        || lower.contains("could not be fully determined from this session");
298    if !privilege_limited || lower.contains("=== elevation note ===") {
299        return body;
300    }
301
302    let mut annotated = body;
303    annotated.push_str("\n=== Elevation note ===\n");
304    annotated.push_str("- Hematite should stay non-admin by default.\n");
305    annotated.push_str(
306        "- This result may be partial because Windows restricted one or more read-only provider calls in the current session.\n",
307    );
308    annotated.push_str(&format!(
309        "- Rerun Hematite as Administrator only if you need a definitive {scope} answer.\n"
310    ));
311    annotated
312}
313
314fn admin_sensitive_topic_scope(topic: &str) -> Option<&'static str> {
315    match topic {
316        "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
317            Some("TPM / Secure Boot / firmware")
318        }
319        "gpo" | "group_policy" | "applied_policies" => Some("Group Policy"),
320        "audit_policy" | "audit" | "auditpol" => Some("audit policy"),
321        "winrm" | "remote_management" | "psremoting" => Some("WinRM"),
322        "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => Some("BitLocker"),
323        "windows_features" | "optional_features" | "installed_features" | "features" => {
324            Some("Windows Features")
325        }
326        "udp_ports" | "udp_listeners" | "udp" => Some("UDP listener"),
327        _ => None,
328    }
329}
330
331#[cfg(test)]
332mod privilege_hint_tests {
333    use super::annotate_privilege_limited_output;
334
335    #[test]
336    fn annotate_privilege_limited_output_only_tags_admin_sensitive_topics() {
337        let body = "Host inspection: network\nError: Access denied.\n".to_string();
338        let annotated = annotate_privilege_limited_output("network", body.clone());
339        assert_eq!(annotated, body);
340    }
341
342    #[test]
343    fn annotate_privilege_limited_output_adds_targeted_note_for_tpm() {
344        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();
345        let annotated = annotate_privilege_limited_output("tpm", body);
346        assert!(annotated.contains("=== Elevation note ==="));
347        assert!(annotated.contains("stay non-admin by default"));
348        assert!(annotated.contains("definitive TPM / Secure Boot / firmware answer"));
349    }
350}
351
352#[cfg(test)]
353mod event_query_tests {
354    use super::is_event_query_no_results_message;
355
356    #[cfg(target_os = "windows")]
357    #[test]
358    fn treats_windows_no_results_message_as_empty_query() {
359        assert!(is_event_query_no_results_message(
360            "No events were found that match the specified selection criteria."
361        ));
362    }
363
364    #[cfg(target_os = "windows")]
365    #[test]
366    fn does_not_treat_real_errors_as_empty_query() {
367        assert!(!is_event_query_no_results_message("Access is denied."));
368    }
369}
370
371fn parse_max_entries(args: &Value) -> usize {
372    args.get("max_entries")
373        .and_then(|v| v.as_u64())
374        .map(|n| n as usize)
375        .unwrap_or(DEFAULT_MAX_ENTRIES)
376        .clamp(1, MAX_ENTRIES_CAP)
377}
378
379fn parse_port_filter(args: &Value) -> Option<u16> {
380    args.get("port")
381        .and_then(|v| v.as_u64())
382        .and_then(|n| u16::try_from(n).ok())
383}
384
385fn parse_name_filter(args: &Value) -> Option<String> {
386    args.get("name")
387        .and_then(|v| v.as_str())
388        .map(str::trim)
389        .filter(|value| !value.is_empty())
390        .map(|value| value.to_string())
391}
392
393fn parse_lookback_hours(args: &Value) -> Option<u32> {
394    args.get("lookback_hours")
395        .and_then(|v| v.as_u64())
396        .map(|n| n as u32)
397}
398
399fn parse_issue_text(args: &Value) -> Option<String> {
400    args.get("issue")
401        .and_then(|v| v.as_str())
402        .map(str::trim)
403        .filter(|value| !value.is_empty())
404        .map(|value| value.to_string())
405}
406
407#[cfg(target_os = "windows")]
408fn is_event_query_no_results_message(message: &str) -> bool {
409    let lower = message.to_ascii_lowercase();
410    lower.contains("no events were found")
411        || lower.contains("no events match the specified selection criteria")
412}
413
414fn resolve_optional_path(args: &Value) -> Result<PathBuf, String> {
415    match args.get("path").and_then(|v| v.as_str()) {
416        Some(raw_path) => resolve_path(raw_path),
417        None => {
418            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))
419        }
420    }
421}
422
423fn inspect_summary(max_entries: usize) -> Result<String, String> {
424    let current_dir =
425        std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
426    let workspace_root = crate::tools::file_ops::workspace_root();
427    let workspace_mode = workspace_mode_label(&workspace_root);
428    let path_stats = analyze_path_env();
429    let toolchains = collect_toolchains();
430
431    let mut out = String::from("Host inspection: summary\n\n");
432    out.push_str(&format!("- OS: {}\n", std::env::consts::OS));
433    out.push_str(&format!("- Current directory: {}\n", current_dir.display()));
434    out.push_str(&format!("- Workspace root: {}\n", workspace_root.display()));
435    out.push_str(&format!("- Workspace mode: {}\n", workspace_mode));
436    out.push_str(&format!("- Preferred shell: {}\n", preferred_shell_label()));
437    out.push_str(&format!(
438        "- PATH entries: {} total, {} unique, {} duplicates, {} missing\n",
439        path_stats.total_entries,
440        path_stats.unique_entries,
441        path_stats.duplicate_entries.len(),
442        path_stats.missing_entries.len()
443    ));
444
445    if toolchains.found.is_empty() {
446        out.push_str(
447            "- Toolchains found: none of the common developer tools were detected on PATH\n",
448        );
449    } else {
450        out.push_str("- Toolchains found:\n");
451        for (label, version) in toolchains.found.iter().take(max_entries.min(8)) {
452            out.push_str(&format!("  - {}: {}\n", label, version));
453        }
454        if toolchains.found.len() > max_entries.min(8) {
455            out.push_str(&format!(
456                "  - ... {} more found tools omitted\n",
457                toolchains.found.len() - max_entries.min(8)
458            ));
459        }
460    }
461
462    if !toolchains.missing.is_empty() {
463        out.push_str(&format!(
464            "- Common tools not detected on PATH: {}\n",
465            toolchains.missing.join(", ")
466        ));
467    }
468
469    for (label, path) in [("Desktop", desktop_dir()), ("Downloads", downloads_dir())] {
470        match path {
471            Some(path) if path.exists() => match count_top_level_items(&path) {
472                Ok(count) => out.push_str(&format!(
473                    "- {}: {} top-level items at {}\n",
474                    label,
475                    count,
476                    path.display()
477                )),
478                Err(e) => out.push_str(&format!(
479                    "- {}: exists at {} but could not inspect ({})\n",
480                    label,
481                    path.display(),
482                    e
483                )),
484            },
485            Some(path) => out.push_str(&format!(
486                "- {}: expected at {} but not found\n",
487                label,
488                path.display()
489            )),
490            None => out.push_str(&format!("- {}: location unavailable on this host\n", label)),
491        }
492    }
493
494    Ok(out.trim_end().to_string())
495}
496
497fn inspect_toolchains() -> Result<String, String> {
498    let report = collect_toolchains();
499    let mut out = String::from("Host inspection: toolchains\n\n");
500
501    if report.found.is_empty() {
502        out.push_str("- No common developer tools were detected on PATH.");
503    } else {
504        out.push_str("Detected developer tools:\n");
505        for (label, version) in report.found {
506            out.push_str(&format!("- {}: {}\n", label, version));
507        }
508    }
509
510    if !report.missing.is_empty() {
511        out.push_str("\nNot detected on PATH:\n");
512        for label in report.missing {
513            out.push_str(&format!("- {}\n", label));
514        }
515    }
516
517    Ok(out.trim_end().to_string())
518}
519
520fn inspect_path(max_entries: usize) -> Result<String, String> {
521    let path_stats = analyze_path_env();
522    let mut out = String::from("Host inspection: PATH\n\n");
523    out.push_str(&format!("- Total entries: {}\n", path_stats.total_entries));
524    out.push_str(&format!(
525        "- Unique entries: {}\n",
526        path_stats.unique_entries
527    ));
528    out.push_str(&format!(
529        "- Duplicate entries: {}\n",
530        path_stats.duplicate_entries.len()
531    ));
532    out.push_str(&format!(
533        "- Missing paths: {}\n",
534        path_stats.missing_entries.len()
535    ));
536
537    out.push_str("\nPATH entries:\n");
538    for entry in path_stats.entries.iter().take(max_entries) {
539        out.push_str(&format!("- {}\n", entry));
540    }
541    if path_stats.entries.len() > max_entries {
542        out.push_str(&format!(
543            "- ... {} more entries omitted\n",
544            path_stats.entries.len() - max_entries
545        ));
546    }
547
548    if !path_stats.duplicate_entries.is_empty() {
549        out.push_str("\nDuplicate entries:\n");
550        for entry in path_stats.duplicate_entries.iter().take(max_entries) {
551            out.push_str(&format!("- {}\n", entry));
552        }
553        if path_stats.duplicate_entries.len() > max_entries {
554            out.push_str(&format!(
555                "- ... {} more duplicates omitted\n",
556                path_stats.duplicate_entries.len() - max_entries
557            ));
558        }
559    }
560
561    if !path_stats.missing_entries.is_empty() {
562        out.push_str("\nMissing directories:\n");
563        for entry in path_stats.missing_entries.iter().take(max_entries) {
564            out.push_str(&format!("- {}\n", entry));
565        }
566        if path_stats.missing_entries.len() > max_entries {
567            out.push_str(&format!(
568                "- ... {} more missing entries omitted\n",
569                path_stats.missing_entries.len() - max_entries
570            ));
571        }
572    }
573
574    Ok(out.trim_end().to_string())
575}
576
577fn inspect_env_doctor(max_entries: usize) -> Result<String, String> {
578    let path_stats = analyze_path_env();
579    let toolchains = collect_toolchains();
580    let package_managers = collect_package_managers();
581    let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
582
583    let mut out = String::from("Host inspection: env_doctor\n\n");
584    out.push_str(&format!(
585        "- PATH health: {} duplicates, {} missing entries\n",
586        path_stats.duplicate_entries.len(),
587        path_stats.missing_entries.len()
588    ));
589    out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
590    out.push_str(&format!(
591        "- Package managers found: {}\n",
592        package_managers.found.len()
593    ));
594
595    if !package_managers.found.is_empty() {
596        out.push_str("\nPackage managers:\n");
597        for (label, version) in package_managers.found.iter().take(max_entries) {
598            out.push_str(&format!("- {}: {}\n", label, version));
599        }
600        if package_managers.found.len() > max_entries {
601            out.push_str(&format!(
602                "- ... {} more package managers omitted\n",
603                package_managers.found.len() - max_entries
604            ));
605        }
606    }
607
608    if !path_stats.duplicate_entries.is_empty() {
609        out.push_str("\nDuplicate PATH entries:\n");
610        for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
611            out.push_str(&format!("- {}\n", entry));
612        }
613        if path_stats.duplicate_entries.len() > max_entries.min(5) {
614            out.push_str(&format!(
615                "- ... {} more duplicate entries omitted\n",
616                path_stats.duplicate_entries.len() - max_entries.min(5)
617            ));
618        }
619    }
620
621    if !path_stats.missing_entries.is_empty() {
622        out.push_str("\nMissing PATH entries:\n");
623        for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
624            out.push_str(&format!("- {}\n", entry));
625        }
626        if path_stats.missing_entries.len() > max_entries.min(5) {
627            out.push_str(&format!(
628                "- ... {} more missing entries omitted\n",
629                path_stats.missing_entries.len() - max_entries.min(5)
630            ));
631        }
632    }
633
634    if !findings.is_empty() {
635        out.push_str("\nFindings:\n");
636        for finding in findings.iter().take(max_entries.max(5)) {
637            out.push_str(&format!("- {}\n", finding));
638        }
639        if findings.len() > max_entries.max(5) {
640            out.push_str(&format!(
641                "- ... {} more findings omitted\n",
642                findings.len() - max_entries.max(5)
643            ));
644        }
645    } else {
646        out.push_str("\nFindings:\n- No obvious environment drift was detected from PATH and package-manager checks.");
647    }
648
649    out.push_str(
650        "\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.",
651    );
652
653    Ok(out.trim_end().to_string())
654}
655
656#[derive(Clone, Copy, Debug, Eq, PartialEq)]
657enum FixPlanKind {
658    EnvPath,
659    PortConflict,
660    LmStudio,
661    DriverInstall,
662    GroupPolicy,
663    FirewallRule,
664    SshKey,
665    WslSetup,
666    ServiceConfig,
667    WindowsActivation,
668    RegistryEdit,
669    ScheduledTaskCreate,
670    DiskCleanup,
671    DnsResolution,
672    Generic,
673}
674
675async fn inspect_fix_plan(
676    issue: Option<String>,
677    port_filter: Option<u16>,
678    max_entries: usize,
679) -> Result<String, String> {
680    let issue = issue.unwrap_or_else(|| {
681        "Help me fix PATH, toolchain, port-conflict, or LM Studio connectivity problems."
682            .to_string()
683    });
684    let plan_kind = classify_fix_plan_kind(&issue, port_filter);
685    match plan_kind {
686        FixPlanKind::EnvPath => inspect_env_fix_plan(&issue, max_entries),
687        FixPlanKind::PortConflict => inspect_port_fix_plan(&issue, port_filter, max_entries),
688        FixPlanKind::LmStudio => inspect_lm_studio_fix_plan(&issue, max_entries).await,
689        FixPlanKind::DriverInstall => inspect_driver_install_fix_plan(&issue),
690        FixPlanKind::GroupPolicy => inspect_group_policy_fix_plan(&issue),
691        FixPlanKind::FirewallRule => inspect_firewall_rule_fix_plan(&issue),
692        FixPlanKind::SshKey => inspect_ssh_key_fix_plan(&issue),
693        FixPlanKind::WslSetup => inspect_wsl_setup_fix_plan(&issue),
694        FixPlanKind::ServiceConfig => inspect_service_config_fix_plan(&issue),
695        FixPlanKind::WindowsActivation => inspect_windows_activation_fix_plan(&issue),
696        FixPlanKind::RegistryEdit => inspect_registry_edit_fix_plan(&issue),
697        FixPlanKind::ScheduledTaskCreate => inspect_scheduled_task_fix_plan(&issue),
698        FixPlanKind::DiskCleanup => inspect_disk_cleanup_fix_plan(&issue),
699        FixPlanKind::DnsResolution => inspect_dns_fix_plan(&issue),
700        FixPlanKind::Generic => inspect_generic_fix_plan(&issue),
701    }
702}
703
704fn classify_fix_plan_kind(issue: &str, port_filter: Option<u16>) -> FixPlanKind {
705    let lower = issue.to_ascii_lowercase();
706    // FirewallRule must be checked before PortConflict — "open port 80 in the firewall"
707    // is firewall rule creation, not a port ownership conflict.
708    if lower.contains("firewall rule")
709        || lower.contains("inbound rule")
710        || lower.contains("outbound rule")
711        || (lower.contains("firewall")
712            && (lower.contains("allow")
713                || lower.contains("block")
714                || lower.contains("create")
715                || lower.contains("open")))
716    {
717        FixPlanKind::FirewallRule
718    } else if port_filter.is_some()
719        || lower.contains("port ")
720        || lower.contains("address already in use")
721        || lower.contains("already in use")
722        || lower.contains("what owns port")
723        || lower.contains("listening on port")
724    {
725        FixPlanKind::PortConflict
726    } else if lower.contains("lm studio")
727        || lower.contains("localhost:1234")
728        || lower.contains("/v1/models")
729        || lower.contains("no coding model loaded")
730        || lower.contains("embedding model")
731        || lower.contains("server on port 1234")
732        || lower.contains("runtime refresh")
733    {
734        FixPlanKind::LmStudio
735    } else if lower.contains("driver")
736        || lower.contains("gpu driver")
737        || lower.contains("nvidia driver")
738        || lower.contains("amd driver")
739        || lower.contains("install driver")
740        || lower.contains("update driver")
741    {
742        FixPlanKind::DriverInstall
743    } else if lower.contains("group policy")
744        || lower.contains("gpedit")
745        || lower.contains("local policy")
746        || lower.contains("secpol")
747        || lower.contains("administrative template")
748    {
749        FixPlanKind::GroupPolicy
750    } else if lower.contains("ssh key")
751        || lower.contains("ssh-keygen")
752        || lower.contains("generate ssh")
753        || lower.contains("authorized_keys")
754        || lower.contains("id_rsa")
755        || lower.contains("id_ed25519")
756    {
757        FixPlanKind::SshKey
758    } else if lower.contains("wsl")
759        || lower.contains("windows subsystem for linux")
760        || lower.contains("install ubuntu")
761        || lower.contains("install linux on windows")
762        || lower.contains("wsl2")
763    {
764        FixPlanKind::WslSetup
765    } else if lower.contains("service")
766        && (lower.contains("start ")
767            || lower.contains("stop ")
768            || lower.contains("restart ")
769            || lower.contains("enable ")
770            || lower.contains("disable ")
771            || lower.contains("configure service"))
772    {
773        FixPlanKind::ServiceConfig
774    } else if lower.contains("activate windows")
775        || lower.contains("windows activation")
776        || lower.contains("product key")
777        || lower.contains("kms")
778        || lower.contains("not activated")
779    {
780        FixPlanKind::WindowsActivation
781    } else if lower.contains("registry")
782        || lower.contains("regedit")
783        || lower.contains("hklm")
784        || lower.contains("hkcu")
785        || lower.contains("reg add")
786        || lower.contains("reg delete")
787        || lower.contains("registry key")
788    {
789        FixPlanKind::RegistryEdit
790    } else if lower.contains("scheduled task")
791        || lower.contains("task scheduler")
792        || lower.contains("schtasks")
793        || lower.contains("create task")
794        || lower.contains("run on startup")
795        || lower.contains("run on schedule")
796        || lower.contains("cron")
797    {
798        FixPlanKind::ScheduledTaskCreate
799    } else if lower.contains("disk cleanup")
800        || lower.contains("free up disk")
801        || lower.contains("free up space")
802        || lower.contains("clear cache")
803        || lower.contains("disk full")
804        || lower.contains("low disk space")
805        || lower.contains("reclaim space")
806    {
807        FixPlanKind::DiskCleanup
808    } else if lower.contains("cargo")
809        || lower.contains("rustc")
810        || lower.contains("path")
811        || lower.contains("package manager")
812        || lower.contains("package managers")
813        || lower.contains("toolchain")
814        || lower.contains("winget")
815        || lower.contains("choco")
816        || lower.contains("scoop")
817        || lower.contains("python")
818        || lower.contains("node")
819    {
820        FixPlanKind::EnvPath
821    } else if lower.contains("dns ")
822        || lower.contains("nameserver")
823        || lower.contains("cannot resolve")
824        || lower.contains("nslookup")
825        || lower.contains("flushdns")
826    {
827        FixPlanKind::DnsResolution
828    } else {
829        FixPlanKind::Generic
830    }
831}
832
833fn inspect_env_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
834    let path_stats = analyze_path_env();
835    let toolchains = collect_toolchains();
836    let package_managers = collect_package_managers();
837    let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
838    let found_tools = toolchains
839        .found
840        .iter()
841        .map(|(label, _)| label.as_str())
842        .collect::<HashSet<_>>();
843    let found_managers = package_managers
844        .found
845        .iter()
846        .map(|(label, _)| label.as_str())
847        .collect::<HashSet<_>>();
848
849    let mut out = String::from("Host inspection: fix_plan\n\n");
850    out.push_str(&format!("- Requested issue: {}\n", issue));
851    out.push_str("- Fix-plan type: environment/path\n");
852    out.push_str(&format!(
853        "- PATH health: {} duplicates, {} missing entries\n",
854        path_stats.duplicate_entries.len(),
855        path_stats.missing_entries.len()
856    ));
857    out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
858    out.push_str(&format!(
859        "- Package managers found: {}\n",
860        package_managers.found.len()
861    ));
862
863    out.push_str("\nLikely causes:\n");
864    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
865        out.push_str(
866            "- 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",
867        );
868    }
869    if path_stats.duplicate_entries.is_empty()
870        && path_stats.missing_entries.is_empty()
871        && !findings.is_empty()
872    {
873        for finding in findings.iter().take(max_entries.max(4)) {
874            out.push_str(&format!("- {}\n", finding));
875        }
876    } else {
877        if !path_stats.duplicate_entries.is_empty() {
878            out.push_str("- Duplicate PATH rows create clutter and can hide which install path is actually winning.\n");
879        }
880        if !path_stats.missing_entries.is_empty() {
881            out.push_str("- Stale PATH rows point at directories that no longer exist, which makes environment drift harder to reason about.\n");
882        }
883    }
884    if found_tools.contains("node")
885        && !found_managers.contains("npm")
886        && !found_managers.contains("pnpm")
887    {
888        out.push_str("- Node is present without a detected package manager. That usually means a partial install or PATH drift.\n");
889    }
890    if found_tools.contains("python")
891        && !found_managers.contains("pip")
892        && !found_managers.contains("uv")
893        && !found_managers.contains("pipx")
894    {
895        out.push_str("- Python is present without a detected package manager. That usually means the launcher works but Scripts/bin is not discoverable.\n");
896    }
897
898    out.push_str("\nFix plan:\n");
899    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");
900    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
901        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");
902    } else if !found_tools.contains("rustc") && !found_managers.contains("cargo") {
903        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");
904    }
905    if !path_stats.duplicate_entries.is_empty() || !path_stats.missing_entries.is_empty() {
906        out.push_str("- Clean duplicate or dead PATH rows in Environment Variables so the winning toolchain path is obvious and stable.\n");
907    }
908    if found_tools.contains("node")
909        && !found_managers.contains("npm")
910        && !found_managers.contains("pnpm")
911    {
912        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");
913    }
914    if found_tools.contains("python")
915        && !found_managers.contains("pip")
916        && !found_managers.contains("uv")
917        && !found_managers.contains("pipx")
918    {
919        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");
920    }
921
922    if !path_stats.duplicate_entries.is_empty() {
923        out.push_str("\nExample duplicate PATH rows:\n");
924        for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
925            out.push_str(&format!("- {}\n", entry));
926        }
927    }
928    if !path_stats.missing_entries.is_empty() {
929        out.push_str("\nExample missing PATH rows:\n");
930        for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
931            out.push_str(&format!("- {}\n", entry));
932        }
933    }
934
935    out.push_str(
936        "\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.",
937    );
938    Ok(out.trim_end().to_string())
939}
940
941fn inspect_port_fix_plan(
942    issue: &str,
943    port_filter: Option<u16>,
944    max_entries: usize,
945) -> Result<String, String> {
946    let requested_port = port_filter.or_else(|| first_port_in_text(issue));
947    let listeners = collect_listening_ports().unwrap_or_default();
948    let mut matching = listeners;
949    if let Some(port) = requested_port {
950        matching.retain(|entry| entry.port == port);
951    }
952    let processes = collect_processes().unwrap_or_default();
953
954    let mut out = String::from("Host inspection: fix_plan\n\n");
955    out.push_str(&format!("- Requested issue: {}\n", issue));
956    out.push_str("- Fix-plan type: port_conflict\n");
957    if let Some(port) = requested_port {
958        out.push_str(&format!("- Requested port: {}\n", port));
959    } else {
960        out.push_str("- Requested port: not parsed from the issue text\n");
961    }
962    out.push_str(&format!("- Matching listeners found: {}\n", matching.len()));
963
964    if !matching.is_empty() {
965        out.push_str("\nCurrent listeners:\n");
966        for entry in matching.iter().take(max_entries.min(5)) {
967            let process_name = entry
968                .pid
969                .as_deref()
970                .and_then(|pid| pid.parse::<u32>().ok())
971                .and_then(|pid| {
972                    processes
973                        .iter()
974                        .find(|process| process.pid == pid)
975                        .map(|process| process.name.as_str())
976                })
977                .unwrap_or("unknown");
978            let pid = entry.pid.as_deref().unwrap_or("unknown");
979            out.push_str(&format!(
980                "- {} {} ({}) pid {} process {}\n",
981                entry.protocol, entry.local, entry.state, pid, process_name
982            ));
983        }
984    }
985
986    out.push_str("\nFix plan:\n");
987    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");
988    if !matching.is_empty() {
989        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");
990    } else {
991        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");
992    }
993    out.push_str("- If the port is intentionally occupied, move your app to another port instead of fighting the existing process.\n");
994    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");
995    out.push_str(
996        "\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.",
997    );
998    Ok(out.trim_end().to_string())
999}
1000
1001async fn inspect_lm_studio_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
1002    let config = crate::agent::config::load_config();
1003    let configured_api = config
1004        .api_url
1005        .unwrap_or_else(|| "http://localhost:1234/v1".to_string());
1006    let models_url = format!("{}/models", configured_api.trim_end_matches('/'));
1007    let reachability = probe_http_endpoint(&models_url).await;
1008    let embed_model = detect_loaded_embed_model(&configured_api).await;
1009
1010    let mut out = String::from("Host inspection: fix_plan\n\n");
1011    out.push_str(&format!("- Requested issue: {}\n", issue));
1012    out.push_str("- Fix-plan type: lm_studio\n");
1013    out.push_str(&format!("- Configured API URL: {}\n", configured_api));
1014    out.push_str(&format!("- Probe URL: {}\n", models_url));
1015    match &reachability {
1016        EndpointProbe::Reachable(status) => {
1017            out.push_str(&format!("- Endpoint reachable: yes (HTTP {})\n", status))
1018        }
1019        EndpointProbe::Unreachable(detail) => {
1020            out.push_str(&format!("- Endpoint reachable: no ({})\n", detail))
1021        }
1022    }
1023    out.push_str(&format!(
1024        "- Embedding model loaded: {}\n",
1025        embed_model.as_deref().unwrap_or("none detected")
1026    ));
1027
1028    out.push_str("\nFix plan:\n");
1029    match reachability {
1030        EndpointProbe::Reachable(_) => {
1031            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");
1032        }
1033        EndpointProbe::Unreachable(_) => {
1034            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");
1035        }
1036    }
1037    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");
1038    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");
1039    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");
1040    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");
1041    if let Some(model) = embed_model {
1042        out.push_str(&format!(
1043            "- Current embedding model already visible: {}. That means the embeddings lane is configured, so focus on the chat model or endpoint next.\n",
1044            model
1045        ));
1046    }
1047    if max_entries > 0 {
1048        out.push_str(
1049            "\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.",
1050        );
1051    }
1052    Ok(out.trim_end().to_string())
1053}
1054
1055fn inspect_driver_install_fix_plan(issue: &str) -> Result<String, String> {
1056    // Read GPU info from the hardware topic output for grounding
1057    #[cfg(target_os = "windows")]
1058    let gpu_info = {
1059        let out = Command::new("powershell")
1060            .args([
1061                "-NoProfile",
1062                "-NonInteractive",
1063                "-Command",
1064                "Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate | ForEach-Object { \"GPU: $($_.Name) | Driver: $($_.DriverVersion) | Date: $($_.DriverDate)\" }",
1065            ])
1066            .output()
1067            .ok()
1068            .and_then(|o| String::from_utf8(o.stdout).ok())
1069            .unwrap_or_default();
1070        out.trim().to_string()
1071    };
1072    #[cfg(not(target_os = "windows"))]
1073    let gpu_info = String::from("(GPU detection not available on this platform)");
1074
1075    let mut out = String::from("Host inspection: fix_plan\n\n");
1076    out.push_str(&format!("- Requested issue: {}\n", issue));
1077    out.push_str("- Fix-plan type: driver_install\n");
1078    if !gpu_info.is_empty() {
1079        out.push_str(&format!("\nDetected GPU(s):\n{}\n", gpu_info));
1080    }
1081    out.push_str("\nFix plan — Installing or updating GPU drivers:\n");
1082    out.push_str("1. Identify your GPU make from the detection above (NVIDIA, AMD, or Intel).\n");
1083    out.push_str(
1084        "2. Open Device Manager: press Win+X → Device Manager → expand Display Adapters.\n",
1085    );
1086    out.push_str("3. Right-click your GPU → Properties → Driver tab — note the current driver version and date.\n");
1087    out.push_str("4. Download the latest driver directly from the manufacturer:\n");
1088    out.push_str("   - NVIDIA: geforce.com/drivers (use GeForce Experience for auto-detection)\n");
1089    out.push_str("   - AMD: amd.com/support (use Auto-Detect tool)\n");
1090    out.push_str("   - Intel: intel.com/content/www/us/en/download-center/home.html\n");
1091    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");
1092    out.push_str("6. Reboot when prompted — driver installs always require a restart.\n");
1093    out.push_str("\nVerification:\n");
1094    out.push_str("- After reboot, run in PowerShell:\n  Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate\n");
1095    out.push_str("- The DriverVersion should match what you installed.\n");
1096    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.");
1097    Ok(out.trim_end().to_string())
1098}
1099
1100fn inspect_group_policy_fix_plan(issue: &str) -> Result<String, String> {
1101    // Check Windows edition — Group Policy editor is not available on Home editions
1102    #[cfg(target_os = "windows")]
1103    let edition = {
1104        Command::new("powershell")
1105            .args([
1106                "-NoProfile",
1107                "-NonInteractive",
1108                "-Command",
1109                "(Get-CimInstance Win32_OperatingSystem).Caption",
1110            ])
1111            .output()
1112            .ok()
1113            .and_then(|o| String::from_utf8(o.stdout).ok())
1114            .unwrap_or_default()
1115            .trim()
1116            .to_string()
1117    };
1118    #[cfg(not(target_os = "windows"))]
1119    let edition = String::from("(Windows edition detection not available)");
1120
1121    let is_home = edition.to_lowercase().contains("home");
1122
1123    let mut out = String::from("Host inspection: fix_plan\n\n");
1124    out.push_str(&format!("- Requested issue: {}\n", issue));
1125    out.push_str("- Fix-plan type: group_policy\n");
1126    out.push_str(&format!(
1127        "- Windows edition detected: {}\n",
1128        if edition.is_empty() {
1129            "unknown".to_string()
1130        } else {
1131            edition.clone()
1132        }
1133    ));
1134
1135    if is_home {
1136        out.push_str("\nWARNING: Windows Home does not include the Local Group Policy Editor (gpedit.msc).\n");
1137        out.push_str("Options on Home edition:\n");
1138        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");
1139        out.push_str(
1140            "2. Install the gpedit.msc enabler script (third-party — use with caution).\n",
1141        );
1142        out.push_str("3. Upgrade to Windows Pro if you need full Group Policy support.\n");
1143    } else {
1144        out.push_str("\nFix plan — Editing Local Group Policy:\n");
1145        out.push_str("1. Press Win+R → type gpedit.msc → press Enter (requires administrator).\n");
1146        out.push_str("2. Navigate the tree: Computer Configuration (machine-wide) or User Configuration (current user).\n");
1147        out.push_str("3. Drill into Administrative Templates → find the policy you want.\n");
1148        out.push_str("4. Double-click a policy → set to Enabled, Disabled, or Not Configured.\n");
1149        out.push_str("5. Click OK — most policies apply on next logon or after gpupdate.\n");
1150        out.push_str("6. To force immediate application, run in an elevated PowerShell:\n  gpupdate /force\n");
1151    }
1152    out.push_str("\nVerification:\n");
1153    out.push_str("- Run `gpresult /r` in an elevated command prompt to see applied policies.\n");
1154    out.push_str(
1155        "- Or: `Get-GPResultantSetOfPolicy` in PowerShell (requires RSAT on domain machines).\n",
1156    );
1157    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.");
1158    Ok(out.trim_end().to_string())
1159}
1160
1161fn inspect_firewall_rule_fix_plan(issue: &str) -> Result<String, String> {
1162    #[cfg(target_os = "windows")]
1163    let profile_state = {
1164        Command::new("powershell")
1165            .args([
1166                "-NoProfile",
1167                "-NonInteractive",
1168                "-Command",
1169                "Get-NetFirewallProfile | Select-Object Name,Enabled | ForEach-Object { \"$($_.Name): $($_.Enabled)\" }",
1170            ])
1171            .output()
1172            .ok()
1173            .and_then(|o| String::from_utf8(o.stdout).ok())
1174            .unwrap_or_default()
1175            .trim()
1176            .to_string()
1177    };
1178    #[cfg(not(target_os = "windows"))]
1179    let profile_state = String::new();
1180
1181    let mut out = String::from("Host inspection: fix_plan\n\n");
1182    out.push_str(&format!("- Requested issue: {}\n", issue));
1183    out.push_str("- Fix-plan type: firewall_rule\n");
1184    if !profile_state.is_empty() {
1185        out.push_str(&format!("\nFirewall profile state:\n{}\n", profile_state));
1186    }
1187    out.push_str("\nFix plan — Creating or modifying a Windows Firewall rule (PowerShell, run as Administrator):\n");
1188    out.push_str("\nTo ALLOW inbound traffic on a port:\n");
1189    out.push_str("  New-NetFirewallRule -DisplayName \"My App Port 8080\" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any\n");
1190    out.push_str("\nTo BLOCK outbound traffic to an address:\n");
1191    out.push_str("  New-NetFirewallRule -DisplayName \"Block Example\" -Direction Outbound -RemoteAddress 1.2.3.4 -Action Block\n");
1192    out.push_str("\nTo ALLOW an application through the firewall:\n");
1193    out.push_str("  New-NetFirewallRule -DisplayName \"My App\" -Direction Inbound -Program \"C:\\Path\\To\\App.exe\" -Action Allow\n");
1194    out.push_str("\nTo REMOVE a rule you created:\n");
1195    out.push_str("  Remove-NetFirewallRule -DisplayName \"My App Port 8080\"\n");
1196    out.push_str("\nTo see existing custom rules:\n");
1197    out.push_str("  Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.PolicyStoreSourceType -ne 'GroupPolicy' } | Select-Object DisplayName,Direction,Action\n");
1198    out.push_str("\nVerification:\n");
1199    out.push_str("- After creating the rule, test reachability from another machine or use:\n  Test-NetConnection -ComputerName localhost -Port 8080\n");
1200    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.");
1201    Ok(out.trim_end().to_string())
1202}
1203
1204fn inspect_ssh_key_fix_plan(issue: &str) -> Result<String, String> {
1205    let home = dirs_home().unwrap_or_else(|| std::path::PathBuf::from("~"));
1206    let ssh_dir = home.join(".ssh");
1207    let has_ssh_dir = ssh_dir.exists();
1208    let has_ed25519 = ssh_dir.join("id_ed25519").exists();
1209    let has_rsa = ssh_dir.join("id_rsa").exists();
1210    let has_authorized_keys = ssh_dir.join("authorized_keys").exists();
1211
1212    let mut out = String::from("Host inspection: fix_plan\n\n");
1213    out.push_str(&format!("- Requested issue: {}\n", issue));
1214    out.push_str("- Fix-plan type: ssh_key\n");
1215    out.push_str(&format!("- ~/.ssh directory exists: {}\n", has_ssh_dir));
1216    out.push_str(&format!("- id_ed25519 key found: {}\n", has_ed25519));
1217    out.push_str(&format!("- id_rsa key found: {}\n", has_rsa));
1218    out.push_str(&format!(
1219        "- authorized_keys found: {}\n",
1220        has_authorized_keys
1221    ));
1222
1223    if has_ed25519 {
1224        out.push_str("\nYou already have an Ed25519 key. If you want to use it, skip to the 'Add to agent' step.\n");
1225    }
1226
1227    out.push_str("\nFix plan — Generating an SSH key pair:\n");
1228    out.push_str("1. Open PowerShell (or Terminal) — no elevation needed.\n");
1229    out.push_str("2. Generate an Ed25519 key (preferred over RSA):\n");
1230    out.push_str("   ssh-keygen -t ed25519 -C \"your@email.com\"\n");
1231    out.push_str(
1232        "   - Accept the default path (~/.ssh/id_ed25519) unless you need a custom name.\n",
1233    );
1234    out.push_str("   - Set a passphrase (recommended) or press Enter twice for no passphrase.\n");
1235    out.push_str("3. Start the SSH agent and add your key:\n");
1236    out.push_str("   # Windows (PowerShell, run as Admin once to enable the service):\n");
1237    out.push_str("   Set-Service -Name ssh-agent -StartupType Automatic\n");
1238    out.push_str("   Start-Service ssh-agent\n");
1239    out.push_str("   # Then add the key (normal PowerShell):\n");
1240    out.push_str("   ssh-add ~/.ssh/id_ed25519\n");
1241    out.push_str("4. Copy your PUBLIC key to the target server's authorized_keys:\n");
1242    out.push_str("   # Print your public key:\n");
1243    out.push_str("   cat ~/.ssh/id_ed25519.pub\n");
1244    out.push_str("   # On the target server, append it:\n");
1245    out.push_str("   echo \"<paste public key>\" >> ~/.ssh/authorized_keys\n");
1246    out.push_str("   chmod 600 ~/.ssh/authorized_keys\n");
1247    out.push_str("5. Test the connection:\n");
1248    out.push_str("   ssh user@server-address\n");
1249    out.push_str("\nFor GitHub/GitLab:\n");
1250    out.push_str("- Copy the public key: Get-Content ~/.ssh/id_ed25519.pub | Set-Clipboard\n");
1251    out.push_str("- Paste it into GitHub Settings → SSH and GPG keys → New SSH key\n");
1252    out.push_str("- Test: ssh -T git@github.com\n");
1253    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.");
1254    Ok(out.trim_end().to_string())
1255}
1256
1257fn inspect_wsl_setup_fix_plan(issue: &str) -> Result<String, String> {
1258    #[cfg(target_os = "windows")]
1259    let wsl_status = {
1260        let out = Command::new("wsl")
1261            .args(["--status"])
1262            .output()
1263            .ok()
1264            .and_then(|o| {
1265                let stdout = String::from_utf8(o.stdout).unwrap_or_default();
1266                let stderr = String::from_utf8(o.stderr).unwrap_or_default();
1267                Some(format!("{}{}", stdout, stderr))
1268            })
1269            .unwrap_or_default();
1270        out.trim().to_string()
1271    };
1272    #[cfg(not(target_os = "windows"))]
1273    let wsl_status = String::new();
1274
1275    let wsl_installed =
1276        !wsl_status.is_empty() && !wsl_status.to_lowercase().contains("not installed");
1277
1278    let mut out = String::from("Host inspection: fix_plan\n\n");
1279    out.push_str(&format!("- Requested issue: {}\n", issue));
1280    out.push_str("- Fix-plan type: wsl_setup\n");
1281    out.push_str(&format!("- WSL already installed: {}\n", wsl_installed));
1282    if !wsl_status.is_empty() {
1283        out.push_str(&format!("- WSL status:\n{}\n", wsl_status));
1284    }
1285
1286    if wsl_installed {
1287        out.push_str("\nWSL is already installed. To install a new Linux distro:\n");
1288        out.push_str("1. Run in PowerShell (Admin): wsl --install -d Ubuntu\n");
1289        out.push_str("   Available distros: wsl --list --online\n");
1290        out.push_str("2. After install, launch from Start menu or type 'ubuntu' in PowerShell.\n");
1291        out.push_str("3. Create your Linux username and password when prompted.\n");
1292    } else {
1293        out.push_str("\nFix plan — Installing WSL2 (Windows Subsystem for Linux):\n");
1294        out.push_str("1. Open PowerShell as Administrator.\n");
1295        out.push_str("2. Install WSL with the default Ubuntu distro:\n");
1296        out.push_str("   wsl --install\n");
1297        out.push_str("   (This enables the required Windows features, downloads WSL2, and installs Ubuntu)\n");
1298        out.push_str("3. Reboot when prompted — WSL requires a restart after the first install.\n");
1299        out.push_str("4. After reboot, Ubuntu will launch automatically and ask you to create a username and password.\n");
1300        out.push_str("5. Set WSL2 as the default version (should already be set, but confirm):\n");
1301        out.push_str("   wsl --set-default-version 2\n");
1302        out.push_str("\nTo install a different distro instead of Ubuntu:\n");
1303        out.push_str("   wsl --install -d Debian\n");
1304        out.push_str("   wsl --list --online   # to see all available distros\n");
1305    }
1306    out.push_str("\nVerification:\n");
1307    out.push_str("- Run: wsl --list --verbose\n");
1308    out.push_str("- You should see your distro with State: Running and Version: 2\n");
1309    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.");
1310    Ok(out.trim_end().to_string())
1311}
1312
1313fn inspect_service_config_fix_plan(issue: &str) -> Result<String, String> {
1314    let lower = issue.to_ascii_lowercase();
1315    // Extract service name hints from the issue text
1316    let service_hint = if lower.contains("ssh") {
1317        Some("sshd")
1318    } else if lower.contains("mysql") {
1319        Some("MySQL80")
1320    } else if lower.contains("postgres") || lower.contains("postgresql") {
1321        Some("postgresql")
1322    } else if lower.contains("redis") {
1323        Some("Redis")
1324    } else if lower.contains("nginx") {
1325        Some("nginx")
1326    } else if lower.contains("apache") {
1327        Some("Apache2.4")
1328    } else {
1329        None
1330    };
1331
1332    #[cfg(target_os = "windows")]
1333    let service_state = if let Some(svc) = service_hint {
1334        Command::new("powershell")
1335            .args([
1336                "-NoProfile",
1337                "-NonInteractive",
1338                "-Command",
1339                &format!("Get-Service -Name '{}' -ErrorAction SilentlyContinue | Select-Object Name,Status,StartType | ForEach-Object {{ \"Service: $($_.Name) | Status: $($_.Status) | StartType: $($_.StartType)\" }}", svc),
1340            ])
1341            .output()
1342            .ok()
1343            .and_then(|o| String::from_utf8(o.stdout).ok())
1344            .unwrap_or_default()
1345            .trim()
1346            .to_string()
1347    } else {
1348        String::new()
1349    };
1350    #[cfg(not(target_os = "windows"))]
1351    let service_state = String::new();
1352
1353    let mut out = String::from("Host inspection: fix_plan\n\n");
1354    out.push_str(&format!("- Requested issue: {}\n", issue));
1355    out.push_str("- Fix-plan type: service_config\n");
1356    if let Some(svc) = service_hint {
1357        out.push_str(&format!("- Service detected in request: {}\n", svc));
1358    }
1359    if !service_state.is_empty() {
1360        out.push_str(&format!("- Current state: {}\n", service_state));
1361    }
1362
1363    out.push_str("\nFix plan — Managing Windows services (PowerShell, run as Administrator):\n");
1364    out.push_str("\nStart a service:\n");
1365    out.push_str("  Start-Service -Name \"ServiceName\"\n");
1366    out.push_str("\nStop a service:\n");
1367    out.push_str("  Stop-Service -Name \"ServiceName\"\n");
1368    out.push_str("\nRestart a service:\n");
1369    out.push_str("  Restart-Service -Name \"ServiceName\"\n");
1370    out.push_str("\nEnable a service to start automatically:\n");
1371    out.push_str("  Set-Service -Name \"ServiceName\" -StartupType Automatic\n");
1372    out.push_str("\nDisable a service (stops it from auto-starting):\n");
1373    out.push_str("  Set-Service -Name \"ServiceName\" -StartupType Disabled\n");
1374    out.push_str("\nFind the exact service name:\n");
1375    out.push_str("  Get-Service | Where-Object { $_.DisplayName -like '*mysql*' }\n");
1376    out.push_str("\nVerification:\n");
1377    out.push_str("  Get-Service -Name \"ServiceName\" | Select-Object Name,Status,StartType\n");
1378    if let Some(svc) = service_hint {
1379        out.push_str(&format!(
1380            "\nFor your detected service ({}):\n  Get-Service -Name '{}'\n",
1381            svc, svc
1382        ));
1383    }
1384    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.");
1385    Ok(out.trim_end().to_string())
1386}
1387
1388fn inspect_windows_activation_fix_plan(issue: &str) -> Result<String, String> {
1389    #[cfg(target_os = "windows")]
1390    let activation_status = {
1391        Command::new("powershell")
1392            .args([
1393                "-NoProfile",
1394                "-NonInteractive",
1395                "-Command",
1396                "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 + ')' })\" }",
1397            ])
1398            .output()
1399            .ok()
1400            .and_then(|o| String::from_utf8(o.stdout).ok())
1401            .unwrap_or_default()
1402            .trim()
1403            .to_string()
1404    };
1405    #[cfg(not(target_os = "windows"))]
1406    let activation_status = String::new();
1407
1408    let activation_lower = activation_status.to_lowercase();
1409    let is_licensed =
1410        activation_lower.contains("licensed") && !activation_lower.contains("not licensed");
1411
1412    let mut out = String::from("Host inspection: fix_plan\n\n");
1413    out.push_str(&format!("- Requested issue: {}\n", issue));
1414    out.push_str("- Fix-plan type: windows_activation\n");
1415    if !activation_status.is_empty() {
1416        out.push_str(&format!(
1417            "- Current activation state:\n{}\n",
1418            activation_status
1419        ));
1420    }
1421
1422    if is_licensed {
1423        out.push_str(
1424            "\nWindows appears to be activated. If you are still seeing activation prompts, try:\n",
1425        );
1426        out.push_str("1. Run in elevated PowerShell: slmgr /ato\n");
1427        out.push_str("   (Forces an online activation attempt)\n");
1428        out.push_str("2. Check activation details: slmgr /dli\n");
1429    } else {
1430        out.push_str("\nFix plan — Activating Windows:\n");
1431        out.push_str("1. Check your current status first:\n");
1432        out.push_str("   slmgr /dli   (basic info)\n");
1433        out.push_str("   slmgr /dlv   (detailed — shows remaining rearms, grace period)\n");
1434        out.push_str("\n2. If you have a retail product key:\n");
1435        out.push_str("   slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX   (install key)\n");
1436        out.push_str("   slmgr /ato                                   (activate online)\n");
1437        out.push_str("\n3. If you had a digital license (linked to your Microsoft account):\n");
1438        out.push_str("   - Go to Settings → System → Activation\n");
1439        out.push_str("   - Click 'Troubleshoot' → 'I changed hardware on this device recently'\n");
1440        out.push_str("   - Sign in with the Microsoft account that holds the license\n");
1441        out.push_str("\n4. If using a volume license (organization/enterprise):\n");
1442        out.push_str("   - Contact your IT department for the KMS server address\n");
1443        out.push_str("   - Set KMS host: slmgr /skms kms.yourorg.com\n");
1444        out.push_str("   - Activate:    slmgr /ato\n");
1445    }
1446    out.push_str("\nVerification:\n");
1447    out.push_str("  slmgr /dli   — should show 'License Status: Licensed'\n");
1448    out.push_str("  Or: Settings → System → Activation → 'Windows is activated'\n");
1449    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.");
1450    Ok(out.trim_end().to_string())
1451}
1452
1453fn inspect_registry_edit_fix_plan(issue: &str) -> Result<String, String> {
1454    let mut out = String::from("Host inspection: fix_plan\n\n");
1455    out.push_str(&format!("- Requested issue: {}\n", issue));
1456    out.push_str("- Fix-plan type: registry_edit\n");
1457    out.push_str(
1458        "\nCAUTION: Registry edits affect core Windows behavior. Always back up before editing.\n",
1459    );
1460    out.push_str("\nFix plan — Safely editing the Windows Registry:\n");
1461    out.push_str("\n1. Back up before you touch anything:\n");
1462    out.push_str("   # Export the key you're about to change (PowerShell):\n");
1463    out.push_str("   reg export \"HKLM\\SOFTWARE\\MyKey\" C:\\backup\\MyKey_backup.reg\n");
1464    out.push_str("   # Or export the whole registry (takes a while):\n");
1465    out.push_str("   reg export HKLM C:\\backup\\HKLM_full.reg\n");
1466    out.push_str("\n2. Read a value (PowerShell, no elevation needed for HKCU):\n");
1467    out.push_str("   Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1468    out.push_str("\n3. Create or update a DWORD value (PowerShell, Admin for HKLM):\n");
1469    out.push_str(
1470        "   Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue' -Value 1 -Type DWord\n",
1471    );
1472    out.push_str("\n4. Create a new key:\n");
1473    out.push_str("   New-Item -Path 'HKLM:\\SOFTWARE\\MyNewKey' -Force\n");
1474    out.push_str("\n5. Delete a value:\n");
1475    out.push_str("   Remove-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1476    out.push_str("\n6. Restore from backup if something breaks:\n");
1477    out.push_str("   reg import C:\\backup\\MyKey_backup.reg\n");
1478    out.push_str("\nCommon registry hives:\n");
1479    out.push_str("  HKLM = HKEY_LOCAL_MACHINE  (machine-wide, requires Admin)\n");
1480    out.push_str("  HKCU = HKEY_CURRENT_USER   (current user, no elevation needed)\n");
1481    out.push_str("  HKCR = HKEY_CLASSES_ROOT    (file associations)\n");
1482    out.push_str("\nVerification:\n");
1483    out.push_str("  Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' | Select-Object MyValue\n");
1484    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.");
1485    Ok(out.trim_end().to_string())
1486}
1487
1488fn inspect_scheduled_task_fix_plan(issue: &str) -> Result<String, String> {
1489    let mut out = String::from("Host inspection: fix_plan\n\n");
1490    out.push_str(&format!("- Requested issue: {}\n", issue));
1491    out.push_str("- Fix-plan type: scheduled_task_create\n");
1492    out.push_str("\nFix plan — Creating a Scheduled Task (PowerShell, run as Administrator):\n");
1493    out.push_str("\nExample: Run a script at 9 AM every day\n");
1494    out.push_str("  $action  = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-File C:\\Scripts\\MyScript.ps1'\n");
1495    out.push_str("  $trigger = New-ScheduledTaskTrigger -Daily -At '09:00AM'\n");
1496    out.push_str("  Register-ScheduledTask -TaskName 'MyDailyTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1497    out.push_str("\nExample: Run at Windows startup\n");
1498    out.push_str("  $trigger = New-ScheduledTaskTrigger -AtStartup\n");
1499    out.push_str("  Register-ScheduledTask -TaskName 'MyStartupTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1500    out.push_str("\nExample: Run at user logon\n");
1501    out.push_str("  $trigger = New-ScheduledTaskTrigger -AtLogon\n");
1502    out.push_str(
1503        "  Register-ScheduledTask -TaskName 'MyLogonTask' -Action $action -Trigger $trigger\n",
1504    );
1505    out.push_str("\nExample: Run every 30 minutes\n");
1506    out.push_str("  $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 30) -Once -At (Get-Date)\n");
1507    out.push_str("\nView all tasks:\n");
1508    out.push_str("  Get-ScheduledTask | Select-Object TaskName,State | Sort-Object TaskName\n");
1509    out.push_str("\nDelete a task:\n");
1510    out.push_str("  Unregister-ScheduledTask -TaskName 'MyDailyTask' -Confirm:$false\n");
1511    out.push_str("\nRun a task immediately:\n");
1512    out.push_str("  Start-ScheduledTask -TaskName 'MyDailyTask'\n");
1513    out.push_str("\nVerification:\n");
1514    out.push_str("  Get-ScheduledTask -TaskName 'MyDailyTask' | Select-Object TaskName,State,LastRunTime,NextRunTime\n");
1515    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.");
1516    Ok(out.trim_end().to_string())
1517}
1518
1519fn inspect_disk_cleanup_fix_plan(issue: &str) -> Result<String, String> {
1520    #[cfg(target_os = "windows")]
1521    let disk_info = {
1522        Command::new("powershell")
1523            .args([
1524                "-NoProfile",
1525                "-NonInteractive",
1526                "-Command",
1527                "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\" }",
1528            ])
1529            .output()
1530            .ok()
1531            .and_then(|o| String::from_utf8(o.stdout).ok())
1532            .unwrap_or_default()
1533            .trim()
1534            .to_string()
1535    };
1536    #[cfg(not(target_os = "windows"))]
1537    let disk_info = String::new();
1538
1539    let mut out = String::from("Host inspection: fix_plan\n\n");
1540    out.push_str(&format!("- Requested issue: {}\n", issue));
1541    out.push_str("- Fix-plan type: disk_cleanup\n");
1542    if !disk_info.is_empty() {
1543        out.push_str(&format!("\nCurrent drive usage:\n{}\n", disk_info));
1544    }
1545    out.push_str("\nFix plan — Reclaiming disk space (ordered by impact):\n");
1546    out.push_str("\n1. Run Windows Disk Cleanup (built-in, GUI):\n");
1547    out.push_str("   cleanmgr /sageset:1    (configure what to clean)\n");
1548    out.push_str("   cleanmgr /sagerun:1    (run the cleanup)\n");
1549    out.push_str("   Tick 'Windows Update Cleanup' for the biggest reclaim (often 5-20 GB).\n");
1550    out.push_str("\n2. Clear the Windows Update cache (PowerShell, Admin):\n");
1551    out.push_str("   Stop-Service wuauserv\n");
1552    out.push_str("   Remove-Item C:\\Windows\\SoftwareDistribution\\Download\\* -Recurse -Force\n");
1553    out.push_str("   Start-Service wuauserv\n");
1554    out.push_str("\n3. Clear Windows Temp folder:\n");
1555    out.push_str("   Remove-Item $env:TEMP\\* -Recurse -Force -ErrorAction SilentlyContinue\n");
1556    out.push_str(
1557        "   Remove-Item C:\\Windows\\Temp\\* -Recurse -Force -ErrorAction SilentlyContinue\n",
1558    );
1559    out.push_str("\n4. Developer cache directories (often the biggest culprits):\n");
1560    out.push_str("   - Rust build artifacts: cargo clean  (inside each project)\n");
1561    out.push_str("   - npm cache:  npm cache clean --force\n");
1562    out.push_str("   - pip cache:  pip cache purge\n");
1563    out.push_str(
1564        "   - Docker:     docker system prune -a  (removes all unused images/containers)\n",
1565    );
1566    out.push_str("   - Cargo registry cache: Remove-Item ~\\.cargo\\registry -Recurse -Force  (will redownload on next build)\n");
1567    out.push_str("\n5. Check for large files:\n");
1568    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");
1569    out.push_str("\nVerification:\n");
1570    out.push_str(
1571        "  Get-PSDrive C | Select-Object @{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}}\n",
1572    );
1573    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.");
1574    Ok(out.trim_end().to_string())
1575}
1576
1577fn inspect_generic_fix_plan(issue: &str) -> Result<String, String> {
1578    let mut out = String::from("Host inspection: fix_plan\n\n");
1579    out.push_str(&format!("- Requested issue: {}\n", issue));
1580    out.push_str("- Fix-plan type: generic\n");
1581    out.push_str(
1582        "\nGuidance:\n- Use `fix_plan` with a descriptive issue string to get a grounded, machine-specific walkthrough.\n\
1583         Structured lanes available:\n\
1584         - PATH/toolchain drift (cargo, rustc, node, python, winget, choco, scoop)\n\
1585         - Port conflict (address already in use, what owns port)\n\
1586         - LM Studio connectivity (localhost:1234, no coding model loaded, embedding model)\n\
1587         - Driver install (GPU driver, nvidia driver, install driver, update driver)\n\
1588         - Group Policy (gpedit, local policy, administrative template)\n\
1589         - Firewall rule (inbound rule, outbound rule, open port, allow port, block port)\n\
1590         - SSH key (ssh-keygen, generate ssh, authorized_keys)\n\
1591         - WSL setup (wsl2, windows subsystem for linux, install ubuntu)\n\
1592         - Service config (start/stop/restart/enable/disable a service)\n\
1593         - Windows activation (product key, not activated, kms)\n\
1594         - Registry edit (regedit, reg add, hklm, hkcu, registry key)\n\
1595         - Scheduled task (task scheduler, schtasks, run on startup, cron)\n\
1596         - Disk cleanup (free up disk, clear cache, disk full, reclaim space)\n\
1597         - If your issue is outside these lanes, run the closest `inspect_host` topic first to ground the diagnosis.",
1598    );
1599    Ok(out.trim_end().to_string())
1600}
1601
1602fn inspect_resource_load() -> Result<String, String> {
1603    #[cfg(target_os = "windows")]
1604    {
1605        let output = Command::new("powershell")
1606            .args([
1607                "-NoProfile",
1608                "-Command",
1609                "(Get-CimInstance Win32_Processor).LoadPercentage; Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json -Compress",
1610            ])
1611            .output()
1612            .map_err(|e| format!("Failed to run powershell: {e}"))?;
1613
1614        let text = String::from_utf8_lossy(&output.stdout);
1615        let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
1616
1617        let cpu_load = lines
1618            .next()
1619            .and_then(|l| l.parse::<u32>().ok())
1620            .unwrap_or(0);
1621        let mem_json = lines.collect::<Vec<_>>().join("");
1622        let mem_val: Value = serde_json::from_str(&mem_json).unwrap_or(Value::Null);
1623
1624        let total_kb = mem_val["TotalVisibleMemorySize"].as_u64().unwrap_or(1);
1625        let free_kb = mem_val["FreePhysicalMemory"].as_u64().unwrap_or(0);
1626        let used_kb = total_kb.saturating_sub(free_kb);
1627        let mem_percent = if total_kb > 0 {
1628            (used_kb * 100) / total_kb
1629        } else {
1630            0
1631        };
1632
1633        let mut out = String::from("Host inspection: resource_load\n\n");
1634        out.push_str("**System Performance Summary:**\n");
1635        out.push_str(&format!("- CPU Load: {}%\n", cpu_load));
1636        out.push_str(&format!(
1637            "- Memory Usage: {} / {} ({}%)\n",
1638            human_bytes(used_kb * 1024),
1639            human_bytes(total_kb * 1024),
1640            mem_percent
1641        ));
1642
1643        if cpu_load > 85 {
1644            out.push_str("\n[Warning] CPU load is extremely high. System may be unresponsive.\n");
1645        }
1646        if mem_percent > 90 {
1647            out.push_str("\n[Warning] Memory usage is near capacity. Swap activity may slow down the machine.\n");
1648        }
1649
1650        Ok(out)
1651    }
1652    #[cfg(not(target_os = "windows"))]
1653    {
1654        Ok("Resource load inspection is not yet implemented for this platform.".to_string())
1655    }
1656}
1657
1658#[derive(Debug)]
1659enum EndpointProbe {
1660    Reachable(u16),
1661    Unreachable(String),
1662}
1663
1664async fn probe_http_endpoint(url: &str) -> EndpointProbe {
1665    let client = match reqwest::Client::builder()
1666        .timeout(std::time::Duration::from_secs(3))
1667        .build()
1668    {
1669        Ok(client) => client,
1670        Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1671    };
1672
1673    match client.get(url).send().await {
1674        Ok(resp) => EndpointProbe::Reachable(resp.status().as_u16()),
1675        Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1676    }
1677}
1678
1679async fn detect_loaded_embed_model(configured_api: &str) -> Option<String> {
1680    if configured_api.contains("11434") {
1681        let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1682        let url = format!("{}/api/ps", base);
1683        let client = reqwest::Client::builder()
1684            .timeout(std::time::Duration::from_secs(3))
1685            .build()
1686            .ok()?;
1687        let response = client.get(url).send().await.ok()?;
1688        let body = response.json::<serde_json::Value>().await.ok()?;
1689        let entries = body["models"].as_array()?;
1690        for entry in entries {
1691            let name = entry["name"]
1692                .as_str()
1693                .or_else(|| entry["model"].as_str())
1694                .unwrap_or_default();
1695            let lower = name.to_ascii_lowercase();
1696            if lower.contains("embed")
1697                || lower.contains("embedding")
1698                || lower.contains("minilm")
1699                || lower.contains("bge")
1700                || lower.contains("e5")
1701            {
1702                return Some(name.to_string());
1703            }
1704        }
1705        return None;
1706    }
1707
1708    let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1709    let url = format!("{}/api/v0/models", base);
1710    let client = reqwest::Client::builder()
1711        .timeout(std::time::Duration::from_secs(3))
1712        .build()
1713        .ok()?;
1714
1715    #[derive(serde::Deserialize)]
1716    struct ModelList {
1717        data: Vec<ModelEntry>,
1718    }
1719    #[derive(serde::Deserialize)]
1720    struct ModelEntry {
1721        id: String,
1722        #[serde(rename = "type", default)]
1723        model_type: String,
1724        #[serde(default)]
1725        state: String,
1726    }
1727
1728    let response = client.get(url).send().await.ok()?;
1729    let models = response.json::<ModelList>().await.ok()?;
1730    models
1731        .data
1732        .into_iter()
1733        .find(|model| model.model_type == "embeddings" && model.state == "loaded")
1734        .map(|model| model.id)
1735}
1736
1737fn first_port_in_text(text: &str) -> Option<u16> {
1738    text.split(|c: char| !c.is_ascii_digit())
1739        .find(|fragment| !fragment.is_empty())
1740        .and_then(|fragment| fragment.parse::<u16>().ok())
1741}
1742
1743fn inspect_processes(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1744    let mut processes = collect_processes()?;
1745    if let Some(filter) = name_filter.as_deref() {
1746        let lowered = filter.to_ascii_lowercase();
1747        processes.retain(|entry| entry.name.to_ascii_lowercase().contains(&lowered));
1748    }
1749    processes.sort_by(|a, b| {
1750        let a_cpu = a.cpu_percent.unwrap_or(0.0);
1751        let b_cpu = b.cpu_percent.unwrap_or(0.0);
1752        b_cpu
1753            .partial_cmp(&a_cpu)
1754            .unwrap_or(std::cmp::Ordering::Equal)
1755            .then_with(|| b.memory_bytes.cmp(&a.memory_bytes))
1756            .then_with(|| a.name.cmp(&b.name))
1757            .then_with(|| a.pid.cmp(&b.pid))
1758    });
1759
1760    let total_memory: u64 = processes.iter().map(|entry| entry.memory_bytes).sum();
1761
1762    let mut out = String::from("Host inspection: processes\n\n");
1763    if let Some(filter) = name_filter.as_deref() {
1764        out.push_str(&format!("- Filter name: {}\n", filter));
1765    }
1766    out.push_str(&format!("- Processes found: {}\n", processes.len()));
1767    out.push_str(&format!(
1768        "- Total reported working set: {}\n",
1769        human_bytes(total_memory)
1770    ));
1771
1772    if processes.is_empty() {
1773        out.push_str("\nNo running processes matched.");
1774        return Ok(out);
1775    }
1776
1777    out.push_str("\nTop processes by resource usage:\n");
1778    for entry in processes.iter().take(max_entries) {
1779        let cpu_str = entry
1780            .cpu_percent
1781            .map(|p| format!(" [CPU: {:.1}%]", p))
1782            .or_else(|| entry.cpu_seconds.map(|s| format!(" [CPU: {:.1}s]", s)))
1783            .unwrap_or_default();
1784        let io_str = if let (Some(r), Some(w)) = (entry.read_ops, entry.write_ops) {
1785            format!(" [I/O R:{}/W:{}]", r, w)
1786        } else {
1787            " [I/O unknown]".to_string()
1788        };
1789        out.push_str(&format!(
1790            "- {} (pid {}) - {}{}{}{}\n",
1791            entry.name,
1792            entry.pid,
1793            human_bytes(entry.memory_bytes),
1794            cpu_str,
1795            io_str,
1796            entry
1797                .detail
1798                .as_deref()
1799                .map(|detail| format!(" [{}]", detail))
1800                .unwrap_or_default()
1801        ));
1802    }
1803    if processes.len() > max_entries {
1804        out.push_str(&format!(
1805            "- ... {} more processes omitted\n",
1806            processes.len() - max_entries
1807        ));
1808    }
1809
1810    Ok(out.trim_end().to_string())
1811}
1812
1813fn inspect_network(max_entries: usize) -> Result<String, String> {
1814    let adapters = collect_network_adapters()?;
1815    let active_count = adapters
1816        .iter()
1817        .filter(|adapter| adapter.is_active())
1818        .count();
1819    let exposure = listener_exposure_summary(collect_listening_ports().ok().unwrap_or_default());
1820
1821    let mut out = String::from("Host inspection: network\n\n");
1822    out.push_str(&format!("- Adapters found: {}\n", adapters.len()));
1823    out.push_str(&format!("- Active adapters: {}\n", active_count));
1824    out.push_str(&format!(
1825        "- Listener exposure: {} loopback-only, {} wildcard/public, {} specific-bind\n",
1826        exposure.loopback_only, exposure.wildcard_public, exposure.specific_bind
1827    ));
1828
1829    if adapters.is_empty() {
1830        out.push_str("\nNo adapter details were detected.");
1831        return Ok(out);
1832    }
1833
1834    out.push_str("\nAdapter summary:\n");
1835    for adapter in adapters.iter().take(max_entries) {
1836        let status = if adapter.is_active() {
1837            "active"
1838        } else if adapter.disconnected {
1839            "disconnected"
1840        } else {
1841            "idle"
1842        };
1843        let mut details = vec![status.to_string()];
1844        if !adapter.ipv4.is_empty() {
1845            details.push(format!("ipv4 {}", adapter.ipv4.join(", ")));
1846        }
1847        if !adapter.ipv6.is_empty() {
1848            details.push(format!("ipv6 {}", adapter.ipv6.join(", ")));
1849        }
1850        if !adapter.gateways.is_empty() {
1851            details.push(format!("gateway {}", adapter.gateways.join(", ")));
1852        }
1853        if !adapter.dns_servers.is_empty() {
1854            details.push(format!("dns {}", adapter.dns_servers.join(", ")));
1855        }
1856        out.push_str(&format!("- {} - {}\n", adapter.name, details.join(" | ")));
1857    }
1858    if adapters.len() > max_entries {
1859        out.push_str(&format!(
1860            "- ... {} more adapters omitted\n",
1861            adapters.len() - max_entries
1862        ));
1863    }
1864
1865    Ok(out.trim_end().to_string())
1866}
1867
1868fn inspect_lan_discovery(max_entries: usize) -> Result<String, String> {
1869    let mut out = String::from("Host inspection: lan_discovery\n\n");
1870
1871    #[cfg(target_os = "windows")]
1872    {
1873        let n = max_entries.clamp(5, 20);
1874        let adapters = collect_network_adapters()?;
1875        let services = collect_services().unwrap_or_default();
1876        let active_adapters: Vec<&NetworkAdapter> = adapters
1877            .iter()
1878            .filter(|adapter| adapter.is_active())
1879            .collect();
1880        let gateways: Vec<String> = active_adapters
1881            .iter()
1882            .flat_map(|adapter| adapter.gateways.clone())
1883            .collect::<HashSet<_>>()
1884            .into_iter()
1885            .collect();
1886
1887        let neighbor_script = r#"
1888$neighbors = Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue |
1889    Where-Object {
1890        $_.IPAddress -notlike '127.*' -and
1891        $_.IPAddress -notlike '169.254*' -and
1892        $_.State -notin @('Unreachable','Invalid')
1893    } |
1894    Select-Object IPAddress, LinkLayerAddress, State, InterfaceAlias
1895$neighbors | ConvertTo-Json -Compress
1896"#;
1897        let neighbor_text = Command::new("powershell")
1898            .args(["-NoProfile", "-NonInteractive", "-Command", neighbor_script])
1899            .output()
1900            .ok()
1901            .and_then(|o| String::from_utf8(o.stdout).ok())
1902            .unwrap_or_default();
1903        let neighbors: Vec<(String, String, String, String)> = parse_lan_neighbors(&neighbor_text)
1904            .into_iter()
1905            .take(n)
1906            .collect();
1907
1908        let listener_script = r#"
1909Get-NetUDPEndpoint -ErrorAction SilentlyContinue |
1910    Where-Object { $_.LocalPort -in 137,138,1900,5353,5355 } |
1911    Select-Object LocalAddress, LocalPort, OwningProcess |
1912    ForEach-Object {
1913        $proc = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Name
1914        "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$proc"
1915    }
1916"#;
1917        let listener_text = Command::new("powershell")
1918            .args(["-NoProfile", "-NonInteractive", "-Command", listener_script])
1919            .output()
1920            .ok()
1921            .and_then(|o| String::from_utf8(o.stdout).ok())
1922            .unwrap_or_default();
1923        let listeners: Vec<(String, u16, String, String)> = listener_text
1924            .lines()
1925            .filter_map(|line| {
1926                let parts: Vec<&str> = line.trim().split('|').collect();
1927                if parts.len() < 4 {
1928                    return None;
1929                }
1930                Some((
1931                    parts[0].to_string(),
1932                    parts[1].parse::<u16>().ok()?,
1933                    parts[2].to_string(),
1934                    parts[3].to_string(),
1935                ))
1936            })
1937            .take(n)
1938            .collect();
1939
1940        let smb_mapping_script = r#"
1941Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } |
1942    ForEach-Object { "$($_.Name):|$($_.DisplayRoot)" }
1943"#;
1944        let smb_mappings: Vec<String> = Command::new("powershell")
1945            .args([
1946                "-NoProfile",
1947                "-NonInteractive",
1948                "-Command",
1949                smb_mapping_script,
1950            ])
1951            .output()
1952            .ok()
1953            .and_then(|o| String::from_utf8(o.stdout).ok())
1954            .unwrap_or_default()
1955            .lines()
1956            .take(n)
1957            .map(|line| line.trim().to_string())
1958            .filter(|line| !line.is_empty())
1959            .collect();
1960
1961        let smb_connections_script = r#"
1962Get-SmbConnection -ErrorAction SilentlyContinue |
1963    Select-Object ServerName, ShareName, NumOpens |
1964    ForEach-Object { "$($_.ServerName)|$($_.ShareName)|$($_.NumOpens)" }
1965"#;
1966        let smb_connections: Vec<String> = Command::new("powershell")
1967            .args([
1968                "-NoProfile",
1969                "-NonInteractive",
1970                "-Command",
1971                smb_connections_script,
1972            ])
1973            .output()
1974            .ok()
1975            .and_then(|o| String::from_utf8(o.stdout).ok())
1976            .unwrap_or_default()
1977            .lines()
1978            .take(n)
1979            .map(|line| line.trim().to_string())
1980            .filter(|line| !line.is_empty())
1981            .collect();
1982
1983        let discovery_service_names = [
1984            "FDResPub",
1985            "fdPHost",
1986            "SSDPSRV",
1987            "upnphost",
1988            "LanmanServer",
1989            "LanmanWorkstation",
1990            "lmhosts",
1991        ];
1992        let discovery_services: Vec<&ServiceEntry> = services
1993            .iter()
1994            .filter(|entry| {
1995                discovery_service_names
1996                    .iter()
1997                    .any(|name| entry.name.eq_ignore_ascii_case(name))
1998            })
1999            .collect();
2000
2001        let mut findings = Vec::new();
2002        if active_adapters.is_empty() {
2003            findings.push(AuditFinding {
2004                finding: "No active LAN adapters were detected.".to_string(),
2005                impact: "Neighborhood, SMB, mDNS, SSDP, and printer/NAS discovery cannot work without an active local interface.".to_string(),
2006                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(),
2007            });
2008        }
2009
2010        let stopped_discovery_services: Vec<&ServiceEntry> = discovery_services
2011            .iter()
2012            .copied()
2013            .filter(|entry| {
2014                !entry.status.eq_ignore_ascii_case("running")
2015                    && !entry.status.eq_ignore_ascii_case("active")
2016            })
2017            .collect();
2018        if !stopped_discovery_services.is_empty() {
2019            let names = stopped_discovery_services
2020                .iter()
2021                .map(|entry| entry.name.as_str())
2022                .collect::<Vec<_>>()
2023                .join(", ");
2024            findings.push(AuditFinding {
2025                finding: format!("Discovery-related services are not running: {names}"),
2026                impact: "Windows network neighborhood visibility, SSDP/UPnP discovery, or SMB browse behavior can look broken even when the network itself is fine.".to_string(),
2027                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(),
2028            });
2029        }
2030
2031        if listeners.is_empty() {
2032            findings.push(AuditFinding {
2033                finding: "No discovery-oriented UDP listeners were found on 137, 138, 1900, 5353, or 5355.".to_string(),
2034                impact: "NetBIOS, SSDP/UPnP, mDNS, and LLMNR discovery may be inactive on this host, so other devices may not see it automatically.".to_string(),
2035                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(),
2036            });
2037        }
2038
2039        if !active_adapters.is_empty() && neighbors.len() <= gateways.len() {
2040            findings.push(AuditFinding {
2041                finding: "Very little neighborhood evidence was observed beyond the default gateway.".to_string(),
2042                impact: "That often means discovery traffic is quiet, the LAN is isolated, or peer devices are not advertising themselves.".to_string(),
2043                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(),
2044            });
2045        }
2046
2047        out.push_str("=== Findings ===\n");
2048        if findings.is_empty() {
2049            out.push_str(
2050                "- Finding: LAN discovery signals look healthy from this inspection pass.\n",
2051            );
2052            out.push_str("  Impact: Neighborhood visibility, SMB browsing, and SSDP/mDNS discovery do not show an obvious host-side blocker.\n");
2053            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");
2054        } else {
2055            for finding in &findings {
2056                out.push_str(&format!("- Finding: {}\n", finding.finding));
2057                out.push_str(&format!("  Impact: {}\n", finding.impact));
2058                out.push_str(&format!("  Fix: {}\n", finding.fix));
2059            }
2060        }
2061
2062        out.push_str("\n=== Active adapter and gateway summary ===\n");
2063        if active_adapters.is_empty() {
2064            out.push_str("- No active adapters detected.\n");
2065        } else {
2066            for adapter in active_adapters.iter().take(n) {
2067                let ipv4 = if adapter.ipv4.is_empty() {
2068                    "no IPv4".to_string()
2069                } else {
2070                    adapter.ipv4.join(", ")
2071                };
2072                let gateway = if adapter.gateways.is_empty() {
2073                    "no gateway".to_string()
2074                } else {
2075                    adapter.gateways.join(", ")
2076                };
2077                out.push_str(&format!(
2078                    "- {} | IPv4: {} | Gateway: {}\n",
2079                    adapter.name, ipv4, gateway
2080                ));
2081            }
2082        }
2083
2084        out.push_str("\n=== Neighborhood evidence ===\n");
2085        out.push_str(&format!("- Gateway count: {}\n", gateways.len()));
2086        out.push_str(&format!(
2087            "- Neighbor entries observed: {}\n",
2088            neighbors.len()
2089        ));
2090        if neighbors.is_empty() {
2091            out.push_str("- No ARP/neighbor evidence retrieved.\n");
2092        } else {
2093            for (ip, mac, state, iface) in neighbors.iter().take(n) {
2094                out.push_str(&format!(
2095                    "- {} on {} | MAC: {} | State: {}\n",
2096                    ip, iface, mac, state
2097                ));
2098            }
2099        }
2100
2101        out.push_str("\n=== Discovery services ===\n");
2102        if discovery_services.is_empty() {
2103            out.push_str("- Discovery service status unavailable.\n");
2104        } else {
2105            for entry in discovery_services.iter().take(n) {
2106                let startup = entry.startup.as_deref().unwrap_or("unknown");
2107                out.push_str(&format!(
2108                    "- {} | Status: {} | Startup: {}\n",
2109                    entry.name, entry.status, startup
2110                ));
2111            }
2112        }
2113
2114        out.push_str("\n=== Discovery listener surface ===\n");
2115        if listeners.is_empty() {
2116            out.push_str("- No discovery-oriented UDP listeners detected.\n");
2117        } else {
2118            for (addr, port, pid, proc_name) in listeners.iter().take(n) {
2119                let label = match *port {
2120                    137 => "NetBIOS Name Service",
2121                    138 => "NetBIOS Datagram",
2122                    1900 => "SSDP/UPnP",
2123                    5353 => "mDNS",
2124                    5355 => "LLMNR",
2125                    _ => "Discovery",
2126                };
2127                let proc_label = if proc_name.is_empty() {
2128                    "unknown".to_string()
2129                } else {
2130                    proc_name.clone()
2131                };
2132                out.push_str(&format!(
2133                    "- {}:{} | {} | PID {} ({})\n",
2134                    addr, port, label, pid, proc_label
2135                ));
2136            }
2137        }
2138
2139        out.push_str("\n=== SMB and neighborhood visibility ===\n");
2140        if smb_mappings.is_empty() && smb_connections.is_empty() {
2141            out.push_str("- No mapped SMB drives or active SMB connections detected.\n");
2142        } else {
2143            if !smb_mappings.is_empty() {
2144                out.push_str("- Mapped drives:\n");
2145                for mapping in smb_mappings.iter().take(n) {
2146                    let parts: Vec<&str> = mapping.split('|').collect();
2147                    if parts.len() >= 2 {
2148                        out.push_str(&format!("  - {} -> {}\n", parts[0], parts[1]));
2149                    }
2150                }
2151            }
2152            if !smb_connections.is_empty() {
2153                out.push_str("- Active SMB connections:\n");
2154                for connection in smb_connections.iter().take(n) {
2155                    let parts: Vec<&str> = connection.split('|').collect();
2156                    if parts.len() >= 3 {
2157                        out.push_str(&format!(
2158                            "  - {}\\{} | Opens: {}\n",
2159                            parts[0], parts[1], parts[2]
2160                        ));
2161                    }
2162                }
2163            }
2164        }
2165    }
2166
2167    #[cfg(not(target_os = "windows"))]
2168    {
2169        let n = max_entries.clamp(5, 20);
2170        let adapters = collect_network_adapters()?;
2171        let arp_output = Command::new("ip")
2172            .args(["neigh"])
2173            .output()
2174            .ok()
2175            .and_then(|o| String::from_utf8(o.stdout).ok())
2176            .unwrap_or_default();
2177        let neighbors: Vec<&str> = arp_output
2178            .lines()
2179            .filter(|line| !line.trim().is_empty())
2180            .take(n)
2181            .collect();
2182
2183        out.push_str("=== Findings ===\n");
2184        if adapters.iter().any(|adapter| adapter.is_active()) {
2185            out.push_str(
2186                "- Finding: LAN discovery support is partially available on this platform.\n",
2187            );
2188            out.push_str("  Impact: Adapter and neighbor evidence can be inspected, but mDNS/UPnP coverage depends on local tools and services like Avahi.\n");
2189            out.push_str("  Fix: If discovery is failing, inspect Avahi/systemd-resolved, local firewall rules, and `udp_ports` next.\n");
2190        } else {
2191            out.push_str("- Finding: No active LAN adapters were detected.\n");
2192            out.push_str(
2193                "  Impact: Neighborhood discovery cannot work without an active interface.\n",
2194            );
2195            out.push_str("  Fix: Bring up Wi-Fi or Ethernet first, then rerun LAN discovery.\n");
2196        }
2197
2198        out.push_str("\n=== Active adapter and gateway summary ===\n");
2199        if adapters.is_empty() {
2200            out.push_str("- No adapters detected.\n");
2201        } else {
2202            for adapter in adapters.iter().take(n) {
2203                let ipv4 = if adapter.ipv4.is_empty() {
2204                    "no IPv4".to_string()
2205                } else {
2206                    adapter.ipv4.join(", ")
2207                };
2208                let gateway = if adapter.gateways.is_empty() {
2209                    "no gateway".to_string()
2210                } else {
2211                    adapter.gateways.join(", ")
2212                };
2213                out.push_str(&format!(
2214                    "- {} | IPv4: {} | Gateway: {}\n",
2215                    adapter.name, ipv4, gateway
2216                ));
2217            }
2218        }
2219
2220        out.push_str("\n=== Neighborhood evidence ===\n");
2221        if neighbors.is_empty() {
2222            out.push_str("- No neighbor entries detected.\n");
2223        } else {
2224            for line in neighbors {
2225                out.push_str(&format!("- {}\n", line.trim()));
2226            }
2227        }
2228    }
2229
2230    Ok(out.trim_end().to_string())
2231}
2232
2233fn inspect_services(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
2234    let mut services = collect_services()?;
2235    if let Some(filter) = name_filter.as_deref() {
2236        let lowered = filter.to_ascii_lowercase();
2237        services.retain(|entry| {
2238            entry.name.to_ascii_lowercase().contains(&lowered)
2239                || entry
2240                    .display_name
2241                    .as_deref()
2242                    .map(|d| d.to_ascii_lowercase().contains(&lowered))
2243                    .unwrap_or(false)
2244        });
2245    }
2246
2247    services.sort_by(|a, b| {
2248        let a_running =
2249            a.status.to_ascii_lowercase() == "running" || a.status.to_ascii_lowercase() == "active";
2250        let b_running =
2251            b.status.to_ascii_lowercase() == "running" || b.status.to_ascii_lowercase() == "active";
2252        b_running.cmp(&a_running).then_with(|| a.name.cmp(&b.name))
2253    });
2254
2255    let running = services
2256        .iter()
2257        .filter(|entry| {
2258            entry.status.eq_ignore_ascii_case("running")
2259                || entry.status.eq_ignore_ascii_case("active")
2260        })
2261        .count();
2262    let failed = services
2263        .iter()
2264        .filter(|entry| {
2265            entry.status.eq_ignore_ascii_case("failed")
2266                || entry.status.eq_ignore_ascii_case("error")
2267                || entry.status.eq_ignore_ascii_case("stopped")
2268        })
2269        .count();
2270
2271    let mut out = String::from("Host inspection: services\n\n");
2272    if let Some(filter) = name_filter.as_deref() {
2273        out.push_str(&format!("- Filter name: {}\n", filter));
2274    }
2275    out.push_str(&format!("- Services found: {}\n", services.len()));
2276    out.push_str(&format!("- Running/active: {}\n", running));
2277    out.push_str(&format!("- Failed/stopped: {}\n", failed));
2278
2279    if services.is_empty() {
2280        out.push_str("\nNo services matched.");
2281        return Ok(out);
2282    }
2283
2284    // Split into running and stopped sections so both are always visible.
2285    let per_section = (max_entries / 2).max(5);
2286
2287    let running_services: Vec<_> = services
2288        .iter()
2289        .filter(|e| {
2290            e.status.eq_ignore_ascii_case("running") || e.status.eq_ignore_ascii_case("active")
2291        })
2292        .collect();
2293    let stopped_services: Vec<_> = services
2294        .iter()
2295        .filter(|e| {
2296            e.status.eq_ignore_ascii_case("stopped")
2297                || e.status.eq_ignore_ascii_case("failed")
2298                || e.status.eq_ignore_ascii_case("error")
2299        })
2300        .collect();
2301
2302    let fmt_entry = |entry: &&ServiceEntry| {
2303        let startup = entry
2304            .startup
2305            .as_deref()
2306            .map(|v| format!(" | startup {}", v))
2307            .unwrap_or_default();
2308        let logon = entry
2309            .start_name
2310            .as_deref()
2311            .map(|v| format!(" | LogOn: {}", v))
2312            .unwrap_or_default();
2313        let display = entry
2314            .display_name
2315            .as_deref()
2316            .filter(|v| *v != &entry.name)
2317            .map(|v| format!(" [{}]", v))
2318            .unwrap_or_default();
2319        format!(
2320            "- {}{} - {}{}{}\n",
2321            entry.name, display, entry.status, startup, logon
2322        )
2323    };
2324
2325    out.push_str(&format!(
2326        "\nRunning services ({} total, showing up to {}):\n",
2327        running_services.len(),
2328        per_section
2329    ));
2330    for entry in running_services.iter().take(per_section) {
2331        out.push_str(&fmt_entry(entry));
2332    }
2333    if running_services.len() > per_section {
2334        out.push_str(&format!(
2335            "- ... {} more running services omitted\n",
2336            running_services.len() - per_section
2337        ));
2338    }
2339
2340    out.push_str(&format!(
2341        "\nStopped/failed services ({} total, showing up to {}):\n",
2342        stopped_services.len(),
2343        per_section
2344    ));
2345    for entry in stopped_services.iter().take(per_section) {
2346        out.push_str(&fmt_entry(entry));
2347    }
2348    if stopped_services.len() > per_section {
2349        out.push_str(&format!(
2350            "- ... {} more stopped services omitted\n",
2351            stopped_services.len() - per_section
2352        ));
2353    }
2354
2355    Ok(out.trim_end().to_string())
2356}
2357
2358async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
2359    inspect_directory("Disk", path, max_entries).await
2360}
2361
2362fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
2363    let mut listeners = collect_listening_ports()?;
2364    if let Some(port) = port_filter {
2365        listeners.retain(|entry| entry.port == port);
2366    }
2367    listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
2368
2369    let mut out = String::from("Host inspection: ports\n\n");
2370    if let Some(port) = port_filter {
2371        out.push_str(&format!("- Filter port: {}\n", port));
2372    }
2373    out.push_str(&format!(
2374        "- Listening endpoints found: {}\n",
2375        listeners.len()
2376    ));
2377
2378    if listeners.is_empty() {
2379        out.push_str("\nNo listening endpoints matched.");
2380        return Ok(out);
2381    }
2382
2383    out.push_str("\nListening endpoints:\n");
2384    for entry in listeners.iter().take(max_entries) {
2385        let pid_str = entry
2386            .pid
2387            .as_deref()
2388            .map(|p| format!(" pid {}", p))
2389            .unwrap_or_default();
2390        let name_str = entry
2391            .process_name
2392            .as_deref()
2393            .map(|n| format!(" [{}]", n))
2394            .unwrap_or_default();
2395        out.push_str(&format!(
2396            "- {} {} ({}){}{}\n",
2397            entry.protocol, entry.local, entry.state, pid_str, name_str
2398        ));
2399    }
2400    if listeners.len() > max_entries {
2401        out.push_str(&format!(
2402            "- ... {} more listening endpoints omitted\n",
2403            listeners.len() - max_entries
2404        ));
2405    }
2406
2407    Ok(out.trim_end().to_string())
2408}
2409
2410fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
2411    if !path.exists() {
2412        return Err(format!("Path does not exist: {}", path.display()));
2413    }
2414    if !path.is_dir() {
2415        return Err(format!("Path is not a directory: {}", path.display()));
2416    }
2417
2418    let markers = collect_project_markers(&path);
2419    let hematite_state = collect_hematite_state(&path);
2420    let git_state = inspect_git_state(&path);
2421    let release_state = inspect_release_artifacts(&path);
2422
2423    let mut out = String::from("Host inspection: repo_doctor\n\n");
2424    out.push_str(&format!("- Path: {}\n", path.display()));
2425    out.push_str(&format!(
2426        "- Workspace mode: {}\n",
2427        workspace_mode_for_path(&path)
2428    ));
2429
2430    if markers.is_empty() {
2431        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");
2432    } else {
2433        out.push_str("- Project markers:\n");
2434        for marker in markers.iter().take(max_entries) {
2435            out.push_str(&format!("  - {}\n", marker));
2436        }
2437    }
2438
2439    match git_state {
2440        Some(git) => {
2441            out.push_str(&format!("- Git root: {}\n", git.root.display()));
2442            out.push_str(&format!("- Git branch: {}\n", git.branch));
2443            out.push_str(&format!("- Git status: {}\n", git.status_label()));
2444        }
2445        None => out.push_str("- Git: not inside a detected work tree\n"),
2446    }
2447
2448    out.push_str(&format!(
2449        "- Hematite docs/imports/reports: {}/{}/{}\n",
2450        hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
2451    ));
2452    if hematite_state.workspace_profile {
2453        out.push_str("- Workspace profile: present\n");
2454    } else {
2455        out.push_str("- Workspace profile: absent\n");
2456    }
2457
2458    if let Some(release) = release_state {
2459        out.push_str(&format!("- Cargo version: {}\n", release.version));
2460        out.push_str(&format!(
2461            "- Windows artifacts for current version: {}/{}/{}\n",
2462            bool_label(release.portable_dir),
2463            bool_label(release.portable_zip),
2464            bool_label(release.setup_exe)
2465        ));
2466    }
2467
2468    Ok(out.trim_end().to_string())
2469}
2470
2471async fn inspect_known_directory(
2472    label: &str,
2473    path: Option<PathBuf>,
2474    max_entries: usize,
2475) -> Result<String, String> {
2476    let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
2477    inspect_directory(label, path, max_entries).await
2478}
2479
2480async fn inspect_directory(
2481    label: &str,
2482    path: PathBuf,
2483    max_entries: usize,
2484) -> Result<String, String> {
2485    let label = label.to_string();
2486    tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
2487        .await
2488        .map_err(|e| format!("inspect_host task failed: {e}"))?
2489}
2490
2491fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
2492    if !path.exists() {
2493        return Err(format!("Path does not exist: {}", path.display()));
2494    }
2495    if !path.is_dir() {
2496        return Err(format!("Path is not a directory: {}", path.display()));
2497    }
2498
2499    let mut top_level_entries = Vec::new();
2500    for entry in fs::read_dir(path)
2501        .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
2502    {
2503        match entry {
2504            Ok(entry) => top_level_entries.push(entry),
2505            Err(_) => continue,
2506        }
2507    }
2508    top_level_entries.sort_by_key(|entry| entry.file_name());
2509
2510    let top_level_count = top_level_entries.len();
2511    let mut sample_names = Vec::new();
2512    let mut largest_entries = Vec::new();
2513    let mut aggregate = PathAggregate::default();
2514    let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
2515
2516    for entry in top_level_entries {
2517        let name = entry.file_name().to_string_lossy().to_string();
2518        if sample_names.len() < max_entries {
2519            sample_names.push(name.clone());
2520        }
2521        let kind = match entry.file_type() {
2522            Ok(ft) if ft.is_dir() => "dir",
2523            Ok(ft) if ft.is_symlink() => "symlink",
2524            _ => "file",
2525        };
2526        let stats = measure_path(&entry.path(), &mut budget);
2527        aggregate.merge(&stats);
2528        largest_entries.push(LargestEntry {
2529            name,
2530            kind,
2531            bytes: stats.total_bytes,
2532        });
2533    }
2534
2535    largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
2536
2537    let mut out = format!("Directory inspection: {}\n\n", label);
2538    out.push_str(&format!("- Path: {}\n", path.display()));
2539    out.push_str(&format!("- Top-level items: {}\n", top_level_count));
2540    out.push_str(&format!("- Recursive files: {}\n", aggregate.file_count));
2541    out.push_str(&format!(
2542        "- Recursive directories: {}\n",
2543        aggregate.dir_count
2544    ));
2545    out.push_str(&format!(
2546        "- Total size: {}{}\n",
2547        human_bytes(aggregate.total_bytes),
2548        if aggregate.partial {
2549            " (partial scan)"
2550        } else {
2551            ""
2552        }
2553    ));
2554    if aggregate.skipped_entries > 0 {
2555        out.push_str(&format!(
2556            "- Skipped entries: {} (permissions, symlinks, or scan budget)\n",
2557            aggregate.skipped_entries
2558        ));
2559    }
2560
2561    if !largest_entries.is_empty() {
2562        out.push_str("\nLargest top-level entries:\n");
2563        for entry in largest_entries.iter().take(max_entries) {
2564            out.push_str(&format!(
2565                "- {} [{}] - {}\n",
2566                entry.name,
2567                entry.kind,
2568                human_bytes(entry.bytes)
2569            ));
2570        }
2571    }
2572
2573    if !sample_names.is_empty() {
2574        out.push_str("\nSample names:\n");
2575        for name in sample_names {
2576            out.push_str(&format!("- {}\n", name));
2577        }
2578    }
2579
2580    Ok(out.trim_end().to_string())
2581}
2582
2583fn resolve_path(raw: &str) -> Result<PathBuf, String> {
2584    let trimmed = raw.trim();
2585    if trimmed.is_empty() {
2586        return Err("Path must not be empty.".to_string());
2587    }
2588
2589    if let Some(rest) = trimmed
2590        .strip_prefix("~/")
2591        .or_else(|| trimmed.strip_prefix("~\\"))
2592    {
2593        let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
2594        return Ok(home.join(rest));
2595    }
2596
2597    let path = PathBuf::from(trimmed);
2598    if path.is_absolute() {
2599        Ok(path)
2600    } else {
2601        let cwd =
2602            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
2603        let full_path = cwd.join(&path);
2604
2605        // Heuristic: If it's a relative path to .hematite or hematite.exe and doesn't exist here,
2606        // check the user's home directory.
2607        if !full_path.exists()
2608            && (trimmed.starts_with(".hematite") || trimmed.starts_with("hematite.exe"))
2609        {
2610            if let Some(home) = home::home_dir() {
2611                let home_path = home.join(trimmed);
2612                if home_path.exists() {
2613                    return Ok(home_path);
2614                }
2615            }
2616        }
2617
2618        Ok(full_path)
2619    }
2620}
2621
2622fn workspace_mode_label(workspace_root: &Path) -> &'static str {
2623    workspace_mode_for_path(workspace_root)
2624}
2625
2626fn workspace_mode_for_path(path: &Path) -> &'static str {
2627    if is_project_marker_path(path) {
2628        "project"
2629    } else if path.join(".hematite").join("docs").exists()
2630        || path.join(".hematite").join("imports").exists()
2631        || path.join(".hematite").join("reports").exists()
2632    {
2633        "docs-only"
2634    } else {
2635        "general directory"
2636    }
2637}
2638
2639fn is_project_marker_path(path: &Path) -> bool {
2640    [
2641        "Cargo.toml",
2642        "package.json",
2643        "pyproject.toml",
2644        "go.mod",
2645        "composer.json",
2646        "requirements.txt",
2647        "Makefile",
2648        "justfile",
2649    ]
2650    .iter()
2651    .any(|name| path.join(name).exists())
2652        || path.join(".git").exists()
2653}
2654
2655fn preferred_shell_label() -> &'static str {
2656    #[cfg(target_os = "windows")]
2657    {
2658        "PowerShell"
2659    }
2660    #[cfg(not(target_os = "windows"))]
2661    {
2662        "sh"
2663    }
2664}
2665
2666fn desktop_dir() -> Option<PathBuf> {
2667    home::home_dir().map(|home| home.join("Desktop"))
2668}
2669
2670fn downloads_dir() -> Option<PathBuf> {
2671    home::home_dir().map(|home| home.join("Downloads"))
2672}
2673
2674fn count_top_level_items(path: &Path) -> Result<usize, String> {
2675    let mut count = 0usize;
2676    for entry in
2677        fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
2678    {
2679        if entry.is_ok() {
2680            count += 1;
2681        }
2682    }
2683    Ok(count)
2684}
2685
2686#[derive(Default)]
2687struct PathAggregate {
2688    total_bytes: u64,
2689    file_count: u64,
2690    dir_count: u64,
2691    skipped_entries: u64,
2692    partial: bool,
2693}
2694
2695impl PathAggregate {
2696    fn merge(&mut self, other: &PathAggregate) {
2697        self.total_bytes += other.total_bytes;
2698        self.file_count += other.file_count;
2699        self.dir_count += other.dir_count;
2700        self.skipped_entries += other.skipped_entries;
2701        self.partial |= other.partial;
2702    }
2703}
2704
2705struct LargestEntry {
2706    name: String,
2707    kind: &'static str,
2708    bytes: u64,
2709}
2710
2711fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
2712    if *budget == 0 {
2713        return PathAggregate {
2714            partial: true,
2715            skipped_entries: 1,
2716            ..PathAggregate::default()
2717        };
2718    }
2719    *budget -= 1;
2720
2721    let metadata = match fs::symlink_metadata(path) {
2722        Ok(metadata) => metadata,
2723        Err(_) => {
2724            return PathAggregate {
2725                skipped_entries: 1,
2726                ..PathAggregate::default()
2727            }
2728        }
2729    };
2730
2731    let file_type = metadata.file_type();
2732    if file_type.is_symlink() {
2733        return PathAggregate {
2734            skipped_entries: 1,
2735            ..PathAggregate::default()
2736        };
2737    }
2738
2739    if metadata.is_file() {
2740        return PathAggregate {
2741            total_bytes: metadata.len(),
2742            file_count: 1,
2743            ..PathAggregate::default()
2744        };
2745    }
2746
2747    if !metadata.is_dir() {
2748        return PathAggregate::default();
2749    }
2750
2751    let mut aggregate = PathAggregate {
2752        dir_count: 1,
2753        ..PathAggregate::default()
2754    };
2755
2756    let read_dir = match fs::read_dir(path) {
2757        Ok(read_dir) => read_dir,
2758        Err(_) => {
2759            aggregate.skipped_entries += 1;
2760            return aggregate;
2761        }
2762    };
2763
2764    for child in read_dir {
2765        match child {
2766            Ok(child) => {
2767                let child_stats = measure_path(&child.path(), budget);
2768                aggregate.merge(&child_stats);
2769            }
2770            Err(_) => aggregate.skipped_entries += 1,
2771        }
2772    }
2773
2774    aggregate
2775}
2776
2777struct PathAnalysis {
2778    total_entries: usize,
2779    unique_entries: usize,
2780    entries: Vec<String>,
2781    duplicate_entries: Vec<String>,
2782    missing_entries: Vec<String>,
2783}
2784
2785fn analyze_path_env() -> PathAnalysis {
2786    let mut entries = Vec::new();
2787    let mut duplicate_entries = Vec::new();
2788    let mut missing_entries = Vec::new();
2789    let mut seen = HashSet::new();
2790
2791    let raw_path = std::env::var_os("PATH").unwrap_or_default();
2792    for path in std::env::split_paths(&raw_path) {
2793        let display = path.display().to_string();
2794        if display.trim().is_empty() {
2795            continue;
2796        }
2797
2798        let normalized = normalize_path_entry(&display);
2799        if !seen.insert(normalized) {
2800            duplicate_entries.push(display.clone());
2801        }
2802        if !path.exists() {
2803            missing_entries.push(display.clone());
2804        }
2805        entries.push(display);
2806    }
2807
2808    let total_entries = entries.len();
2809    let unique_entries = seen.len();
2810
2811    PathAnalysis {
2812        total_entries,
2813        unique_entries,
2814        entries,
2815        duplicate_entries,
2816        missing_entries,
2817    }
2818}
2819
2820fn normalize_path_entry(value: &str) -> String {
2821    #[cfg(target_os = "windows")]
2822    {
2823        value
2824            .replace('/', "\\")
2825            .trim_end_matches(['\\', '/'])
2826            .to_ascii_lowercase()
2827    }
2828    #[cfg(not(target_os = "windows"))]
2829    {
2830        value.trim_end_matches('/').to_string()
2831    }
2832}
2833
2834struct ToolchainReport {
2835    found: Vec<(String, String)>,
2836    missing: Vec<String>,
2837}
2838
2839struct PackageManagerReport {
2840    found: Vec<(String, String)>,
2841}
2842
2843#[derive(Debug, Clone)]
2844struct ProcessEntry {
2845    name: String,
2846    pid: u32,
2847    memory_bytes: u64,
2848    cpu_seconds: Option<f64>,
2849    cpu_percent: Option<f64>,
2850    read_ops: Option<u64>,
2851    write_ops: Option<u64>,
2852    detail: Option<String>,
2853}
2854
2855#[derive(Debug, Clone)]
2856struct ServiceEntry {
2857    name: String,
2858    status: String,
2859    startup: Option<String>,
2860    display_name: Option<String>,
2861    start_name: Option<String>,
2862}
2863
2864#[derive(Debug, Clone, Default)]
2865struct NetworkAdapter {
2866    name: String,
2867    ipv4: Vec<String>,
2868    ipv6: Vec<String>,
2869    gateways: Vec<String>,
2870    dns_servers: Vec<String>,
2871    disconnected: bool,
2872}
2873
2874impl NetworkAdapter {
2875    fn is_active(&self) -> bool {
2876        !self.disconnected
2877            && (!self.ipv4.is_empty() || !self.ipv6.is_empty() || !self.gateways.is_empty())
2878    }
2879}
2880
2881#[derive(Debug, Clone, Copy, Default)]
2882struct ListenerExposureSummary {
2883    loopback_only: usize,
2884    wildcard_public: usize,
2885    specific_bind: usize,
2886}
2887
2888#[derive(Debug, Clone)]
2889struct ListeningPort {
2890    protocol: String,
2891    local: String,
2892    port: u16,
2893    state: String,
2894    pid: Option<String>,
2895    process_name: Option<String>,
2896}
2897
2898fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
2899    #[cfg(target_os = "windows")]
2900    {
2901        collect_windows_listening_ports()
2902    }
2903    #[cfg(not(target_os = "windows"))]
2904    {
2905        collect_unix_listening_ports()
2906    }
2907}
2908
2909fn collect_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2910    #[cfg(target_os = "windows")]
2911    {
2912        collect_windows_network_adapters()
2913    }
2914    #[cfg(not(target_os = "windows"))]
2915    {
2916        collect_unix_network_adapters()
2917    }
2918}
2919
2920fn collect_services() -> Result<Vec<ServiceEntry>, String> {
2921    #[cfg(target_os = "windows")]
2922    {
2923        collect_windows_services()
2924    }
2925    #[cfg(not(target_os = "windows"))]
2926    {
2927        collect_unix_services()
2928    }
2929}
2930
2931#[cfg(target_os = "windows")]
2932fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
2933    let output = Command::new("netstat")
2934        .args(["-ano", "-p", "tcp"])
2935        .output()
2936        .map_err(|e| format!("Failed to run netstat: {e}"))?;
2937    if !output.status.success() {
2938        return Err("netstat returned a non-success status.".to_string());
2939    }
2940
2941    let text = String::from_utf8_lossy(&output.stdout);
2942    let mut listeners = Vec::new();
2943    for line in text.lines() {
2944        let trimmed = line.trim();
2945        if !trimmed.starts_with("TCP") {
2946            continue;
2947        }
2948        let cols: Vec<&str> = trimmed.split_whitespace().collect();
2949        if cols.len() < 5 || cols[3] != "LISTENING" {
2950            continue;
2951        }
2952        let Some(port) = extract_port_from_socket(cols[1]) else {
2953            continue;
2954        };
2955        listeners.push(ListeningPort {
2956            protocol: cols[0].to_string(),
2957            local: cols[1].to_string(),
2958            port,
2959            state: cols[3].to_string(),
2960            pid: Some(cols[4].to_string()),
2961            process_name: None,
2962        });
2963    }
2964
2965    // Enrich with process names via PowerShell — works without elevation for
2966    // most user-space processes. System processes (PID 4, etc.) stay unnamed.
2967    let unique_pids: Vec<String> = listeners
2968        .iter()
2969        .filter_map(|l| l.pid.clone())
2970        .collect::<HashSet<_>>()
2971        .into_iter()
2972        .collect();
2973
2974    if !unique_pids.is_empty() {
2975        let pid_list = unique_pids.join(",");
2976        let ps_cmd = format!(
2977            "Get-Process -Id {} -ErrorAction SilentlyContinue | Select-Object Id,Name | Format-Table -HideTableHeaders",
2978            pid_list
2979        );
2980        if let Ok(ps_out) = Command::new("powershell")
2981            .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
2982            .output()
2983        {
2984            let mut pid_map = std::collections::HashMap::<String, String>::new();
2985            let ps_text = String::from_utf8_lossy(&ps_out.stdout);
2986            for line in ps_text.lines() {
2987                let parts: Vec<&str> = line.split_whitespace().collect();
2988                if parts.len() >= 2 {
2989                    pid_map.insert(parts[0].to_string(), parts[1].to_string());
2990                }
2991            }
2992            for listener in &mut listeners {
2993                if let Some(pid) = &listener.pid {
2994                    listener.process_name = pid_map.get(pid).cloned();
2995                }
2996            }
2997        }
2998    }
2999
3000    Ok(listeners)
3001}
3002
3003#[cfg(not(target_os = "windows"))]
3004fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
3005    let output = Command::new("ss")
3006        .args(["-ltn"])
3007        .output()
3008        .map_err(|e| format!("Failed to run ss: {e}"))?;
3009    if !output.status.success() {
3010        return Err("ss returned a non-success status.".to_string());
3011    }
3012
3013    let text = String::from_utf8_lossy(&output.stdout);
3014    let mut listeners = Vec::new();
3015    for line in text.lines().skip(1) {
3016        let cols: Vec<&str> = line.split_whitespace().collect();
3017        if cols.len() < 4 {
3018            continue;
3019        }
3020        let Some(port) = extract_port_from_socket(cols[3]) else {
3021            continue;
3022        };
3023        listeners.push(ListeningPort {
3024            protocol: "tcp".to_string(),
3025            local: cols[3].to_string(),
3026            port,
3027            state: cols[0].to_string(),
3028            pid: None,
3029            process_name: None,
3030        });
3031    }
3032
3033    Ok(listeners)
3034}
3035
3036fn collect_processes() -> Result<Vec<ProcessEntry>, String> {
3037    #[cfg(target_os = "windows")]
3038    {
3039        collect_windows_processes()
3040    }
3041    #[cfg(not(target_os = "windows"))]
3042    {
3043        collect_unix_processes()
3044    }
3045}
3046
3047#[cfg(target_os = "windows")]
3048fn collect_windows_services() -> Result<Vec<ServiceEntry>, String> {
3049    let command = "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,DisplayName,StartName | ConvertTo-Json -Compress";
3050    let output = Command::new("powershell")
3051        .args(["-NoProfile", "-Command", command])
3052        .output()
3053        .map_err(|e| format!("Failed to run PowerShell service inspection: {e}"))?;
3054    if !output.status.success() {
3055        return Err("PowerShell service inspection returned a non-success status.".to_string());
3056    }
3057
3058    parse_windows_services_json(&String::from_utf8_lossy(&output.stdout))
3059}
3060
3061#[cfg(not(target_os = "windows"))]
3062fn collect_unix_services() -> Result<Vec<ServiceEntry>, String> {
3063    let status_output = Command::new("systemctl")
3064        .args([
3065            "list-units",
3066            "--type=service",
3067            "--all",
3068            "--no-pager",
3069            "--no-legend",
3070            "--plain",
3071        ])
3072        .output()
3073        .map_err(|e| format!("Failed to run systemctl list-units: {e}"))?;
3074    if !status_output.status.success() {
3075        return Err("systemctl list-units returned a non-success status.".to_string());
3076    }
3077
3078    let startup_output = Command::new("systemctl")
3079        .args([
3080            "list-unit-files",
3081            "--type=service",
3082            "--no-legend",
3083            "--no-pager",
3084            "--plain",
3085        ])
3086        .output()
3087        .map_err(|e| format!("Failed to run systemctl list-unit-files: {e}"))?;
3088    if !startup_output.status.success() {
3089        return Err("systemctl list-unit-files returned a non-success status.".to_string());
3090    }
3091
3092    Ok(parse_unix_services(
3093        &String::from_utf8_lossy(&status_output.stdout),
3094        &String::from_utf8_lossy(&startup_output.stdout),
3095    ))
3096}
3097
3098#[cfg(target_os = "windows")]
3099fn collect_windows_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3100    let output = Command::new("ipconfig")
3101        .args(["/all"])
3102        .output()
3103        .map_err(|e| format!("Failed to run ipconfig: {e}"))?;
3104    if !output.status.success() {
3105        return Err("ipconfig returned a non-success status.".to_string());
3106    }
3107
3108    Ok(parse_windows_ipconfig_all(&String::from_utf8_lossy(
3109        &output.stdout,
3110    )))
3111}
3112
3113#[cfg(not(target_os = "windows"))]
3114fn collect_unix_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3115    let addr_output = Command::new("ip")
3116        .args(["-o", "addr", "show", "up"])
3117        .output()
3118        .map_err(|e| format!("Failed to run ip addr: {e}"))?;
3119    if !addr_output.status.success() {
3120        return Err("ip addr returned a non-success status.".to_string());
3121    }
3122
3123    let route_output = Command::new("ip")
3124        .args(["route", "show", "default"])
3125        .output()
3126        .map_err(|e| format!("Failed to run ip route: {e}"))?;
3127    if !route_output.status.success() {
3128        return Err("ip route returned a non-success status.".to_string());
3129    }
3130
3131    let mut adapters = parse_unix_ip_addr(&String::from_utf8_lossy(&addr_output.stdout));
3132    apply_unix_default_routes(
3133        &mut adapters,
3134        &String::from_utf8_lossy(&route_output.stdout),
3135    );
3136    apply_unix_dns_servers(&mut adapters);
3137    Ok(adapters)
3138}
3139
3140#[cfg(target_os = "windows")]
3141fn collect_windows_processes() -> Result<Vec<ProcessEntry>, String> {
3142    // We take two samples of CPU time separated by a short interval to calculate recent CPU %
3143    let script = r#"
3144        $s1 = Get-Process | Select-Object Id, CPU
3145        Start-Sleep -Milliseconds 250
3146        $s2 = Get-Process | Select-Object Name, Id, WorkingSet64, CPU, ReadOperationCount, WriteOperationCount
3147        $s2 | ForEach-Object {
3148            $p2 = $_
3149            $p1 = $s1 | Where-Object { $_.Id -eq $p2.Id }
3150            $pct = 0.0
3151            if ($p1 -and $p2.CPU -gt $p1.CPU) {
3152                # (Delta CPU seconds / interval) * 100 / LogicalProcessors
3153                # Note: We skip division by logical processors to show 'per-core' usage or just raw % if preferred.
3154                # Standard Task Manager style is (delta / interval) * 100.
3155                $pct = [math]::Round((($p2.CPU - $p1.CPU) / 0.25) * 100, 1)
3156            }
3157            "PID:$($p2.Id)|NAME:$($p2.Name)|MEM:$($p2.WorkingSet64)|CPU_S:$($p2.CPU)|CPU_P:$pct|READ:$($p2.ReadOperationCount)|WRITE:$($p2.WriteOperationCount)"
3158        }
3159    "#;
3160
3161    let output = Command::new("powershell")
3162        .args(["-NoProfile", "-Command", script])
3163        .output()
3164        .map_err(|e| format!("Failed to run powershell Get-Process: {e}"))?;
3165
3166    let text = String::from_utf8_lossy(&output.stdout);
3167    let mut out = Vec::new();
3168    for line in text.lines() {
3169        let parts: Vec<&str> = line.trim().split('|').collect();
3170        if parts.len() < 5 {
3171            continue;
3172        }
3173        let mut entry = ProcessEntry {
3174            name: "unknown".to_string(),
3175            pid: 0,
3176            memory_bytes: 0,
3177            cpu_seconds: None,
3178            cpu_percent: None,
3179            read_ops: None,
3180            write_ops: None,
3181            detail: None,
3182        };
3183        for p in parts {
3184            if let Some((k, v)) = p.split_once(':') {
3185                match k {
3186                    "PID" => entry.pid = v.parse().unwrap_or(0),
3187                    "NAME" => entry.name = v.to_string(),
3188                    "MEM" => entry.memory_bytes = v.parse().unwrap_or(0),
3189                    "CPU_S" => entry.cpu_seconds = v.parse().ok(),
3190                    "CPU_P" => entry.cpu_percent = v.parse().ok(),
3191                    "READ" => entry.read_ops = v.parse().ok(),
3192                    "WRITE" => entry.write_ops = v.parse().ok(),
3193                    _ => {}
3194                }
3195            }
3196        }
3197        out.push(entry);
3198    }
3199    Ok(out)
3200}
3201
3202#[cfg(not(target_os = "windows"))]
3203fn collect_unix_processes() -> Result<Vec<ProcessEntry>, String> {
3204    let output = Command::new("ps")
3205        .args(["-eo", "pid=,rss=,comm="])
3206        .output()
3207        .map_err(|e| format!("Failed to run ps: {e}"))?;
3208    if !output.status.success() {
3209        return Err("ps returned a non-success status.".to_string());
3210    }
3211
3212    let text = String::from_utf8_lossy(&output.stdout);
3213    let mut processes = Vec::new();
3214    for line in text.lines() {
3215        let cols: Vec<&str> = line.split_whitespace().collect();
3216        if cols.len() < 3 {
3217            continue;
3218        }
3219        let (Some(pid), Some(rss_kib)) = (cols[0].parse::<u32>().ok(), cols[1].parse::<u64>().ok())
3220        else {
3221            continue;
3222        };
3223        processes.push(ProcessEntry {
3224            name: cols[2..].join(" "),
3225            pid,
3226            memory_bytes: rss_kib * 1024,
3227            cpu_seconds: None,
3228            cpu_percent: None,
3229            read_ops: None,
3230            write_ops: None,
3231            detail: None,
3232        });
3233    }
3234
3235    Ok(processes)
3236}
3237
3238fn extract_port_from_socket(value: &str) -> Option<u16> {
3239    let cleaned = value.trim().trim_matches(['[', ']']);
3240    let port_str = cleaned.rsplit(':').next()?;
3241    port_str.parse::<u16>().ok()
3242}
3243
3244fn listener_exposure_summary(listeners: Vec<ListeningPort>) -> ListenerExposureSummary {
3245    let mut summary = ListenerExposureSummary::default();
3246    for entry in listeners {
3247        let local = entry.local.to_ascii_lowercase();
3248        if is_loopback_listener(&local) {
3249            summary.loopback_only += 1;
3250        } else if is_wildcard_listener(&local) {
3251            summary.wildcard_public += 1;
3252        } else {
3253            summary.specific_bind += 1;
3254        }
3255    }
3256    summary
3257}
3258
3259fn is_loopback_listener(local: &str) -> bool {
3260    local.starts_with("127.")
3261        || local.starts_with("[::1]")
3262        || local.starts_with("::1")
3263        || local.starts_with("localhost:")
3264}
3265
3266fn is_wildcard_listener(local: &str) -> bool {
3267    local.starts_with("0.0.0.0:")
3268        || local.starts_with("[::]:")
3269        || local.starts_with(":::")
3270        || local == "*:*"
3271}
3272
3273struct GitState {
3274    root: PathBuf,
3275    branch: String,
3276    dirty_entries: usize,
3277}
3278
3279impl GitState {
3280    fn status_label(&self) -> String {
3281        if self.dirty_entries == 0 {
3282            "clean".to_string()
3283        } else {
3284            format!("dirty ({} changed path(s))", self.dirty_entries)
3285        }
3286    }
3287}
3288
3289fn inspect_git_state(path: &Path) -> Option<GitState> {
3290    let root = capture_first_line(
3291        "git",
3292        &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
3293    )?;
3294    let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
3295        .unwrap_or_else(|| "detached".to_string());
3296    let output = Command::new("git")
3297        .args(["-C", path.to_str()?, "status", "--short"])
3298        .output()
3299        .ok()?;
3300    if !output.status.success() {
3301        return None;
3302    }
3303    let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
3304    Some(GitState {
3305        root: PathBuf::from(root),
3306        branch,
3307        dirty_entries,
3308    })
3309}
3310
3311struct HematiteState {
3312    docs_count: usize,
3313    import_count: usize,
3314    report_count: usize,
3315    workspace_profile: bool,
3316}
3317
3318fn collect_hematite_state(path: &Path) -> HematiteState {
3319    let root = path.join(".hematite");
3320    HematiteState {
3321        docs_count: count_entries_if_exists(&root.join("docs")),
3322        import_count: count_entries_if_exists(&root.join("imports")),
3323        report_count: count_entries_if_exists(&root.join("reports")),
3324        workspace_profile: root.join("workspace_profile.json").exists(),
3325    }
3326}
3327
3328fn count_entries_if_exists(path: &Path) -> usize {
3329    if !path.exists() || !path.is_dir() {
3330        return 0;
3331    }
3332    fs::read_dir(path)
3333        .ok()
3334        .map(|iter| iter.filter(|entry| entry.is_ok()).count())
3335        .unwrap_or(0)
3336}
3337
3338fn collect_project_markers(path: &Path) -> Vec<String> {
3339    [
3340        "Cargo.toml",
3341        "package.json",
3342        "pyproject.toml",
3343        "go.mod",
3344        "justfile",
3345        "Makefile",
3346        ".git",
3347    ]
3348    .iter()
3349    .filter_map(|name| path.join(name).exists().then(|| (*name).to_string()))
3350    .collect()
3351}
3352
3353struct ReleaseArtifactState {
3354    version: String,
3355    portable_dir: bool,
3356    portable_zip: bool,
3357    setup_exe: bool,
3358}
3359
3360fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
3361    let cargo_toml = path.join("Cargo.toml");
3362    if !cargo_toml.exists() {
3363        return None;
3364    }
3365    let cargo_text = fs::read_to_string(cargo_toml).ok()?;
3366    let version = [regex_line_capture(
3367        &cargo_text,
3368        r#"(?m)^version\s*=\s*"([^"]+)""#,
3369    )?]
3370    .concat();
3371    let dist_windows = path.join("dist").join("windows");
3372    let prefix = format!("Hematite-{}", version);
3373    Some(ReleaseArtifactState {
3374        version,
3375        portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
3376        portable_zip: dist_windows
3377            .join(format!("{}-portable.zip", prefix))
3378            .exists(),
3379        setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
3380    })
3381}
3382
3383fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
3384    let regex = regex::Regex::new(pattern).ok()?;
3385    let captures = regex.captures(text)?;
3386    captures.get(1).map(|m| m.as_str().to_string())
3387}
3388
3389fn bool_label(value: bool) -> &'static str {
3390    if value {
3391        "yes"
3392    } else {
3393        "no"
3394    }
3395}
3396
3397fn collect_toolchains() -> ToolchainReport {
3398    let config = crate::agent::config::load_config();
3399    let mut python_probes = Vec::new();
3400    let _ = if let Some(ref path) = config.python_path {
3401        python_probes.push(CommandProbe::new(path, &["--version"]));
3402    } else {
3403    };
3404
3405    python_probes.extend([
3406        CommandProbe::new("python3", &["--version"]),
3407        CommandProbe::new("python", &["--version"]),
3408        CommandProbe::new("py", &["-3", "--version"]),
3409        CommandProbe::new("py", &["--version"]),
3410    ]);
3411
3412    let checks = [
3413        ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
3414        ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
3415        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3416        ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
3417        ToolCheck::new(
3418            "npm",
3419            &[
3420                CommandProbe::new("npm", &["--version"]),
3421                CommandProbe::new("npm.cmd", &["--version"]),
3422            ],
3423        ),
3424        ToolCheck::new(
3425            "pnpm",
3426            &[
3427                CommandProbe::new("pnpm", &["--version"]),
3428                CommandProbe::new("pnpm.cmd", &["--version"]),
3429            ],
3430        ),
3431        ToolCheck::new("python", &python_probes),
3432        ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
3433        ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
3434        ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
3435        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3436    ];
3437
3438    let mut found = Vec::new();
3439    let mut missing = Vec::new();
3440
3441    for check in checks {
3442        match check.detect() {
3443            Some(version) => found.push((check.label.to_string(), version)),
3444            None => missing.push(check.label.to_string()),
3445        }
3446    }
3447
3448    ToolchainReport { found, missing }
3449}
3450
3451fn collect_package_managers() -> PackageManagerReport {
3452    let config = crate::agent::config::load_config();
3453    let mut pip_probes = Vec::new();
3454    if let Some(ref path) = config.python_path {
3455        pip_probes.push(CommandProbe::new(path, &["-m", "pip", "--version"]));
3456    }
3457    pip_probes.extend([
3458        CommandProbe::new("python3", &["-m", "pip", "--version"]),
3459        CommandProbe::new("python", &["-m", "pip", "--version"]),
3460        CommandProbe::new("py", &["-3", "-m", "pip", "--version"]),
3461        CommandProbe::new("py", &["-m", "pip", "--version"]),
3462        CommandProbe::new("pip", &["--version"]),
3463    ]);
3464
3465    let checks = [
3466        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3467        ToolCheck::new(
3468            "npm",
3469            &[
3470                CommandProbe::new("npm", &["--version"]),
3471                CommandProbe::new("npm.cmd", &["--version"]),
3472            ],
3473        ),
3474        ToolCheck::new(
3475            "pnpm",
3476            &[
3477                CommandProbe::new("pnpm", &["--version"]),
3478                CommandProbe::new("pnpm.cmd", &["--version"]),
3479            ],
3480        ),
3481        ToolCheck::new("pip", &pip_probes),
3482        ToolCheck::new("pipx", &[CommandProbe::new("pipx", &["--version"])]),
3483        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3484        ToolCheck::new("winget", &[CommandProbe::new("winget", &["--version"])]),
3485        ToolCheck::new(
3486            "choco",
3487            &[
3488                CommandProbe::new("choco", &["--version"]),
3489                CommandProbe::new("choco.exe", &["--version"]),
3490            ],
3491        ),
3492        ToolCheck::new("scoop", &[CommandProbe::new("scoop", &["--version"])]),
3493    ];
3494
3495    let mut found = Vec::new();
3496    for check in checks {
3497        match check.detect() {
3498            Some(version) => found.push((check.label.to_string(), version)),
3499            None => {}
3500        }
3501    }
3502
3503    PackageManagerReport { found }
3504}
3505
3506#[derive(Clone)]
3507struct ToolCheck {
3508    label: &'static str,
3509    probes: Vec<CommandProbe>,
3510}
3511
3512impl ToolCheck {
3513    fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
3514        Self {
3515            label,
3516            probes: probes.to_vec(),
3517        }
3518    }
3519
3520    fn detect(&self) -> Option<String> {
3521        for probe in &self.probes {
3522            if let Some(output) = capture_first_line(&probe.program, &probe.args) {
3523                return Some(output);
3524            }
3525        }
3526        None
3527    }
3528}
3529
3530#[derive(Clone)]
3531struct CommandProbe {
3532    program: String,
3533    args: Vec<String>,
3534}
3535
3536impl CommandProbe {
3537    fn new(program: &str, args: &[&str]) -> Self {
3538        Self {
3539            program: program.to_string(),
3540            args: args.iter().map(|s| s.to_string()).collect(),
3541        }
3542    }
3543}
3544
3545fn build_env_doctor_findings(
3546    toolchains: &ToolchainReport,
3547    package_managers: &PackageManagerReport,
3548    path_stats: &PathAnalysis,
3549) -> Vec<String> {
3550    let found_tools = toolchains
3551        .found
3552        .iter()
3553        .map(|(label, _)| label.as_str())
3554        .collect::<HashSet<_>>();
3555    let found_managers = package_managers
3556        .found
3557        .iter()
3558        .map(|(label, _)| label.as_str())
3559        .collect::<HashSet<_>>();
3560
3561    let mut findings = Vec::new();
3562
3563    if path_stats.duplicate_entries.len() > 0 {
3564        findings.push(format!(
3565            "PATH contains {} duplicate entries. That is usually harmless but worth cleaning up.",
3566            path_stats.duplicate_entries.len()
3567        ));
3568    }
3569    if path_stats.missing_entries.len() > 0 {
3570        findings.push(format!(
3571            "PATH contains {} entries that do not exist on disk.",
3572            path_stats.missing_entries.len()
3573        ));
3574    }
3575    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
3576        findings.push(
3577            "Rust is present but Cargo was not detected. That is an incomplete Rust toolchain."
3578                .to_string(),
3579        );
3580    }
3581    if found_tools.contains("node")
3582        && !found_managers.contains("npm")
3583        && !found_managers.contains("pnpm")
3584    {
3585        findings.push(
3586            "Node is present but no JavaScript package manager was detected (npm or pnpm)."
3587                .to_string(),
3588        );
3589    }
3590    if found_tools.contains("python")
3591        && !found_managers.contains("pip")
3592        && !found_managers.contains("uv")
3593        && !found_managers.contains("pipx")
3594    {
3595        findings.push(
3596            "Python is present but no Python package manager was detected (pip, uv, or pipx)."
3597                .to_string(),
3598        );
3599    }
3600    let windows_manager_count = ["winget", "choco", "scoop"]
3601        .iter()
3602        .filter(|label| found_managers.contains(**label))
3603        .count();
3604    if windows_manager_count > 1 {
3605        findings.push(
3606            "Multiple Windows package managers are installed. That is workable, but it can create overlap in update paths."
3607                .to_string(),
3608        );
3609    }
3610    if findings.is_empty() && !found_managers.is_empty() {
3611        findings.push(
3612            "Core package-manager coverage looks healthy for a normal developer workstation."
3613                .to_string(),
3614        );
3615    }
3616
3617    findings
3618}
3619
3620fn capture_first_line<S: AsRef<str>>(program: &str, args: &[S]) -> Option<String> {
3621    let output = std::process::Command::new(program)
3622        .args(args.iter().map(|s| s.as_ref()))
3623        .output()
3624        .ok()?;
3625    if !output.status.success() {
3626        return None;
3627    }
3628
3629    let stdout = if output.stdout.is_empty() {
3630        String::from_utf8_lossy(&output.stderr).into_owned()
3631    } else {
3632        String::from_utf8_lossy(&output.stdout).into_owned()
3633    };
3634
3635    stdout
3636        .lines()
3637        .map(str::trim)
3638        .find(|line| !line.is_empty())
3639        .map(|line| line.to_string())
3640}
3641
3642fn human_bytes(bytes: u64) -> String {
3643    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
3644    let mut value = bytes as f64;
3645    let mut unit_index = 0usize;
3646
3647    while value >= 1024.0 && unit_index < UNITS.len() - 1 {
3648        value /= 1024.0;
3649        unit_index += 1;
3650    }
3651
3652    if unit_index == 0 {
3653        format!("{} {}", bytes, UNITS[unit_index])
3654    } else {
3655        format!("{value:.1} {}", UNITS[unit_index])
3656    }
3657}
3658
3659#[cfg(target_os = "windows")]
3660fn parse_windows_ipconfig_all(text: &str) -> Vec<NetworkAdapter> {
3661    let mut adapters = Vec::new();
3662    let mut current: Option<NetworkAdapter> = None;
3663    let mut pending_dns = false;
3664
3665    for raw_line in text.lines() {
3666        let line = raw_line.trim_end();
3667        let trimmed = line.trim();
3668        if trimmed.is_empty() {
3669            pending_dns = false;
3670            continue;
3671        }
3672
3673        if !line.starts_with(' ') && trimmed.ends_with(':') && trimmed.contains("adapter") {
3674            if let Some(adapter) = current.take() {
3675                adapters.push(adapter);
3676            }
3677            current = Some(NetworkAdapter {
3678                name: trimmed.trim_end_matches(':').to_string(),
3679                ..NetworkAdapter::default()
3680            });
3681            pending_dns = false;
3682            continue;
3683        }
3684
3685        let Some(adapter) = current.as_mut() else {
3686            continue;
3687        };
3688
3689        if trimmed.contains("Media State") && trimmed.contains("disconnected") {
3690            adapter.disconnected = true;
3691        }
3692
3693        if let Some(value) = value_after_colon(trimmed) {
3694            let normalized = normalize_ipconfig_value(value);
3695            if trimmed.starts_with("IPv4 Address") && !normalized.is_empty() {
3696                adapter.ipv4.push(normalized);
3697                pending_dns = false;
3698            } else if trimmed.starts_with("IPv6 Address")
3699                || trimmed.starts_with("Temporary IPv6 Address")
3700                || trimmed.starts_with("Link-local IPv6 Address")
3701            {
3702                if !normalized.is_empty() {
3703                    adapter.ipv6.push(normalized);
3704                }
3705                pending_dns = false;
3706            } else if trimmed.starts_with("Default Gateway") {
3707                if !normalized.is_empty() {
3708                    adapter.gateways.push(normalized);
3709                }
3710                pending_dns = false;
3711            } else if trimmed.starts_with("DNS Servers") {
3712                if !normalized.is_empty() {
3713                    adapter.dns_servers.push(normalized);
3714                }
3715                pending_dns = true;
3716            } else {
3717                pending_dns = false;
3718            }
3719        } else if pending_dns {
3720            let normalized = normalize_ipconfig_value(trimmed);
3721            if !normalized.is_empty() {
3722                adapter.dns_servers.push(normalized);
3723            }
3724        }
3725    }
3726
3727    if let Some(adapter) = current.take() {
3728        adapters.push(adapter);
3729    }
3730
3731    for adapter in &mut adapters {
3732        dedup_vec(&mut adapter.ipv4);
3733        dedup_vec(&mut adapter.ipv6);
3734        dedup_vec(&mut adapter.gateways);
3735        dedup_vec(&mut adapter.dns_servers);
3736    }
3737
3738    adapters
3739}
3740
3741#[cfg(not(target_os = "windows"))]
3742fn parse_unix_ip_addr(text: &str) -> Vec<NetworkAdapter> {
3743    let mut adapters = std::collections::BTreeMap::<String, NetworkAdapter>::new();
3744
3745    for line in text.lines() {
3746        let cols: Vec<&str> = line.split_whitespace().collect();
3747        if cols.len() < 4 {
3748            continue;
3749        }
3750        let name = cols[1].trim_end_matches(':').to_string();
3751        let family = cols[2];
3752        let addr = cols[3].split('/').next().unwrap_or("").to_string();
3753        let entry = adapters
3754            .entry(name.clone())
3755            .or_insert_with(|| NetworkAdapter {
3756                name,
3757                ..NetworkAdapter::default()
3758            });
3759        match family {
3760            "inet" if !addr.is_empty() => entry.ipv4.push(addr),
3761            "inet6" if !addr.is_empty() => entry.ipv6.push(addr),
3762            _ => {}
3763        }
3764    }
3765
3766    adapters.into_values().collect()
3767}
3768
3769#[cfg(not(target_os = "windows"))]
3770fn apply_unix_default_routes(adapters: &mut [NetworkAdapter], text: &str) {
3771    for line in text.lines() {
3772        let cols: Vec<&str> = line.split_whitespace().collect();
3773        if cols.len() < 5 {
3774            continue;
3775        }
3776        let gateway = cols
3777            .windows(2)
3778            .find(|pair| pair[0] == "via")
3779            .map(|pair| pair[1].to_string());
3780        let dev = cols
3781            .windows(2)
3782            .find(|pair| pair[0] == "dev")
3783            .map(|pair| pair[1]);
3784        if let (Some(gateway), Some(dev)) = (gateway, dev) {
3785            if let Some(adapter) = adapters.iter_mut().find(|adapter| adapter.name == dev) {
3786                adapter.gateways.push(gateway);
3787            }
3788        }
3789    }
3790
3791    for adapter in adapters {
3792        dedup_vec(&mut adapter.gateways);
3793    }
3794}
3795
3796#[cfg(not(target_os = "windows"))]
3797fn apply_unix_dns_servers(adapters: &mut [NetworkAdapter]) {
3798    let Ok(text) = fs::read_to_string("/etc/resolv.conf") else {
3799        return;
3800    };
3801    let mut dns_servers = text
3802        .lines()
3803        .filter_map(|line| line.strip_prefix("nameserver "))
3804        .map(str::trim)
3805        .filter(|value| !value.is_empty())
3806        .map(|value| value.to_string())
3807        .collect::<Vec<_>>();
3808    dedup_vec(&mut dns_servers);
3809    if dns_servers.is_empty() {
3810        return;
3811    }
3812    for adapter in adapters.iter_mut().filter(|adapter| adapter.is_active()) {
3813        adapter.dns_servers = dns_servers.clone();
3814    }
3815}
3816
3817#[cfg(target_os = "windows")]
3818fn value_after_colon(line: &str) -> Option<&str> {
3819    line.split_once(':').map(|(_, value)| value.trim())
3820}
3821
3822#[cfg(target_os = "windows")]
3823fn normalize_ipconfig_value(value: &str) -> String {
3824    value
3825        .trim()
3826        .trim_end_matches("(Preferred)")
3827        .trim_end_matches("(Deprecated)")
3828        .trim()
3829        .trim_matches(['(', ')'])
3830        .trim()
3831        .to_string()
3832}
3833
3834#[cfg(target_os = "windows")]
3835fn is_noise_lan_neighbor(ip: &str, mac: &str) -> bool {
3836    let mac_upper = mac.to_ascii_uppercase();
3837    if mac_upper == "FF-FF-FF-FF-FF-FF" || mac_upper.starts_with("01-00-5E-") {
3838        return true;
3839    }
3840
3841    ip == "255.255.255.255"
3842        || ip.starts_with("224.")
3843        || ip.starts_with("225.")
3844        || ip.starts_with("226.")
3845        || ip.starts_with("227.")
3846        || ip.starts_with("228.")
3847        || ip.starts_with("229.")
3848        || ip.starts_with("230.")
3849        || ip.starts_with("231.")
3850        || ip.starts_with("232.")
3851        || ip.starts_with("233.")
3852        || ip.starts_with("234.")
3853        || ip.starts_with("235.")
3854        || ip.starts_with("236.")
3855        || ip.starts_with("237.")
3856        || ip.starts_with("238.")
3857        || ip.starts_with("239.")
3858}
3859
3860fn dedup_vec(values: &mut Vec<String>) {
3861    let mut seen = HashSet::new();
3862    values.retain(|value| seen.insert(value.clone()));
3863}
3864
3865#[cfg(target_os = "windows")]
3866fn parse_lan_neighbors(text: &str) -> Vec<(String, String, String, String)> {
3867    let trimmed = text.trim();
3868    if trimmed.is_empty() {
3869        return Vec::new();
3870    }
3871
3872    let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
3873        return Vec::new();
3874    };
3875    let entries = match value {
3876        Value::Array(items) => items,
3877        other => vec![other],
3878    };
3879
3880    let mut neighbors = Vec::new();
3881    for entry in entries {
3882        let ip = entry
3883            .get("IPAddress")
3884            .and_then(|v| v.as_str())
3885            .unwrap_or("")
3886            .to_string();
3887        if ip.is_empty() {
3888            continue;
3889        }
3890        let mac = entry
3891            .get("LinkLayerAddress")
3892            .and_then(|v| v.as_str())
3893            .unwrap_or("unknown")
3894            .to_string();
3895        let state = entry
3896            .get("State")
3897            .and_then(|v| v.as_str())
3898            .unwrap_or("unknown")
3899            .to_string();
3900        let iface = entry
3901            .get("InterfaceAlias")
3902            .and_then(|v| v.as_str())
3903            .unwrap_or("unknown")
3904            .to_string();
3905        if is_noise_lan_neighbor(&ip, &mac) {
3906            continue;
3907        }
3908        neighbors.push((ip, mac, state, iface));
3909    }
3910
3911    neighbors
3912}
3913
3914#[cfg(target_os = "windows")]
3915fn parse_windows_services_json(text: &str) -> Result<Vec<ServiceEntry>, String> {
3916    let trimmed = text.trim();
3917    if trimmed.is_empty() {
3918        return Ok(Vec::new());
3919    }
3920
3921    let value: Value = serde_json::from_str(trimmed)
3922        .map_err(|e| format!("Failed to parse PowerShell service JSON: {e}"))?;
3923    let entries = match value {
3924        Value::Array(items) => items,
3925        other => vec![other],
3926    };
3927
3928    let mut services = Vec::new();
3929    for entry in entries {
3930        let Some(name) = entry.get("Name").and_then(|v| v.as_str()) else {
3931            continue;
3932        };
3933        services.push(ServiceEntry {
3934            name: name.to_string(),
3935            status: entry
3936                .get("State")
3937                .and_then(|v| v.as_str())
3938                .unwrap_or("unknown")
3939                .to_string(),
3940            startup: entry
3941                .get("StartMode")
3942                .and_then(|v| v.as_str())
3943                .map(|v| v.to_string()),
3944            display_name: entry
3945                .get("DisplayName")
3946                .and_then(|v| v.as_str())
3947                .map(|v| v.to_string()),
3948            start_name: entry
3949                .get("StartName")
3950                .and_then(|v| v.as_str())
3951                .map(|v| v.to_string()),
3952        });
3953    }
3954
3955    Ok(services)
3956}
3957
3958#[cfg(target_os = "windows")]
3959fn windows_json_entries(node: Option<&Value>) -> Vec<Value> {
3960    match node.cloned() {
3961        Some(Value::Array(items)) => items,
3962        Some(other) => vec![other],
3963        None => Vec::new(),
3964    }
3965}
3966
3967#[cfg(target_os = "windows")]
3968fn parse_windows_pnp_devices(node: Option<&Value>) -> Vec<WindowsPnpDevice> {
3969    windows_json_entries(node)
3970        .into_iter()
3971        .filter_map(|entry| {
3972            let name = entry
3973                .get("FriendlyName")
3974                .and_then(|v| v.as_str())
3975                .or_else(|| entry.get("Name").and_then(|v| v.as_str()))
3976                .unwrap_or("")
3977                .trim()
3978                .to_string();
3979            if name.is_empty() {
3980                return None;
3981            }
3982            Some(WindowsPnpDevice {
3983                name,
3984                status: entry
3985                    .get("Status")
3986                    .and_then(|v| v.as_str())
3987                    .unwrap_or("Unknown")
3988                    .trim()
3989                    .to_string(),
3990                problem: entry.get("Problem").and_then(|v| v.as_u64()).or_else(|| {
3991                    entry
3992                        .get("Problem")
3993                        .and_then(|v| v.as_i64())
3994                        .map(|v| v as u64)
3995                }),
3996                class_name: entry
3997                    .get("Class")
3998                    .and_then(|v| v.as_str())
3999                    .map(|v| v.trim().to_string()),
4000                instance_id: entry
4001                    .get("InstanceId")
4002                    .and_then(|v| v.as_str())
4003                    .map(|v| v.trim().to_string()),
4004            })
4005        })
4006        .collect()
4007}
4008
4009#[cfg(target_os = "windows")]
4010fn parse_windows_sound_devices(node: Option<&Value>) -> Vec<WindowsSoundDevice> {
4011    windows_json_entries(node)
4012        .into_iter()
4013        .filter_map(|entry| {
4014            let name = entry
4015                .get("Name")
4016                .and_then(|v| v.as_str())
4017                .unwrap_or("")
4018                .trim()
4019                .to_string();
4020            if name.is_empty() {
4021                return None;
4022            }
4023            Some(WindowsSoundDevice {
4024                name,
4025                status: entry
4026                    .get("Status")
4027                    .and_then(|v| v.as_str())
4028                    .unwrap_or("Unknown")
4029                    .trim()
4030                    .to_string(),
4031                manufacturer: entry
4032                    .get("Manufacturer")
4033                    .and_then(|v| v.as_str())
4034                    .map(|v| v.trim().to_string()),
4035            })
4036        })
4037        .collect()
4038}
4039
4040#[cfg(target_os = "windows")]
4041fn windows_device_has_issue(device: &WindowsPnpDevice) -> bool {
4042    !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
4043        || device.problem.unwrap_or(0) != 0
4044}
4045
4046#[cfg(target_os = "windows")]
4047fn windows_sound_device_has_issue(device: &WindowsSoundDevice) -> bool {
4048    !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
4049}
4050
4051#[cfg(target_os = "windows")]
4052fn is_microphone_like_name(name: &str) -> bool {
4053    let lower = name.to_ascii_lowercase();
4054    lower.contains("microphone")
4055        || lower.contains("mic")
4056        || lower.contains("input")
4057        || lower.contains("array")
4058        || lower.contains("capture")
4059        || lower.contains("record")
4060}
4061
4062#[cfg(target_os = "windows")]
4063fn is_bluetooth_like_name(name: &str) -> bool {
4064    let lower = name.to_ascii_lowercase();
4065    lower.contains("bluetooth") || lower.contains("hands-free") || lower.contains("a2dp")
4066}
4067
4068#[cfg(target_os = "windows")]
4069fn service_is_running(service: &ServiceEntry) -> bool {
4070    service.status.eq_ignore_ascii_case("running") || service.status.eq_ignore_ascii_case("active")
4071}
4072
4073#[cfg(not(target_os = "windows"))]
4074fn parse_unix_services(status_text: &str, startup_text: &str) -> Vec<ServiceEntry> {
4075    let mut startup_modes = std::collections::HashMap::<String, String>::new();
4076    for line in startup_text.lines() {
4077        let cols: Vec<&str> = line.split_whitespace().collect();
4078        if cols.len() < 2 {
4079            continue;
4080        }
4081        startup_modes.insert(cols[0].to_string(), cols[1].to_string());
4082    }
4083
4084    let mut services = Vec::new();
4085    for line in status_text.lines() {
4086        let cols: Vec<&str> = line.split_whitespace().collect();
4087        if cols.len() < 4 {
4088            continue;
4089        }
4090        let unit = cols[0];
4091        let load = cols[1];
4092        let active = cols[2];
4093        let sub = cols[3];
4094        let description = if cols.len() > 4 {
4095            Some(cols[4..].join(" "))
4096        } else {
4097            None
4098        };
4099        services.push(ServiceEntry {
4100            name: unit.to_string(),
4101            status: format!("{}/{}", active, sub),
4102            startup: startup_modes
4103                .get(unit)
4104                .cloned()
4105                .or_else(|| Some(load.to_string())),
4106            display_name: description,
4107            start_name: None,
4108        });
4109    }
4110
4111    services
4112}
4113
4114// ── health_report ─────────────────────────────────────────────────────────────
4115
4116/// Synthesized system health report — runs multiple checks and returns a
4117/// plain-English tiered verdict suitable for both developers and non-technical
4118/// users who just want to know if their machine is okay.
4119fn inspect_health_report() -> Result<String, String> {
4120    let mut needs_fix: Vec<String> = Vec::new();
4121    let mut watch: Vec<String> = Vec::new();
4122    let mut good: Vec<String> = Vec::new();
4123    let mut tips: Vec<String> = Vec::new();
4124
4125    health_check_disk(&mut needs_fix, &mut watch, &mut good);
4126    health_check_memory(&mut watch, &mut good);
4127    health_check_network(&mut needs_fix, &mut watch, &mut good);
4128    health_check_pending_reboot(&mut watch, &mut good);
4129    health_check_services(&mut needs_fix, &mut watch, &mut good);
4130    health_check_thermal(&mut watch, &mut good);
4131    health_check_tools(&mut watch, &mut good, &mut tips);
4132    health_check_recent_errors(&mut watch, &mut tips);
4133
4134    let overall = if !needs_fix.is_empty() {
4135        "ACTION REQUIRED"
4136    } else if !watch.is_empty() {
4137        "WORTH A LOOK"
4138    } else {
4139        "ALL GOOD"
4140    };
4141
4142    let mut out = format!("System Health Report — {overall}\n\n");
4143
4144    if !needs_fix.is_empty() {
4145        out.push_str("Needs fixing:\n");
4146        for item in &needs_fix {
4147            out.push_str(&format!("  [!] {item}\n"));
4148        }
4149        out.push('\n');
4150    }
4151    if !watch.is_empty() {
4152        out.push_str("Worth watching:\n");
4153        for item in &watch {
4154            out.push_str(&format!("  [-] {item}\n"));
4155        }
4156        out.push('\n');
4157    }
4158    if !good.is_empty() {
4159        out.push_str("Looking good:\n");
4160        for item in &good {
4161            out.push_str(&format!("  [+] {item}\n"));
4162        }
4163        out.push('\n');
4164    }
4165    if !tips.is_empty() {
4166        out.push_str("To dig deeper:\n");
4167        for tip in &tips {
4168            out.push_str(&format!("  {tip}\n"));
4169        }
4170    }
4171
4172    Ok(out.trim_end().to_string())
4173}
4174
4175fn health_check_disk(needs_fix: &mut Vec<String>, watch: &mut Vec<String>, good: &mut Vec<String>) {
4176    #[cfg(target_os = "windows")]
4177    {
4178        let script = r#"try {
4179    $d = Get-PSDrive C -ErrorAction Stop
4180    "$($d.Free)|$($d.Used)"
4181} catch { "ERR" }"#;
4182        if let Ok(out) = Command::new("powershell")
4183            .args(["-NoProfile", "-Command", script])
4184            .output()
4185        {
4186            let text = String::from_utf8_lossy(&out.stdout);
4187            let text = text.trim();
4188            if !text.starts_with("ERR") {
4189                let parts: Vec<&str> = text.split('|').collect();
4190                if parts.len() == 2 {
4191                    let free_bytes: u64 = parts[0].trim().parse().unwrap_or(0);
4192                    let used_bytes: u64 = parts[1].trim().parse().unwrap_or(0);
4193                    let total = free_bytes + used_bytes;
4194                    let free_gb = free_bytes / 1_073_741_824;
4195                    let pct_free = if total > 0 {
4196                        (free_bytes as f64 / total as f64 * 100.0) as u64
4197                    } else {
4198                        0
4199                    };
4200                    let msg = format!("Disk: {free_gb} GB free on C: ({pct_free}% available)");
4201                    if free_gb < 5 {
4202                        needs_fix.push(format!(
4203                            "{msg} — very low. Free up space or your system may slow down or stop working."
4204                        ));
4205                    } else if free_gb < 15 {
4206                        watch.push(format!("{msg} — getting low, consider cleaning up."));
4207                    } else {
4208                        good.push(msg);
4209                    }
4210                    return;
4211                }
4212            }
4213        }
4214        watch.push("Disk: could not read free space from C: drive.".to_string());
4215    }
4216
4217    #[cfg(not(target_os = "windows"))]
4218    {
4219        if let Ok(out) = Command::new("df").args(["-BG", "/"]).output() {
4220            let text = String::from_utf8_lossy(&out.stdout);
4221            for line in text.lines().skip(1) {
4222                let cols: Vec<&str> = line.split_whitespace().collect();
4223                if cols.len() >= 5 {
4224                    let avail_str = cols[3].trim_end_matches('G');
4225                    let use_pct = cols[4].trim_end_matches('%');
4226                    let avail_gb: u64 = avail_str.parse().unwrap_or(0);
4227                    let used_pct: u64 = use_pct.parse().unwrap_or(0);
4228                    let msg = format!("Disk: {avail_gb} GB free on / ({used_pct}% used)");
4229                    if avail_gb < 5 {
4230                        needs_fix.push(format!(
4231                            "{msg} — very low. Free up space to prevent system issues."
4232                        ));
4233                    } else if avail_gb < 15 {
4234                        watch.push(format!("{msg} — getting low."));
4235                    } else {
4236                        good.push(msg);
4237                    }
4238                    return;
4239                }
4240            }
4241        }
4242        watch.push("Disk: could not determine free space.".to_string());
4243    }
4244}
4245
4246fn health_check_memory(watch: &mut Vec<String>, good: &mut Vec<String>) {
4247    #[cfg(target_os = "windows")]
4248    {
4249        let script = r#"try {
4250    $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
4251    "$($os.FreePhysicalMemory)|$($os.TotalVisibleMemorySize)"
4252} catch { "ERR" }"#;
4253        if let Ok(out) = Command::new("powershell")
4254            .args(["-NoProfile", "-Command", script])
4255            .output()
4256        {
4257            let text = String::from_utf8_lossy(&out.stdout);
4258            let text = text.trim();
4259            if !text.starts_with("ERR") {
4260                let parts: Vec<&str> = text.split('|').collect();
4261                if parts.len() == 2 {
4262                    let free_kb: u64 = parts[0].trim().parse().unwrap_or(0);
4263                    let total_kb: u64 = parts[1].trim().parse().unwrap_or(0);
4264                    if total_kb > 0 {
4265                        let free_gb = free_kb / 1_048_576;
4266                        let total_gb = total_kb / 1_048_576;
4267                        let free_pct = free_kb * 100 / total_kb;
4268                        let msg = format!(
4269                            "RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)"
4270                        );
4271                        if free_pct < 10 {
4272                            watch.push(format!(
4273                                "{msg} — very low. Close unused apps to free up memory."
4274                            ));
4275                        } else if free_pct < 25 {
4276                            watch.push(format!("{msg} — running a bit low."));
4277                        } else {
4278                            good.push(msg);
4279                        }
4280                        return;
4281                    }
4282                }
4283            }
4284        }
4285    }
4286
4287    #[cfg(not(target_os = "windows"))]
4288    {
4289        if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4290            let mut total_kb = 0u64;
4291            let mut avail_kb = 0u64;
4292            for line in content.lines() {
4293                if line.starts_with("MemTotal:") {
4294                    total_kb = line
4295                        .split_whitespace()
4296                        .nth(1)
4297                        .and_then(|v| v.parse().ok())
4298                        .unwrap_or(0);
4299                } else if line.starts_with("MemAvailable:") {
4300                    avail_kb = line
4301                        .split_whitespace()
4302                        .nth(1)
4303                        .and_then(|v| v.parse().ok())
4304                        .unwrap_or(0);
4305                }
4306            }
4307            if total_kb > 0 {
4308                let free_gb = avail_kb / 1_048_576;
4309                let total_gb = total_kb / 1_048_576;
4310                let free_pct = avail_kb * 100 / total_kb;
4311                let msg =
4312                    format!("RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)");
4313                if free_pct < 10 {
4314                    watch.push(format!("{msg} — very low. Close unused apps."));
4315                } else if free_pct < 25 {
4316                    watch.push(format!("{msg} — running a bit low."));
4317                } else {
4318                    good.push(msg);
4319                }
4320            }
4321        }
4322    }
4323}
4324
4325/// Try running `cmd --arg` via PATH first, then via a known install-path fallback.
4326/// Prevents false "not installed" reports when the process PATH omits tool directories
4327/// (e.g. ~/.cargo/bin missing from a shortcut-launched or headless session).
4328fn probe_tool(cmd: &str, arg: &str) -> bool {
4329    if Command::new(cmd)
4330        .arg(arg)
4331        .stdout(std::process::Stdio::null())
4332        .stderr(std::process::Stdio::null())
4333        .status()
4334        .map(|s| s.success())
4335        .unwrap_or(false)
4336    {
4337        return true;
4338    }
4339    // Fallback: well-known Windows install locations for tools that live outside system32.
4340    #[cfg(windows)]
4341    {
4342        let home = std::env::var("USERPROFILE").unwrap_or_default();
4343        let fallback: Option<String> = match cmd {
4344            "cargo" | "rustc" | "rustup" => Some(format!(r"{}\\.cargo\\bin\\{}.exe", home, cmd)),
4345            "node" => Some(r"C:\Program Files\nodejs\node.exe".to_string()),
4346            "npm" => Some("C:\\Program Files\\nodejs\\npm.cmd".to_string()),
4347            _ => None,
4348        };
4349        if let Some(path) = fallback {
4350            return Command::new(&path)
4351                .arg(arg)
4352                .stdout(std::process::Stdio::null())
4353                .stderr(std::process::Stdio::null())
4354                .status()
4355                .map(|s| s.success())
4356                .unwrap_or(false);
4357        }
4358    }
4359    false
4360}
4361
4362fn health_check_tools(watch: &mut Vec<String>, good: &mut Vec<String>, tips: &mut Vec<String>) {
4363    let tool_checks: &[(&str, &str, &str)] = &[
4364        ("git", "--version", "Git"),
4365        ("cargo", "--version", "Rust / Cargo"),
4366        ("node", "--version", "Node.js"),
4367        ("python", "--version", "Python"),
4368        ("python3", "--version", "Python 3"),
4369        ("npm", "--version", "npm"),
4370    ];
4371
4372    let mut found: Vec<String> = Vec::new();
4373    let mut missing: Vec<String> = Vec::new();
4374    let mut python_found = false;
4375
4376    for (cmd, arg, label) in tool_checks {
4377        if cmd.starts_with("python") && python_found {
4378            continue;
4379        }
4380        let ok = probe_tool(cmd, arg);
4381        if ok {
4382            found.push((*label).to_string());
4383            if cmd.starts_with("python") {
4384                python_found = true;
4385            }
4386        } else if !cmd.starts_with("python") || !python_found {
4387            missing.push((*label).to_string());
4388        }
4389    }
4390
4391    if !found.is_empty() {
4392        good.push(format!("Dev tools found: {}", found.join(", ")));
4393    }
4394    if !missing.is_empty() {
4395        watch.push(format!(
4396            "Not installed (or not on PATH): {} — only matters if you need them",
4397            missing.join(", ")
4398        ));
4399        tips.push(
4400            "Run inspect_host(topic=\"toolchains\") for exact version details on all dev tools."
4401                .to_string(),
4402        );
4403    }
4404}
4405
4406fn health_check_recent_errors(watch: &mut Vec<String>, tips: &mut Vec<String>) {
4407    #[cfg(target_os = "windows")]
4408    {
4409        let script = r#"try {
4410    $cutoff = (Get-Date).AddHours(-24)
4411    $count = (Get-WinEvent -FilterHashtable @{LogName='Application','System'; Level=1,2,3; StartTime=$cutoff} -MaxEvents 200 -ErrorAction SilentlyContinue | Measure-Object).Count
4412    $count
4413} catch { "0" }"#;
4414        if let Ok(out) = Command::new("powershell")
4415            .args(["-NoProfile", "-Command", script])
4416            .output()
4417        {
4418            let text = String::from_utf8_lossy(&out.stdout);
4419            let count: u64 = text.trim().parse().unwrap_or(0);
4420            if count > 0 {
4421                watch.push(format!(
4422                    "{count} critical/error event{} in Windows event logs in the last 24 hours.",
4423                    if count == 1 { "" } else { "s" }
4424                ));
4425                tips.push(
4426                    "Run inspect_host(topic=\"log_check\") to see the actual error messages."
4427                        .to_string(),
4428                );
4429            }
4430        }
4431    }
4432
4433    #[cfg(not(target_os = "windows"))]
4434    {
4435        if let Ok(out) = Command::new("journalctl")
4436            .args(["-p", "3", "-n", "1", "--no-pager", "--quiet"])
4437            .output()
4438        {
4439            let text = String::from_utf8_lossy(&out.stdout);
4440            if !text.trim().is_empty() {
4441                watch.push("Critical/error entries found in the system journal.".to_string());
4442                tips.push(
4443                    "Run inspect_host(topic=\"log_check\") to see recent errors.".to_string(),
4444                );
4445            }
4446        }
4447    }
4448}
4449
4450fn health_check_network(
4451    needs_fix: &mut Vec<String>,
4452    watch: &mut Vec<String>,
4453    good: &mut Vec<String>,
4454) {
4455    #[cfg(target_os = "windows")]
4456    {
4457        // Use .NET Ping directly — PS5.1 compatible, 2-second timeout.
4458        let script = r#"try {
4459    $ping = New-Object System.Net.NetworkInformation.Ping
4460    $r = $ping.Send("1.1.1.1", 2000)
4461    if ($r.Status -eq 'Success') { "OK|$($r.RoundtripTime)" } else { "FAIL" }
4462} catch { "FAIL" }"#;
4463        if let Ok(out) = Command::new("powershell")
4464            .args(["-NoProfile", "-Command", script])
4465            .output()
4466        {
4467            let text = String::from_utf8_lossy(&out.stdout);
4468            let text = text.trim();
4469            if text.starts_with("OK") {
4470                let latency = text.split('|').nth(1).unwrap_or("?");
4471                let latency_ms: u64 = latency.parse().unwrap_or(0);
4472                let msg = format!("Internet connectivity: reachable ({}ms RTT)", latency_ms);
4473                if latency_ms > 300 {
4474                    watch.push(format!("{msg} — high latency, may indicate network issue."));
4475                } else {
4476                    good.push(msg);
4477                }
4478            } else {
4479                needs_fix.push(
4480                    "Internet connectivity: unreachable — could not ping 1.1.1.1. \
4481                     Check adapter, gateway, or DNS."
4482                        .to_string(),
4483                );
4484            }
4485            return;
4486        }
4487        watch.push("Network: could not run connectivity check.".to_string());
4488    }
4489
4490    #[cfg(not(target_os = "windows"))]
4491    {
4492        let _ = watch;
4493        let ok = Command::new("ping")
4494            .args(["-c", "1", "-W", "2", "1.1.1.1"])
4495            .stdout(std::process::Stdio::null())
4496            .stderr(std::process::Stdio::null())
4497            .status()
4498            .map(|s| s.success())
4499            .unwrap_or(false);
4500        if ok {
4501            good.push("Internet connectivity: reachable.".to_string());
4502        } else {
4503            needs_fix.push("Internet connectivity: unreachable — cannot ping 1.1.1.1.".to_string());
4504        }
4505    }
4506}
4507
4508fn health_check_pending_reboot(watch: &mut Vec<String>, good: &mut Vec<String>) {
4509    #[cfg(target_os = "windows")]
4510    {
4511        let script = r#"try {
4512    $pending = $false
4513    $reasons = @()
4514    if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending' -ErrorAction SilentlyContinue) {
4515        $pending = $true; $reasons += 'CBS/component update'
4516    }
4517    if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired' -ErrorAction SilentlyContinue) {
4518        $pending = $true; $reasons += 'Windows Update'
4519    }
4520    $pfr = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue)
4521    if ($pfr -and $pfr.PendingFileRenameOperations) {
4522        $pending = $true; $reasons += 'file rename ops'
4523    }
4524    if ($pending) { "PENDING|$($reasons -join ',')" } else { "OK" }
4525} catch { "OK" }"#;
4526        if let Ok(out) = Command::new("powershell")
4527            .args(["-NoProfile", "-Command", script])
4528            .output()
4529        {
4530            let text = String::from_utf8_lossy(&out.stdout);
4531            let text = text.trim();
4532            if text.starts_with("PENDING") {
4533                let reasons = text.split('|').nth(1).unwrap_or("unknown reason");
4534                watch.push(format!(
4535                    "Pending reboot required ({reasons}) — restart when convenient to apply changes."
4536                ));
4537            } else {
4538                good.push("No pending reboot.".to_string());
4539            }
4540        }
4541    }
4542
4543    #[cfg(not(target_os = "windows"))]
4544    {
4545        // Linux: check if a kernel update is pending (requires reboot to take effect)
4546        if std::path::Path::new("/var/run/reboot-required").exists() {
4547            watch.push(
4548                "Pending reboot required — a kernel or package update needs a restart.".to_string(),
4549            );
4550        } else {
4551            good.push("No pending reboot.".to_string());
4552        }
4553    }
4554}
4555
4556fn health_check_services(
4557    needs_fix: &mut Vec<String>,
4558    watch: &mut Vec<String>,
4559    good: &mut Vec<String>,
4560) {
4561    #[cfg(not(target_os = "windows"))]
4562    let _ = (&needs_fix, &good);
4563    #[cfg(target_os = "windows")]
4564    let _ = &watch;
4565
4566    #[cfg(target_os = "windows")]
4567    {
4568        // Only checks services whose being stopped indicates a real system problem.
4569        let script = r#"try {
4570    $names = @('EventLog','WinDefend','Dnscache')
4571    $stopped = @()
4572    foreach ($n in $names) {
4573        $s = Get-Service $n -ErrorAction SilentlyContinue
4574        if ($s -and $s.Status -ne 'Running') { $stopped += $n }
4575    }
4576    if ($stopped.Count -gt 0) { "STOPPED|$($stopped -join ',')" } else { "OK" }
4577} catch { "OK" }"#;
4578        if let Ok(out) = Command::new("powershell")
4579            .args(["-NoProfile", "-Command", script])
4580            .output()
4581        {
4582            let text = String::from_utf8_lossy(&out.stdout);
4583            let text = text.trim();
4584            if text.starts_with("STOPPED") {
4585                let names = text.split('|').nth(1).unwrap_or("unknown");
4586                needs_fix.push(format!(
4587                    "Critical service(s) not running: {names} — these should always be active."
4588                ));
4589            } else {
4590                good.push("Core services (Event Log, Defender, DNS) all running.".to_string());
4591            }
4592        }
4593    }
4594
4595    #[cfg(not(target_os = "windows"))]
4596    {
4597        // Linux: check systemd failed units
4598        if let Ok(out) = Command::new("systemctl")
4599            .args(["--failed", "--no-legend", "--plain"])
4600            .output()
4601        {
4602            let text = String::from_utf8_lossy(&out.stdout);
4603            let failed: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
4604            if !failed.is_empty() {
4605                watch.push(format!(
4606                    "{} failed systemd unit(s): {}",
4607                    failed.len(),
4608                    failed.join(", ")
4609                ));
4610            } else {
4611                good.push("No failed systemd units.".to_string());
4612            }
4613        }
4614    }
4615}
4616
4617fn health_check_thermal(watch: &mut Vec<String>, good: &mut Vec<String>) {
4618    #[cfg(target_os = "windows")]
4619    {
4620        // WMI thermal zones — best-effort, silently skip if unavailable or requires elevation.
4621        let script = r#"try {
4622    $zones = Get-WmiObject -Namespace "root/wmi" -Class MSAcpi_ThermalZoneTemperature -ErrorAction Stop
4623    $temps = $zones | ForEach-Object { [math]::Round(($_.CurrentTemperature - 2732) / 10, 1) }
4624    $max = ($temps | Measure-Object -Maximum).Maximum
4625    "$max"
4626} catch { "NA" }"#;
4627        if let Ok(out) = Command::new("powershell")
4628            .args(["-NoProfile", "-Command", script])
4629            .output()
4630        {
4631            let text = String::from_utf8_lossy(&out.stdout);
4632            let text = text.trim();
4633            if text != "NA" && !text.is_empty() {
4634                if let Ok(temp) = text.parse::<f64>() {
4635                    let msg = format!("CPU thermal: {temp:.0}°C peak zone temperature");
4636                    if temp >= 90.0 {
4637                        watch.push(format!("{msg} — very high, check cooling and airflow."));
4638                    } else if temp >= 75.0 {
4639                        watch.push(format!(
4640                            "{msg} — elevated under load, monitor for throttling."
4641                        ));
4642                    } else {
4643                        good.push(format!("{msg} — normal."));
4644                    }
4645                }
4646            }
4647            // If NA or unparseable, skip silently — thermal WMI often needs admin.
4648        }
4649    }
4650
4651    #[cfg(not(target_os = "windows"))]
4652    {
4653        // Linux: read first available hwmon temp input
4654        let paths = [
4655            "/sys/class/thermal/thermal_zone0/temp",
4656            "/sys/class/hwmon/hwmon0/temp1_input",
4657        ];
4658        for path in &paths {
4659            if let Ok(content) = std::fs::read_to_string(path) {
4660                if let Ok(raw) = content.trim().parse::<u64>() {
4661                    let temp_c = raw / 1000;
4662                    let msg = format!("CPU thermal: {temp_c}°C");
4663                    if temp_c >= 90 {
4664                        watch.push(format!("{msg} — very high, check cooling."));
4665                    } else if temp_c >= 75 {
4666                        watch.push(format!("{msg} — elevated under load."));
4667                    } else {
4668                        good.push(format!("{msg} — normal."));
4669                    }
4670                    return;
4671                }
4672            }
4673        }
4674    }
4675}
4676
4677// ── log_check ─────────────────────────────────────────────────────────────────
4678
4679fn inspect_log_check(lookback_hours: Option<u32>, max_entries: usize) -> Result<String, String> {
4680    let mut out = String::from("Host inspection: log_check\n\n");
4681
4682    #[cfg(target_os = "windows")]
4683    {
4684        // Pull recent critical/error events from Windows Application and System logs.
4685        let hours = lookback_hours.unwrap_or(24);
4686        out.push_str(&format!(
4687            "Checking System/Application logs from the last {} hours...\n\n",
4688            hours
4689        ));
4690
4691        let n = max_entries.clamp(1, 50);
4692        let script = format!(
4693            r#"try {{
4694    $events = Get-WinEvent -FilterHashtable @{{LogName='Application','System'; Level=1,2,3; StartTime=(Get-Date).AddHours(-{hours})}} -MaxEvents 100 -ErrorAction SilentlyContinue
4695    if (-not $events) {{ "NO_EVENTS"; exit }}
4696    $events | Select-Object -First {n} | ForEach-Object {{
4697        $line = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + '|' + $_.LevelDisplayName + '|' + $_.ProviderName + '|' + (($_.Message -split '[\r\n]')[0].Trim())
4698        $line
4699    }}
4700}} catch {{ "ERROR:" + $_.Exception.Message }}"#,
4701            hours = hours,
4702            n = n
4703        );
4704        let output = Command::new("powershell")
4705            .args(["-NoProfile", "-Command", &script])
4706            .output()
4707            .map_err(|e| format!("log_check: failed to run PowerShell: {e}"))?;
4708
4709        let raw = String::from_utf8_lossy(&output.stdout);
4710        let text = raw.trim();
4711
4712        if text.is_empty() || text == "NO_EVENTS" {
4713            out.push_str("No critical or error events found in Application/System logs.\n");
4714            return Ok(out.trim_end().to_string());
4715        }
4716        if text.starts_with("ERROR:") {
4717            out.push_str(&format!("Warning: event log query returned: {text}\n"));
4718            return Ok(out.trim_end().to_string());
4719        }
4720
4721        let mut count = 0usize;
4722        for line in text.lines() {
4723            let parts: Vec<&str> = line.splitn(4, '|').collect();
4724            if parts.len() == 4 {
4725                let (time, level, source, msg) = (parts[0], parts[1], parts[2], parts[3]);
4726                out.push_str(&format!("[{time}] [{level}] {source}: {msg}\n"));
4727                count += 1;
4728            }
4729        }
4730        out.push_str(&format!(
4731            "\nEvents shown: {count} (critical/error from Application + System logs)\n"
4732        ));
4733    }
4734
4735    #[cfg(not(target_os = "windows"))]
4736    {
4737        let _ = lookback_hours;
4738        // Use journalctl on Linux/macOS if available.
4739        let n = max_entries.clamp(1, 50).to_string();
4740        let output = Command::new("journalctl")
4741            .args(["-p", "3", "-n", &n, "--no-pager", "--output=short-precise"])
4742            .output();
4743
4744        match output {
4745            Ok(o) if o.status.success() => {
4746                let text = String::from_utf8_lossy(&o.stdout);
4747                let trimmed = text.trim();
4748                if trimmed.is_empty() || trimmed.contains("No entries") {
4749                    out.push_str("No critical or error entries found in the system journal.\n");
4750                } else {
4751                    out.push_str(trimmed);
4752                    out.push('\n');
4753                    out.push_str("\n(source: journalctl -p 3 = critical/alert/emergency/error)\n");
4754                }
4755            }
4756            _ => {
4757                // Fallback: check /var/log/syslog or /var/log/messages
4758                let log_paths = ["/var/log/syslog", "/var/log/messages"];
4759                let mut found = false;
4760                for log_path in &log_paths {
4761                    if let Ok(content) = std::fs::read_to_string(log_path) {
4762                        let lines: Vec<&str> = content.lines().collect();
4763                        let tail: Vec<&str> = lines
4764                            .iter()
4765                            .rev()
4766                            .filter(|l| {
4767                                let l_lower = l.to_ascii_lowercase();
4768                                l_lower.contains("error") || l_lower.contains("crit")
4769                            })
4770                            .take(max_entries)
4771                            .copied()
4772                            .collect::<Vec<_>>()
4773                            .into_iter()
4774                            .rev()
4775                            .collect();
4776                        if !tail.is_empty() {
4777                            out.push_str(&format!("Source: {log_path}\n"));
4778                            for l in &tail {
4779                                out.push_str(l);
4780                                out.push('\n');
4781                            }
4782                            found = true;
4783                            break;
4784                        }
4785                    }
4786                }
4787                if !found {
4788                    out.push_str(
4789                        "journalctl not found and no readable syslog detected on this system.\n",
4790                    );
4791                }
4792            }
4793        }
4794    }
4795
4796    Ok(out.trim_end().to_string())
4797}
4798
4799// ── startup_items ─────────────────────────────────────────────────────────────
4800
4801fn inspect_startup_items(max_entries: usize) -> Result<String, String> {
4802    let mut out = String::from("Host inspection: startup_items\n\n");
4803
4804    #[cfg(target_os = "windows")]
4805    {
4806        // Query both HKLM and HKCU Run keys.
4807        let script = r#"
4808$hives = @(
4809    @{Hive='HKLM'; Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4810    @{Hive='HKCU'; Path='HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4811    @{Hive='HKLM (32-bit)'; Path='HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'}
4812)
4813foreach ($h in $hives) {
4814    try {
4815        $props = Get-ItemProperty -Path $h.Path -ErrorAction Stop
4816        $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
4817            "$($h.Hive)|$($_.Name)|$($_.Value)"
4818        }
4819    } catch {}
4820}
4821"#;
4822        let output = Command::new("powershell")
4823            .args(["-NoProfile", "-Command", script])
4824            .output()
4825            .map_err(|e| format!("startup_items: failed to run PowerShell: {e}"))?;
4826
4827        let raw = String::from_utf8_lossy(&output.stdout);
4828        let text = raw.trim();
4829
4830        let entries: Vec<(String, String, String)> = text
4831            .lines()
4832            .filter_map(|l| {
4833                let parts: Vec<&str> = l.splitn(3, '|').collect();
4834                if parts.len() == 3 {
4835                    Some((
4836                        parts[0].to_string(),
4837                        parts[1].to_string(),
4838                        parts[2].to_string(),
4839                    ))
4840                } else {
4841                    None
4842                }
4843            })
4844            .take(max_entries)
4845            .collect();
4846
4847        if entries.is_empty() {
4848            out.push_str("No startup entries found in the Windows Run registry keys.\n");
4849        } else {
4850            out.push_str("Registry run keys (programs that start with Windows):\n\n");
4851            let mut last_hive = String::new();
4852            for (hive, name, value) in &entries {
4853                if *hive != last_hive {
4854                    out.push_str(&format!("[{}]\n", hive));
4855                    last_hive = hive.clone();
4856                }
4857                // Truncate very long values (paths with many args)
4858                let display = if value.len() > 100 {
4859                    format!("{}…", &value[..100])
4860                } else {
4861                    value.clone()
4862                };
4863                out.push_str(&format!("  {name}: {display}\n"));
4864            }
4865            out.push_str(&format!("\nTotal startup entries: {}\n", entries.len()));
4866        }
4867
4868        // 3. Unified Startup Command check (Task Manager style)
4869        let unified_script = r#"Get-CimInstance Win32_StartupCommand | ForEach-Object { "  $($_.Name): $($_.Command) ($($_.Location))" }"#;
4870        if let Ok(unified_out) = Command::new("powershell")
4871            .args(["-NoProfile", "-Command", unified_script])
4872            .output()
4873        {
4874            let unified_text = String::from_utf8_lossy(&unified_out.stdout);
4875            let trimmed = unified_text.trim();
4876            if !trimmed.is_empty() {
4877                out.push_str("\n=== Unified Startup Commands (WMI) ===\n");
4878                out.push_str(trimmed);
4879                out.push('\n');
4880            }
4881        }
4882    }
4883
4884    #[cfg(not(target_os = "windows"))]
4885    {
4886        // On Linux: systemd enabled services + cron @reboot entries.
4887        let output = Command::new("systemctl")
4888            .args([
4889                "list-unit-files",
4890                "--type=service",
4891                "--state=enabled",
4892                "--no-legend",
4893                "--no-pager",
4894                "--plain",
4895            ])
4896            .output();
4897
4898        match output {
4899            Ok(o) if o.status.success() => {
4900                let text = String::from_utf8_lossy(&o.stdout);
4901                let services: Vec<&str> = text
4902                    .lines()
4903                    .filter(|l| !l.trim().is_empty())
4904                    .take(max_entries)
4905                    .collect();
4906                if services.is_empty() {
4907                    out.push_str("No enabled systemd services found.\n");
4908                } else {
4909                    out.push_str("Enabled systemd services (run at boot):\n\n");
4910                    for s in &services {
4911                        out.push_str(&format!("  {s}\n"));
4912                    }
4913                    out.push_str(&format!(
4914                        "\nShowing {} of enabled services.\n",
4915                        services.len()
4916                    ));
4917                }
4918            }
4919            _ => {
4920                out.push_str(
4921                    "systemctl not found on this system. Cannot enumerate startup services.\n",
4922                );
4923            }
4924        }
4925
4926        // Check @reboot cron entries.
4927        if let Ok(cron_out) = Command::new("crontab").args(["-l"]).output() {
4928            let cron_text = String::from_utf8_lossy(&cron_out.stdout);
4929            let reboot_entries: Vec<&str> = cron_text
4930                .lines()
4931                .filter(|l| l.trim_start().starts_with("@reboot"))
4932                .collect();
4933            if !reboot_entries.is_empty() {
4934                out.push_str("\nCron @reboot entries:\n");
4935                for e in reboot_entries {
4936                    out.push_str(&format!("  {e}\n"));
4937                }
4938            }
4939        }
4940    }
4941
4942    Ok(out.trim_end().to_string())
4943}
4944
4945fn inspect_os_config() -> Result<String, String> {
4946    let mut out = String::from("Host inspection: OS Configuration\n\n");
4947
4948    #[cfg(target_os = "windows")]
4949    {
4950        // Power Plan
4951        if let Ok(power_out) = Command::new("powercfg").args(["/getactivescheme"]).output() {
4952            let power_str = String::from_utf8_lossy(&power_out.stdout);
4953            out.push_str("=== Power Plan ===\n");
4954            out.push_str(power_str.trim());
4955            out.push_str("\n\n");
4956        }
4957
4958        // Firewall Status
4959        let fw_script =
4960            "Get-NetFirewallProfile | Format-Table -Property Name, Enabled -AutoSize | Out-String";
4961        if let Ok(fw_out) = Command::new("powershell")
4962            .args(["-NoProfile", "-Command", fw_script])
4963            .output()
4964        {
4965            let fw_str = String::from_utf8_lossy(&fw_out.stdout);
4966            out.push_str("=== Firewall Profiles ===\n");
4967            out.push_str(fw_str.trim());
4968            out.push_str("\n\n");
4969        }
4970
4971        // System Uptime
4972        let uptime_script =
4973            "(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString()";
4974        if let Ok(uptime_out) = Command::new("powershell")
4975            .args(["-NoProfile", "-Command", uptime_script])
4976            .output()
4977        {
4978            let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4979            out.push_str("=== System Uptime (Last Boot) ===\n");
4980            out.push_str(uptime_str.trim());
4981            out.push_str("\n\n");
4982        }
4983    }
4984
4985    #[cfg(not(target_os = "windows"))]
4986    {
4987        // Uptime
4988        if let Ok(uptime_out) = Command::new("uptime").args(["-p"]).output() {
4989            let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4990            out.push_str("=== System Uptime ===\n");
4991            out.push_str(uptime_str.trim());
4992            out.push_str("\n\n");
4993        }
4994
4995        // Firewall (ufw status if available)
4996        if let Ok(ufw_out) = Command::new("ufw").arg("status").output() {
4997            let ufw_str = String::from_utf8_lossy(&ufw_out.stdout);
4998            if !ufw_str.trim().is_empty() {
4999                out.push_str("=== Firewall (UFW) ===\n");
5000                out.push_str(ufw_str.trim());
5001                out.push_str("\n\n");
5002            }
5003        }
5004    }
5005    Ok(out.trim_end().to_string())
5006}
5007
5008pub async fn resolve_host_issue(args: &Value) -> Result<String, String> {
5009    let action = args
5010        .get("action")
5011        .and_then(|v| v.as_str())
5012        .ok_or_else(|| "Missing required argument: 'action'".to_string())?;
5013
5014    let target = args
5015        .get("target")
5016        .and_then(|v| v.as_str())
5017        .unwrap_or("")
5018        .trim();
5019
5020    if target.is_empty() && action != "clear_temp" {
5021        return Err("Missing required argument: 'target' for this action".to_string());
5022    }
5023
5024    match action {
5025        "install_package" => {
5026            #[cfg(target_os = "windows")]
5027            {
5028                let cmd = format!("winget install --id {} -e --accept-package-agreements --accept-source-agreements", target);
5029                match Command::new("powershell")
5030                    .args(["-NoProfile", "-Command", &cmd])
5031                    .output()
5032                {
5033                    Ok(out) => Ok(format!(
5034                        "Executed remediation (winget install):\n{}",
5035                        String::from_utf8_lossy(&out.stdout)
5036                    )),
5037                    Err(e) => Err(format!("Failed to run winget: {}", e)),
5038                }
5039            }
5040            #[cfg(not(target_os = "windows"))]
5041            {
5042                Err(
5043                    "install_package via wrapper is only supported on Windows currently (winget)"
5044                        .to_string(),
5045                )
5046            }
5047        }
5048        "restart_service" => {
5049            #[cfg(target_os = "windows")]
5050            {
5051                let cmd = format!("Restart-Service -Name {} -Force", target);
5052                match Command::new("powershell")
5053                    .args(["-NoProfile", "-Command", &cmd])
5054                    .output()
5055                {
5056                    Ok(out) => {
5057                        let err_str = String::from_utf8_lossy(&out.stderr);
5058                        if !err_str.is_empty() {
5059                            return Err(format!("Error restarting service:\n{}", err_str));
5060                        }
5061                        Ok(format!("Successfully restarted service: {}", target))
5062                    }
5063                    Err(e) => Err(format!("Failed to restart service: {}", e)),
5064                }
5065            }
5066            #[cfg(not(target_os = "windows"))]
5067            {
5068                Err(
5069                    "restart_service via wrapper is only supported on Windows currently"
5070                        .to_string(),
5071                )
5072            }
5073        }
5074        "clear_temp" => {
5075            #[cfg(target_os = "windows")]
5076            {
5077                let cmd = "Remove-Item -Path \"$env:TEMP\\*\" -Recurse -Force -ErrorAction SilentlyContinue";
5078                match Command::new("powershell")
5079                    .args(["-NoProfile", "-Command", cmd])
5080                    .output()
5081                {
5082                    Ok(_) => Ok("Successfully cleared temporary files".to_string()),
5083                    Err(e) => Err(format!("Failed to clear temp: {}", e)),
5084                }
5085            }
5086            #[cfg(not(target_os = "windows"))]
5087            {
5088                Err("clear_temp via wrapper is only supported on Windows currently".to_string())
5089            }
5090        }
5091        other => Err(format!("Unknown remediation action: {}", other)),
5092    }
5093}
5094
5095// ── storage ───────────────────────────────────────────────────────────────────
5096
5097fn inspect_storage(max_entries: usize) -> Result<String, String> {
5098    let mut out = String::from("Host inspection: storage\n\n");
5099    let _ = max_entries; // used by non-Windows branch
5100
5101    // ── Drive overview ────────────────────────────────────────────────────────
5102    out.push_str("Drives:\n");
5103
5104    #[cfg(target_os = "windows")]
5105    {
5106        let script = r#"Get-PSDrive -PSProvider 'FileSystem' | ForEach-Object {
5107    $free = $_.Free
5108    $used = $_.Used
5109    if ($free -eq $null) { $free = 0 }
5110    if ($used -eq $null) { $used = 0 }
5111    $total = $free + $used
5112    "$($_.Name)|$free|$used|$total"
5113}"#;
5114        match Command::new("powershell")
5115            .args(["-NoProfile", "-Command", script])
5116            .output()
5117        {
5118            Ok(o) => {
5119                let text = String::from_utf8_lossy(&o.stdout);
5120                let mut drive_count = 0usize;
5121                for line in text.lines() {
5122                    let parts: Vec<&str> = line.trim().split('|').collect();
5123                    if parts.len() == 4 {
5124                        let name = parts[0];
5125                        let free: u64 = parts[1].parse().unwrap_or(0);
5126                        let total: u64 = parts[3].parse().unwrap_or(0);
5127                        if total == 0 {
5128                            continue;
5129                        }
5130                        let free_gb = free / 1_073_741_824;
5131                        let total_gb = total / 1_073_741_824;
5132                        let used_pct = ((total - free) as f64 / total as f64 * 100.0) as u64;
5133                        let bar_len = 20usize;
5134                        let filled = (used_pct as usize * bar_len / 100).min(bar_len);
5135                        let bar: String = "#".repeat(filled) + &".".repeat(bar_len - filled);
5136                        let warn = if free_gb < 5 {
5137                            " [!] CRITICALLY LOW"
5138                        } else if free_gb < 15 {
5139                            " [-] LOW"
5140                        } else {
5141                            ""
5142                        };
5143                        out.push_str(&format!(
5144                            "  {name}:  [{bar}] {used_pct}% used — {free_gb} GB free of {total_gb} GB{warn}\n"
5145                        ));
5146                        drive_count += 1;
5147                    }
5148                }
5149                if drive_count == 0 {
5150                    out.push_str("  (could not enumerate drives)\n");
5151                }
5152            }
5153            Err(e) => out.push_str(&format!("  (drive scan failed: {e})\n")),
5154        }
5155
5156        // ── Real-time Performance (Latency) ──────────────────────────────────
5157        let latency_script = "Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter \"Name='_Total'\" | Select-Object -ExpandProperty AvgDiskQueueLength";
5158        match Command::new("powershell")
5159            .args(["-NoProfile", "-Command", latency_script])
5160            .output()
5161        {
5162            Ok(o) => {
5163                out.push_str("\nReal-time Disk Intensity:\n");
5164                let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5165                if !text.is_empty() {
5166                    out.push_str(&format!("  Average Disk Queue Length: {text}\n"));
5167                    if let Ok(q) = text.parse::<f64>() {
5168                        if q > 2.0 {
5169                            out.push_str(
5170                                "  [!] WARNING: High disk latency detected (Queue Length > 2.0)\n",
5171                            );
5172                        } else {
5173                            out.push_str("  [~] Disk latency is within healthy bounds.\n");
5174                        }
5175                    }
5176                } else {
5177                    out.push_str("  Average Disk Queue Length: unavailable\n");
5178                }
5179            }
5180            Err(_) => {
5181                out.push_str("\nReal-time Disk Intensity:\n");
5182                out.push_str("  Average Disk Queue Length: unavailable\n");
5183            }
5184        }
5185    }
5186
5187    #[cfg(not(target_os = "windows"))]
5188    {
5189        match Command::new("df")
5190            .args(["-h", "--output=target,size,avail,pcent"])
5191            .output()
5192        {
5193            Ok(o) => {
5194                let text = String::from_utf8_lossy(&o.stdout);
5195                let mut count = 0usize;
5196                for line in text.lines().skip(1) {
5197                    let cols: Vec<&str> = line.split_whitespace().collect();
5198                    if cols.len() >= 4 && !cols[0].starts_with("tmpfs") {
5199                        out.push_str(&format!(
5200                            "  {}  size: {}  avail: {}  used: {}\n",
5201                            cols[0], cols[1], cols[2], cols[3]
5202                        ));
5203                        count += 1;
5204                        if count >= max_entries {
5205                            break;
5206                        }
5207                    }
5208                }
5209            }
5210            Err(e) => out.push_str(&format!("  (df failed: {e})\n")),
5211        }
5212    }
5213
5214    // ── Large developer cache directories ─────────────────────────────────────
5215    out.push_str("\nLarge developer cache directories (if present):\n");
5216
5217    #[cfg(target_os = "windows")]
5218    {
5219        let home = std::env::var("USERPROFILE").unwrap_or_default();
5220        let check_dirs: &[(&str, &str)] = &[
5221            ("Temp", r"AppData\Local\Temp"),
5222            ("npm cache", r"AppData\Roaming\npm-cache"),
5223            ("Cargo registry", r".cargo\registry"),
5224            ("Cargo git", r".cargo\git"),
5225            ("pip cache", r"AppData\Local\pip\cache"),
5226            ("Yarn cache", r"AppData\Local\Yarn\Cache"),
5227            (".rustup toolchains", r".rustup\toolchains"),
5228            ("node_modules (home)", r"node_modules"),
5229        ];
5230
5231        let mut found_any = false;
5232        for (label, rel) in check_dirs {
5233            let full = format!(r"{}\{}", home, rel);
5234            let path = std::path::Path::new(&full);
5235            if path.exists() {
5236                // Quick size estimate via PowerShell (non-blocking cap at 5s)
5237                let size_script = format!(
5238                    r#"try {{ $s = (Get-ChildItem -Path '{}' -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum; [math]::Round($s/1MB,0) }} catch {{ '?' }}"#,
5239                    full.replace('\'', "''")
5240                );
5241                let size_mb = Command::new("powershell")
5242                    .args(["-NoProfile", "-Command", &size_script])
5243                    .output()
5244                    .ok()
5245                    .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
5246                    .unwrap_or_else(|| "?".to_string());
5247                out.push_str(&format!("  {label}: {size_mb} MB  ({full})\n"));
5248                found_any = true;
5249            }
5250        }
5251        if !found_any {
5252            out.push_str("  (none of the common cache directories found)\n");
5253        }
5254
5255        out.push_str("\nTip: to reclaim space, run inspect_host(topic=\"fix_plan\", issue=\"free up disk space\")\n");
5256    }
5257
5258    #[cfg(not(target_os = "windows"))]
5259    {
5260        let home = std::env::var("HOME").unwrap_or_default();
5261        let check_dirs: &[(&str, &str)] = &[
5262            ("npm cache", ".npm"),
5263            ("Cargo registry", ".cargo/registry"),
5264            ("pip cache", ".cache/pip"),
5265            (".rustup toolchains", ".rustup/toolchains"),
5266            ("Yarn cache", ".cache/yarn"),
5267        ];
5268        let mut found_any = false;
5269        for (label, rel) in check_dirs {
5270            let full = format!("{}/{}", home, rel);
5271            if std::path::Path::new(&full).exists() {
5272                let size = Command::new("du")
5273                    .args(["-sh", &full])
5274                    .output()
5275                    .ok()
5276                    .map(|o| {
5277                        let s = String::from_utf8_lossy(&o.stdout);
5278                        s.split_whitespace().next().unwrap_or("?").to_string()
5279                    })
5280                    .unwrap_or_else(|| "?".to_string());
5281                out.push_str(&format!("  {label}: {size}  ({full})\n"));
5282                found_any = true;
5283            }
5284        }
5285        if !found_any {
5286            out.push_str("  (none of the common cache directories found)\n");
5287        }
5288    }
5289
5290    Ok(out.trim_end().to_string())
5291}
5292
5293// ── hardware ──────────────────────────────────────────────────────────────────
5294
5295fn inspect_hardware() -> Result<String, String> {
5296    let mut out = String::from("Host inspection: hardware\n\n");
5297
5298    #[cfg(target_os = "windows")]
5299    {
5300        // CPU
5301        let cpu_script = r#"Get-CimInstance Win32_Processor | ForEach-Object {
5302    "$($_.Name.Trim())|$($_.NumberOfCores)|$($_.NumberOfLogicalProcessors)|$([math]::Round($_.MaxClockSpeed/1000,1))"
5303} | Select-Object -First 1"#;
5304        if let Ok(o) = Command::new("powershell")
5305            .args(["-NoProfile", "-Command", cpu_script])
5306            .output()
5307        {
5308            let text = String::from_utf8_lossy(&o.stdout);
5309            let text = text.trim();
5310            let parts: Vec<&str> = text.split('|').collect();
5311            if parts.len() == 4 {
5312                out.push_str(&format!(
5313                    "CPU: {}\n  {} physical cores, {} logical processors, {:.1} GHz\n\n",
5314                    parts[0],
5315                    parts[1],
5316                    parts[2],
5317                    parts[3].parse::<f32>().unwrap_or(0.0)
5318                ));
5319            } else {
5320                out.push_str(&format!("CPU: {text}\n\n"));
5321            }
5322        }
5323
5324        // RAM (total installed + speed)
5325        let ram_script = r#"$sticks = Get-CimInstance Win32_PhysicalMemory
5326$total = ($sticks | Measure-Object Capacity -Sum).Sum / 1GB
5327$speed = ($sticks | Select-Object -First 1).Speed
5328"$([math]::Round($total,0)) GB @ $($speed) MHz ($($sticks.Count) stick(s))""#;
5329        if let Ok(o) = Command::new("powershell")
5330            .args(["-NoProfile", "-Command", ram_script])
5331            .output()
5332        {
5333            let text = String::from_utf8_lossy(&o.stdout);
5334            out.push_str(&format!("RAM: {}\n\n", text.trim().trim_matches('"')));
5335        }
5336
5337        // GPU(s)
5338        let gpu_script = r#"Get-CimInstance Win32_VideoController | ForEach-Object {
5339    "$($_.Name)|$($_.DriverVersion)|$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
5340}"#;
5341        if let Ok(o) = Command::new("powershell")
5342            .args(["-NoProfile", "-Command", gpu_script])
5343            .output()
5344        {
5345            let text = String::from_utf8_lossy(&o.stdout);
5346            let lines: Vec<&str> = text.lines().collect();
5347            if !lines.is_empty() {
5348                out.push_str("GPU(s):\n");
5349                for line in lines.iter().filter(|l| !l.trim().is_empty()) {
5350                    let parts: Vec<&str> = line.trim().split('|').collect();
5351                    if parts.len() == 3 {
5352                        let res = if parts[2] == "x" || parts[2].starts_with('0') {
5353                            String::new()
5354                        } else {
5355                            format!(" — {}@display", parts[2])
5356                        };
5357                        out.push_str(&format!(
5358                            "  {}\n    Driver: {}{}\n",
5359                            parts[0], parts[1], res
5360                        ));
5361                    } else {
5362                        out.push_str(&format!("  {}\n", line.trim()));
5363                    }
5364                }
5365                out.push('\n');
5366            }
5367        }
5368
5369        // Motherboard + BIOS + Virtualization
5370        let mb_script = r#"$mb = Get-CimInstance Win32_BaseBoard
5371$bios = Get-CimInstance Win32_BIOS
5372$cs = Get-CimInstance Win32_ComputerSystem
5373$proc = Get-CimInstance Win32_Processor | Select-Object -First 1
5374$virt = "Hypervisor: $($cs.HypervisorPresent)|SLAT: $($proc.SecondLevelAddressTranslationExtensions)"
5375"$($mb.Manufacturer.Trim()) $($mb.Product.Trim())|BIOS: $($bios.Manufacturer.Trim()) $($bios.SMBIOSBIOSVersion.Trim()) ($($bios.ReleaseDate))|$virt""#;
5376        if let Ok(o) = Command::new("powershell")
5377            .args(["-NoProfile", "-Command", mb_script])
5378            .output()
5379        {
5380            let text = String::from_utf8_lossy(&o.stdout);
5381            let text = text.trim().trim_matches('"');
5382            let parts: Vec<&str> = text.split('|').collect();
5383            if parts.len() == 4 {
5384                out.push_str(&format!(
5385                    "Motherboard: {}\n{}\nVirtualization: {}, {}\n\n",
5386                    parts[0].trim(),
5387                    parts[1].trim(),
5388                    parts[2].trim(),
5389                    parts[3].trim()
5390                ));
5391            }
5392        }
5393
5394        // Display(s)
5395        let disp_script = r#"Get-CimInstance Win32_DesktopMonitor | Where-Object {$_.ScreenWidth -gt 0} | ForEach-Object {
5396    "$($_.Name)|$($_.ScreenWidth)x$($_.ScreenHeight)"
5397}"#;
5398        if let Ok(o) = Command::new("powershell")
5399            .args(["-NoProfile", "-Command", disp_script])
5400            .output()
5401        {
5402            let text = String::from_utf8_lossy(&o.stdout);
5403            let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
5404            if !lines.is_empty() {
5405                out.push_str("Display(s):\n");
5406                for line in &lines {
5407                    let parts: Vec<&str> = line.trim().split('|').collect();
5408                    if parts.len() == 2 {
5409                        out.push_str(&format!("  {} — {}\n", parts[0].trim(), parts[1]));
5410                    }
5411                }
5412            }
5413        }
5414    }
5415
5416    #[cfg(not(target_os = "windows"))]
5417    {
5418        // CPU via /proc/cpuinfo
5419        if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
5420            let model = content
5421                .lines()
5422                .find(|l| l.starts_with("model name"))
5423                .and_then(|l| l.split(':').nth(1))
5424                .map(str::trim)
5425                .unwrap_or("unknown");
5426            let cores = content
5427                .lines()
5428                .filter(|l| l.starts_with("processor"))
5429                .count();
5430            out.push_str(&format!("CPU: {model}\n  {cores} logical processors\n\n"));
5431        }
5432
5433        // RAM
5434        if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
5435            let total_kb: u64 = content
5436                .lines()
5437                .find(|l| l.starts_with("MemTotal:"))
5438                .and_then(|l| l.split_whitespace().nth(1))
5439                .and_then(|v| v.parse().ok())
5440                .unwrap_or(0);
5441            let total_gb = total_kb / 1_048_576;
5442            out.push_str(&format!("RAM: {total_gb} GB total\n\n"));
5443        }
5444
5445        // GPU via lspci
5446        if let Ok(o) = Command::new("lspci").args(["-vmm"]).output() {
5447            let text = String::from_utf8_lossy(&o.stdout);
5448            let gpu_lines: Vec<&str> = text
5449                .lines()
5450                .filter(|l| l.contains("VGA") || l.contains("Display") || l.contains("3D"))
5451                .collect();
5452            if !gpu_lines.is_empty() {
5453                out.push_str("GPU(s):\n");
5454                for l in gpu_lines {
5455                    out.push_str(&format!("  {l}\n"));
5456                }
5457                out.push('\n');
5458            }
5459        }
5460
5461        // DMI/BIOS info
5462        if let Ok(o) = Command::new("dmidecode")
5463            .args(["-t", "baseboard", "-t", "bios"])
5464            .output()
5465        {
5466            let text = String::from_utf8_lossy(&o.stdout);
5467            out.push_str("Motherboard/BIOS:\n");
5468            for line in text
5469                .lines()
5470                .filter(|l| {
5471                    l.contains("Manufacturer:")
5472                        || l.contains("Product Name:")
5473                        || l.contains("Version:")
5474                })
5475                .take(6)
5476            {
5477                out.push_str(&format!("  {}\n", line.trim()));
5478            }
5479        }
5480    }
5481
5482    Ok(out.trim_end().to_string())
5483}
5484
5485// ── updates ───────────────────────────────────────────────────────────────────
5486
5487fn inspect_updates() -> Result<String, String> {
5488    let mut out = String::from("Host inspection: updates\n\n");
5489
5490    #[cfg(target_os = "windows")]
5491    {
5492        // Last installed update via COM
5493        let script = r#"
5494try {
5495    $sess = New-Object -ComObject Microsoft.Update.Session
5496    $searcher = $sess.CreateUpdateSearcher()
5497    $count = $searcher.GetTotalHistoryCount()
5498    if ($count -gt 0) {
5499        $latest = $searcher.QueryHistory(0, 1) | Select-Object -First 1
5500        $latest.Date.ToString("yyyy-MM-dd HH:mm") + "|LAST_INSTALL"
5501    } else { "NONE|LAST_INSTALL" }
5502} catch { "ERROR:" + $_.Exception.Message + "|LAST_INSTALL" }
5503"#;
5504        if let Ok(o) = Command::new("powershell")
5505            .args(["-NoProfile", "-Command", script])
5506            .output()
5507        {
5508            let raw = String::from_utf8_lossy(&o.stdout);
5509            let text = raw.trim();
5510            if text.starts_with("ERROR:") {
5511                out.push_str("Last update install: (unable to query)\n");
5512            } else if text.contains("NONE") {
5513                out.push_str("Last update install: No update history found\n");
5514            } else {
5515                let date = text.replace("|LAST_INSTALL", "");
5516                out.push_str(&format!("Last update install: {date}\n"));
5517            }
5518        }
5519
5520        // Pending updates count
5521        let pending_script = r#"
5522try {
5523    $sess = New-Object -ComObject Microsoft.Update.Session
5524    $searcher = $sess.CreateUpdateSearcher()
5525    $results = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
5526    $results.Updates.Count.ToString() + "|PENDING"
5527} catch { "ERROR:" + $_.Exception.Message + "|PENDING" }
5528"#;
5529        if let Ok(o) = Command::new("powershell")
5530            .args(["-NoProfile", "-Command", pending_script])
5531            .output()
5532        {
5533            let raw = String::from_utf8_lossy(&o.stdout);
5534            let text = raw.trim();
5535            if text.starts_with("ERROR:") {
5536                out.push_str("Pending updates: (unable to query via COM — try opening Windows Update manually)\n");
5537            } else {
5538                let count: i64 = text.replace("|PENDING", "").trim().parse().unwrap_or(-1);
5539                if count == 0 {
5540                    out.push_str("Pending updates: Up to date — no updates waiting\n");
5541                } else if count > 0 {
5542                    out.push_str(&format!("Pending updates: {count} update(s) available\n"));
5543                    out.push_str(
5544                        "  → Open Windows Update (Settings > Windows Update) to install\n",
5545                    );
5546                }
5547            }
5548        }
5549
5550        // Windows Update service state
5551        let svc_script = r#"
5552$svc = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
5553if ($svc) { $svc.Status.ToString() } else { "NOT_FOUND" }
5554"#;
5555        if let Ok(o) = Command::new("powershell")
5556            .args(["-NoProfile", "-Command", svc_script])
5557            .output()
5558        {
5559            let raw = String::from_utf8_lossy(&o.stdout);
5560            let status = raw.trim();
5561            out.push_str(&format!("Windows Update service: {status}\n"));
5562        }
5563    }
5564
5565    #[cfg(not(target_os = "windows"))]
5566    {
5567        let apt_out = Command::new("apt").args(["list", "--upgradable"]).output();
5568        let mut found = false;
5569        if let Ok(o) = apt_out {
5570            let text = String::from_utf8_lossy(&o.stdout);
5571            let lines: Vec<&str> = text
5572                .lines()
5573                .filter(|l| l.contains('/') && !l.contains("Listing"))
5574                .collect();
5575            if !lines.is_empty() {
5576                out.push_str(&format!(
5577                    "{} package(s) can be upgraded (apt)\n",
5578                    lines.len()
5579                ));
5580                out.push_str("  → Run: sudo apt upgrade\n");
5581                found = true;
5582            }
5583        }
5584        if !found {
5585            if let Ok(o) = Command::new("dnf")
5586                .args(["check-update", "--quiet"])
5587                .output()
5588            {
5589                let text = String::from_utf8_lossy(&o.stdout);
5590                let count = text
5591                    .lines()
5592                    .filter(|l| !l.is_empty() && !l.starts_with('!'))
5593                    .count();
5594                if count > 0 {
5595                    out.push_str(&format!("{count} package(s) can be upgraded (dnf)\n"));
5596                    out.push_str("  → Run: sudo dnf upgrade\n");
5597                } else {
5598                    out.push_str("System is up to date.\n");
5599                }
5600            } else {
5601                out.push_str("Could not query package manager for updates.\n");
5602            }
5603        }
5604    }
5605
5606    Ok(out.trim_end().to_string())
5607}
5608
5609// ── security ──────────────────────────────────────────────────────────────────
5610
5611fn inspect_security() -> Result<String, String> {
5612    let mut out = String::from("Host inspection: security\n\n");
5613
5614    #[cfg(target_os = "windows")]
5615    {
5616        // Windows Defender status
5617        let defender_script = r#"
5618try {
5619    $status = Get-MpComputerStatus -ErrorAction Stop
5620    "RTP:" + $status.RealTimeProtectionEnabled + "|SCAN:" + $status.QuickScanEndTime.ToString("yyyy-MM-dd HH:mm") + "|VER:" + $status.AntivirusSignatureVersion + "|AGE:" + $status.AntivirusSignatureAge
5621} catch { "ERROR:" + $_.Exception.Message }
5622"#;
5623        if let Ok(o) = Command::new("powershell")
5624            .args(["-NoProfile", "-Command", defender_script])
5625            .output()
5626        {
5627            let raw = String::from_utf8_lossy(&o.stdout);
5628            let text = raw.trim();
5629            if text.starts_with("ERROR:") {
5630                out.push_str(&format!("Windows Defender: unable to query — {text}\n"));
5631            } else {
5632                let get = |key: &str| -> String {
5633                    text.split('|')
5634                        .find(|s| s.starts_with(key))
5635                        .and_then(|s| s.splitn(2, ':').nth(1))
5636                        .unwrap_or("unknown")
5637                        .to_string()
5638                };
5639                let rtp = get("RTP");
5640                let last_scan = {
5641                    // SCAN field has a colon in the time, so grab everything after "SCAN:"
5642                    text.split('|')
5643                        .find(|s| s.starts_with("SCAN:"))
5644                        .and_then(|s| s.get(5..))
5645                        .unwrap_or("unknown")
5646                        .to_string()
5647                };
5648                let def_ver = get("VER");
5649                let age_days: i64 = get("AGE").parse().unwrap_or(-1);
5650
5651                let rtp_label = if rtp == "True" {
5652                    "ENABLED"
5653                } else {
5654                    "DISABLED [!]"
5655                };
5656                out.push_str(&format!(
5657                    "Windows Defender real-time protection: {rtp_label}\n"
5658                ));
5659                out.push_str(&format!("Last quick scan: {last_scan}\n"));
5660                out.push_str(&format!("Signature version: {def_ver}\n"));
5661                if age_days >= 0 {
5662                    let freshness = if age_days == 0 {
5663                        "up to date".to_string()
5664                    } else if age_days <= 3 {
5665                        format!("{age_days} day(s) old — OK")
5666                    } else if age_days <= 7 {
5667                        format!("{age_days} day(s) old — consider updating")
5668                    } else {
5669                        format!("{age_days} day(s) old — [!] STALE, run Windows Update")
5670                    };
5671                    out.push_str(&format!("Signature age: {freshness}\n"));
5672                }
5673                if rtp != "True" {
5674                    out.push_str(
5675                        "\n[!] Real-time protection is OFF — your PC is not actively protected.\n",
5676                    );
5677                    out.push_str(
5678                        "    → Open Windows Security > Virus & threat protection to re-enable.\n",
5679                    );
5680                }
5681            }
5682        }
5683
5684        out.push('\n');
5685
5686        // Windows Firewall state
5687        let fw_script = r#"
5688try {
5689    Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object { $_.Name + ":" + $_.Enabled }
5690} catch { "ERROR:" + $_.Exception.Message }
5691"#;
5692        if let Ok(o) = Command::new("powershell")
5693            .args(["-NoProfile", "-Command", fw_script])
5694            .output()
5695        {
5696            let raw = String::from_utf8_lossy(&o.stdout);
5697            let text = raw.trim();
5698            if !text.starts_with("ERROR:") && !text.is_empty() {
5699                out.push_str("Windows Firewall:\n");
5700                for line in text.lines() {
5701                    if let Some((name, enabled)) = line.split_once(':') {
5702                        let state = if enabled.trim() == "True" {
5703                            "ON"
5704                        } else {
5705                            "OFF [!]"
5706                        };
5707                        out.push_str(&format!("  {name}: {state}\n"));
5708                    }
5709                }
5710                out.push('\n');
5711            }
5712        }
5713
5714        // Windows activation status
5715        let act_script = r#"
5716try {
5717    $lic = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%' and LicenseStatus=1" -ErrorAction Stop | Select-Object -First 1
5718    if ($lic) { "ACTIVATED" } else { "NOT_ACTIVATED" }
5719} catch { "UNKNOWN" }
5720"#;
5721        if let Ok(o) = Command::new("powershell")
5722            .args(["-NoProfile", "-Command", act_script])
5723            .output()
5724        {
5725            let raw = String::from_utf8_lossy(&o.stdout);
5726            match raw.trim() {
5727                "ACTIVATED" => out.push_str("Windows activation: Activated\n"),
5728                "NOT_ACTIVATED" => out.push_str("Windows activation: [!] NOT ACTIVATED\n"),
5729                _ => out.push_str("Windows activation: Unable to determine\n"),
5730            }
5731        }
5732
5733        // UAC state
5734        let uac_script = r#"
5735$val = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue
5736if ($val -eq 1) { "ON" } else { "OFF" }
5737"#;
5738        if let Ok(o) = Command::new("powershell")
5739            .args(["-NoProfile", "-Command", uac_script])
5740            .output()
5741        {
5742            let raw = String::from_utf8_lossy(&o.stdout);
5743            let state = raw.trim();
5744            let label = if state == "ON" {
5745                "Enabled"
5746            } else {
5747                "DISABLED [!] — recommended to re-enable via secpol.msc"
5748            };
5749            out.push_str(&format!("UAC (User Account Control): {label}\n"));
5750        }
5751    }
5752
5753    #[cfg(not(target_os = "windows"))]
5754    {
5755        if let Ok(o) = Command::new("ufw").arg("status").output() {
5756            let text = String::from_utf8_lossy(&o.stdout);
5757            out.push_str(&format!(
5758                "UFW: {}\n",
5759                text.lines().next().unwrap_or("unknown")
5760            ));
5761        }
5762        if let Ok(cfg) = std::fs::read_to_string("/etc/selinux/config") {
5763            if let Some(line) = cfg.lines().find(|l| l.starts_with("SELINUX=")) {
5764                out.push_str(&format!("{line}\n"));
5765            }
5766        }
5767    }
5768
5769    Ok(out.trim_end().to_string())
5770}
5771
5772// ── pending_reboot ────────────────────────────────────────────────────────────
5773
5774fn inspect_pending_reboot() -> Result<String, String> {
5775    let mut out = String::from("Host inspection: pending_reboot\n\n");
5776
5777    #[cfg(target_os = "windows")]
5778    {
5779        let script = r#"
5780$reasons = @()
5781if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
5782    $reasons += "Windows Update requires a restart"
5783}
5784if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
5785    $reasons += "Windows component install/update requires a restart"
5786}
5787$pfro = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
5788if ($pfro -and $pfro.PendingFileRenameOperations) {
5789    $reasons += "Pending file rename operations (driver or system file replacement)"
5790}
5791if ($reasons.Count -eq 0) { "NO_REBOOT_NEEDED" } else { $reasons -join "|REASON|" }
5792"#;
5793        let output = Command::new("powershell")
5794            .args(["-NoProfile", "-Command", script])
5795            .output()
5796            .map_err(|e| format!("pending_reboot: {e}"))?;
5797
5798        let raw = String::from_utf8_lossy(&output.stdout);
5799        let text = raw.trim();
5800
5801        if text == "NO_REBOOT_NEEDED" {
5802            out.push_str("No restart required — system is up to date and stable.\n");
5803        } else if text.is_empty() {
5804            out.push_str("Could not determine reboot status.\n");
5805        } else {
5806            out.push_str("[!] A system restart is pending:\n\n");
5807            for reason in text.split("|REASON|") {
5808                out.push_str(&format!("  • {}\n", reason.trim()));
5809            }
5810            out.push_str("\nRecommendation: Save your work and restart when convenient.\n");
5811        }
5812    }
5813
5814    #[cfg(not(target_os = "windows"))]
5815    {
5816        if std::path::Path::new("/var/run/reboot-required").exists() {
5817            out.push_str("[!] A restart is required (see /var/run/reboot-required)\n");
5818            if let Ok(pkgs) = std::fs::read_to_string("/var/run/reboot-required.pkgs") {
5819                out.push_str("Packages requiring restart:\n");
5820                for p in pkgs.lines().take(10) {
5821                    out.push_str(&format!("  • {p}\n"));
5822                }
5823            }
5824        } else {
5825            out.push_str("No restart required.\n");
5826        }
5827    }
5828
5829    Ok(out.trim_end().to_string())
5830}
5831
5832// ── disk_health ───────────────────────────────────────────────────────────────
5833
5834fn inspect_disk_health() -> Result<String, String> {
5835    let mut out = String::from("Host inspection: disk_health\n\n");
5836
5837    #[cfg(target_os = "windows")]
5838    {
5839        let script = r#"
5840try {
5841    $disks = Get-PhysicalDisk -ErrorAction Stop
5842    foreach ($d in $disks) {
5843        $size_gb = [math]::Round($d.Size / 1GB, 0)
5844        $d.FriendlyName + "|" + $d.MediaType + "|" + $size_gb + "GB|" + $d.HealthStatus + "|" + $d.OperationalStatus
5845    }
5846} catch { "ERROR:" + $_.Exception.Message }
5847"#;
5848        let output = Command::new("powershell")
5849            .args(["-NoProfile", "-Command", script])
5850            .output()
5851            .map_err(|e| format!("disk_health: {e}"))?;
5852
5853        let raw = String::from_utf8_lossy(&output.stdout);
5854        let text = raw.trim();
5855
5856        if text.starts_with("ERROR:") {
5857            out.push_str(&format!("Unable to query disk health: {text}\n"));
5858            out.push_str("This may require running as administrator.\n");
5859        } else if text.is_empty() {
5860            out.push_str("No physical disks found.\n");
5861        } else {
5862            out.push_str("Physical Drive Health:\n\n");
5863            for line in text.lines() {
5864                let parts: Vec<&str> = line.splitn(5, '|').collect();
5865                if parts.len() >= 4 {
5866                    let name = parts[0];
5867                    let media = parts[1];
5868                    let size = parts[2];
5869                    let health = parts[3];
5870                    let op_status = parts.get(4).unwrap_or(&"");
5871                    let health_label = match health.trim() {
5872                        "Healthy" => "OK",
5873                        "Warning" => "[!] WARNING",
5874                        "Unhealthy" => "[!!] UNHEALTHY — BACK UP YOUR DATA NOW",
5875                        other => other,
5876                    };
5877                    out.push_str(&format!("  {name}\n"));
5878                    out.push_str(&format!("    Type: {media} | Size: {size}\n"));
5879                    out.push_str(&format!("    Health: {health_label}\n"));
5880                    if !op_status.is_empty() {
5881                        out.push_str(&format!("    Status: {op_status}\n"));
5882                    }
5883                    out.push('\n');
5884                }
5885            }
5886        }
5887
5888        // SMART failure prediction (best-effort, may need admin)
5889        let smart_script = r#"
5890try {
5891    Get-WmiObject -Class MSStorageDriver_FailurePredictStatus -Namespace root\wmi -ErrorAction Stop |
5892        ForEach-Object { $_.InstanceName + "|" + $_.PredictFailure }
5893} catch { "" }
5894"#;
5895        if let Ok(o) = Command::new("powershell")
5896            .args(["-NoProfile", "-Command", smart_script])
5897            .output()
5898        {
5899            let raw2 = String::from_utf8_lossy(&o.stdout);
5900            let text2 = raw2.trim();
5901            if !text2.is_empty() {
5902                let failures: Vec<&str> = text2.lines().filter(|l| l.contains("|True")).collect();
5903                if failures.is_empty() {
5904                    out.push_str("SMART failure prediction: No failures predicted\n");
5905                } else {
5906                    out.push_str("[!!] SMART failure predicted on one or more drives:\n");
5907                    for f in failures {
5908                        let name = f.split('|').next().unwrap_or(f);
5909                        out.push_str(&format!("  • {name}\n"));
5910                    }
5911                    out.push_str(
5912                        "\nBack up your data immediately and replace the failing drive.\n",
5913                    );
5914                }
5915            }
5916        }
5917    }
5918
5919    #[cfg(not(target_os = "windows"))]
5920    {
5921        if let Ok(o) = Command::new("lsblk")
5922            .args(["-d", "-o", "NAME,SIZE,TYPE,ROTA,MODEL"])
5923            .output()
5924        {
5925            let text = String::from_utf8_lossy(&o.stdout);
5926            out.push_str("Block devices:\n");
5927            out.push_str(text.trim());
5928            out.push('\n');
5929        }
5930        if let Ok(scan) = Command::new("smartctl").args(["--scan"]).output() {
5931            let devices = String::from_utf8_lossy(&scan.stdout);
5932            for dev_line in devices.lines().take(4) {
5933                let dev = dev_line.split_whitespace().next().unwrap_or("");
5934                if dev.is_empty() {
5935                    continue;
5936                }
5937                if let Ok(o) = Command::new("smartctl").args(["-H", dev]).output() {
5938                    let health = String::from_utf8_lossy(&o.stdout);
5939                    if let Some(line) = health.lines().find(|l| l.contains("SMART overall-health"))
5940                    {
5941                        out.push_str(&format!("{dev}: {}\n", line.trim()));
5942                    }
5943                }
5944            }
5945        } else {
5946            out.push_str("(install smartmontools for SMART health data)\n");
5947        }
5948    }
5949
5950    Ok(out.trim_end().to_string())
5951}
5952
5953// ── battery ───────────────────────────────────────────────────────────────────
5954
5955fn inspect_battery() -> Result<String, String> {
5956    let mut out = String::from("Host inspection: battery\n\n");
5957
5958    #[cfg(target_os = "windows")]
5959    {
5960        let script = r#"
5961try {
5962    $bats = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
5963    if (-not $bats) { "NO_BATTERY"; exit }
5964    
5965    # Modern Battery Health (Cycle count + Capacity health)
5966    $static = Get-CimInstance -Namespace root/WMI -ClassName BatteryStaticData -ErrorAction SilentlyContinue
5967    $full = Get-CimInstance -Namespace root/WMI -ClassName BatteryFullCapacity -ErrorAction SilentlyContinue 
5968    $status = Get-CimInstance -Namespace root/WMI -ClassName BatteryStatus -ErrorAction SilentlyContinue
5969
5970    foreach ($b in $bats) {
5971        $state = switch ($b.BatteryStatus) {
5972            1 { "Discharging" }
5973            2 { "AC Power (Fully Charged)" }
5974            3 { "AC Power (Charging)" }
5975            default { "Status $($b.BatteryStatus)" }
5976        }
5977        
5978        $cycles = if ($status) { $status.CycleCount } else { "unknown" }
5979        $health = if ($static -and $full) {
5980             [math]::Round(($full.FullChargeCapacity / $static.DesignCapacity) * 100, 1)
5981        } else { "unknown" }
5982
5983        $b.Name + "|" + $b.EstimatedChargeRemaining + "|" + $state + "|" + $cycles + "|" + $health
5984    }
5985} catch { "ERROR:" + $_.Exception.Message }
5986"#;
5987        let output = Command::new("powershell")
5988            .args(["-NoProfile", "-Command", script])
5989            .output()
5990            .map_err(|e| format!("battery: {e}"))?;
5991
5992        let raw = String::from_utf8_lossy(&output.stdout);
5993        let text = raw.trim();
5994
5995        if text == "NO_BATTERY" {
5996            out.push_str("No battery detected — desktop or AC-only system.\n");
5997            return Ok(out.trim_end().to_string());
5998        }
5999        if text.starts_with("ERROR:") {
6000            out.push_str(&format!("Unable to query battery: {text}\n"));
6001            return Ok(out.trim_end().to_string());
6002        }
6003
6004        for line in text.lines() {
6005            let parts: Vec<&str> = line.split('|').collect();
6006            if parts.len() == 5 {
6007                let name = parts[0];
6008                let charge: i64 = parts[1].parse().unwrap_or(-1);
6009                let state = parts[2];
6010                let cycles = parts[3];
6011                let health = parts[4];
6012
6013                out.push_str(&format!("Battery: {name}\n"));
6014                if charge >= 0 {
6015                    let bar_filled = (charge as usize * 20) / 100;
6016                    out.push_str(&format!(
6017                        "  Charge: [{}{}] {}%\n",
6018                        "#".repeat(bar_filled),
6019                        ".".repeat(20 - bar_filled),
6020                        charge
6021                    ));
6022                }
6023                out.push_str(&format!("  Status: {state}\n"));
6024                out.push_str(&format!("  Cycles: {cycles}\n"));
6025                out.push_str(&format!(
6026                    "  Health: {health}% (Actual vs Design Capacity)\n\n"
6027                ));
6028            }
6029        }
6030    }
6031
6032    #[cfg(not(target_os = "windows"))]
6033    {
6034        let power_path = std::path::Path::new("/sys/class/power_supply");
6035        let mut found = false;
6036        if power_path.exists() {
6037            if let Ok(entries) = std::fs::read_dir(power_path) {
6038                for entry in entries.flatten() {
6039                    let p = entry.path();
6040                    if let Ok(t) = std::fs::read_to_string(p.join("type")) {
6041                        if t.trim() == "Battery" {
6042                            found = true;
6043                            let name = p
6044                                .file_name()
6045                                .unwrap_or_default()
6046                                .to_string_lossy()
6047                                .to_string();
6048                            out.push_str(&format!("Battery: {name}\n"));
6049                            let read = |f: &str| {
6050                                std::fs::read_to_string(p.join(f))
6051                                    .ok()
6052                                    .map(|s| s.trim().to_string())
6053                            };
6054                            if let Some(cap) = read("capacity") {
6055                                out.push_str(&format!("  Charge: {cap}%\n"));
6056                            }
6057                            if let Some(status) = read("status") {
6058                                out.push_str(&format!("  Status: {status}\n"));
6059                            }
6060                            if let (Some(full), Some(design)) =
6061                                (read("energy_full"), read("energy_full_design"))
6062                            {
6063                                if let (Ok(f), Ok(d)) = (full.parse::<f64>(), design.parse::<f64>())
6064                                {
6065                                    if d > 0.0 {
6066                                        out.push_str(&format!(
6067                                            "  Wear level: {:.1}% of design capacity\n",
6068                                            (f / d) * 100.0
6069                                        ));
6070                                    }
6071                                }
6072                            }
6073                        }
6074                    }
6075                }
6076            }
6077        }
6078        if !found {
6079            out.push_str("No battery found.\n");
6080        }
6081    }
6082
6083    Ok(out.trim_end().to_string())
6084}
6085
6086// ── recent_crashes ────────────────────────────────────────────────────────────
6087
6088fn inspect_recent_crashes(max_entries: usize) -> Result<String, String> {
6089    let mut out = String::from("Host inspection: recent_crashes\n\n");
6090    let n = max_entries.clamp(1, 30);
6091
6092    #[cfg(target_os = "windows")]
6093    {
6094        // BSODs / unexpected shutdowns (EventID 41 = kernel power, 1001 = BugCheck)
6095        let bsod_script = format!(
6096            r#"
6097try {{
6098    $events = Get-WinEvent -FilterHashtable @{{LogName='System'; Id=41,1001}} -MaxEvents {n} -ErrorAction SilentlyContinue
6099    if ($events) {{
6100        $events | ForEach-Object {{
6101            $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + $_.Id + "|" + (($_.Message -split "[\r\n]")[0].Trim())
6102        }}
6103    }} else {{ "NO_BSOD" }}
6104}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6105        );
6106
6107        if let Ok(o) = Command::new("powershell")
6108            .args(["-NoProfile", "-Command", &bsod_script])
6109            .output()
6110        {
6111            let raw = String::from_utf8_lossy(&o.stdout);
6112            let text = raw.trim();
6113            if text == "NO_BSOD" {
6114                out.push_str("System crashes (BSOD/kernel): None in recent history\n");
6115            } else if text.starts_with("ERROR:") {
6116                out.push_str("System crashes: unable to query\n");
6117            } else {
6118                out.push_str("System crashes / unexpected shutdowns:\n");
6119                for line in text.lines() {
6120                    let parts: Vec<&str> = line.splitn(3, '|').collect();
6121                    if parts.len() >= 3 {
6122                        let time = parts[0];
6123                        let id = parts[1];
6124                        let msg = parts[2];
6125                        let label = if id == "41" {
6126                            "Unexpected shutdown"
6127                        } else {
6128                            "BSOD (BugCheck)"
6129                        };
6130                        out.push_str(&format!("  [{time}] {label}: {msg}\n"));
6131                    }
6132                }
6133                out.push('\n');
6134            }
6135        }
6136
6137        // Application crashes (EventID 1000 = app crash, 1002 = app hang)
6138        let app_script = format!(
6139            r#"
6140try {{
6141    $crashes = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue
6142    if ($crashes) {{
6143        $crashes | ForEach-Object {{
6144            $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + (($_.Message -split "[\r\n]")[0].Trim())
6145        }}
6146    }} else {{ "NO_CRASHES" }}
6147}} catch {{ "ERROR_APP:" + $_.Exception.Message }}"#
6148        );
6149
6150        if let Ok(o) = Command::new("powershell")
6151            .args(["-NoProfile", "-Command", &app_script])
6152            .output()
6153        {
6154            let raw = String::from_utf8_lossy(&o.stdout);
6155            let text = raw.trim();
6156            if text == "NO_CRASHES" {
6157                out.push_str("Application crashes: None in recent history\n");
6158            } else if text.starts_with("ERROR_APP:") {
6159                out.push_str("Application crashes: unable to query\n");
6160            } else {
6161                out.push_str("Application crashes:\n");
6162                for line in text.lines().take(n) {
6163                    let parts: Vec<&str> = line.splitn(2, '|').collect();
6164                    if parts.len() >= 2 {
6165                        out.push_str(&format!("  [{}] {}\n", parts[0], parts[1]));
6166                    }
6167                }
6168            }
6169        }
6170    }
6171
6172    #[cfg(not(target_os = "windows"))]
6173    {
6174        let n_str = n.to_string();
6175        if let Ok(o) = Command::new("journalctl")
6176            .args(["-k", "--no-pager", "-n", &n_str, "-p", "0..2"])
6177            .output()
6178        {
6179            let text = String::from_utf8_lossy(&o.stdout);
6180            let trimmed = text.trim();
6181            if trimmed.is_empty() || trimmed.contains("No entries") {
6182                out.push_str("No kernel panics or critical crashes found.\n");
6183            } else {
6184                out.push_str("Kernel critical events:\n");
6185                out.push_str(trimmed);
6186                out.push('\n');
6187            }
6188        }
6189        if let Ok(o) = Command::new("coredumpctl")
6190            .args(["list", "--no-pager"])
6191            .output()
6192        {
6193            let text = String::from_utf8_lossy(&o.stdout);
6194            let count = text
6195                .lines()
6196                .filter(|l| !l.trim().is_empty() && !l.starts_with("TIME"))
6197                .count();
6198            if count > 0 {
6199                out.push_str(&format!(
6200                    "\nCore dumps on file: {count}\n  → Run: coredumpctl list\n"
6201                ));
6202            }
6203        }
6204    }
6205
6206    Ok(out.trim_end().to_string())
6207}
6208
6209// ── scheduled_tasks ───────────────────────────────────────────────────────────
6210
6211fn inspect_scheduled_tasks(max_entries: usize) -> Result<String, String> {
6212    let mut out = String::from("Host inspection: scheduled_tasks\n\n");
6213    let n = max_entries.clamp(1, 30);
6214
6215    #[cfg(target_os = "windows")]
6216    {
6217        let script = format!(
6218            r#"
6219try {{
6220    $tasks = Get-ScheduledTask -ErrorAction Stop |
6221        Where-Object {{ $_.State -ne 'Disabled' }} |
6222        ForEach-Object {{
6223            $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
6224            $lastRun = if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {{
6225                $info.LastRunTime.ToString("yyyy-MM-dd HH:mm")
6226            }} else {{ "never" }}
6227            $res = if ($info) {{ "0x{{0:x}}" -f $info.LastTaskResult }} else {{ "---" }}
6228            $exec = ($_.Actions | Select-Object -First 1).Execute
6229            if (-not $exec) {{ $exec = "(no exec)" }}
6230            $_.TaskName + "|" + $_.TaskPath + "|" + $_.State + "|" + $lastRun + "|" + $res + "|" + $exec
6231        }}
6232    $tasks | Select-Object -First {n}
6233}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6234        );
6235
6236        let output = Command::new("powershell")
6237            .args(["-NoProfile", "-Command", &script])
6238            .output()
6239            .map_err(|e| format!("scheduled_tasks: {e}"))?;
6240
6241        let raw = String::from_utf8_lossy(&output.stdout);
6242        let text = raw.trim();
6243
6244        if text.starts_with("ERROR:") {
6245            out.push_str(&format!("Unable to query scheduled tasks: {text}\n"));
6246        } else if text.is_empty() {
6247            out.push_str("No active scheduled tasks found.\n");
6248        } else {
6249            out.push_str(&format!("Active scheduled tasks (up to {n}):\n\n"));
6250            for line in text.lines() {
6251                let parts: Vec<&str> = line.splitn(6, '|').collect();
6252                if parts.len() >= 5 {
6253                    let name = parts[0];
6254                    let path = parts[1];
6255                    let state = parts[2];
6256                    let last = parts[3];
6257                    let res = parts[4];
6258                    let exec = parts.get(5).unwrap_or(&"").trim();
6259                    let display_path = path.trim_matches('\\');
6260                    let display_path = if display_path.is_empty() {
6261                        "Root"
6262                    } else {
6263                        display_path
6264                    };
6265                    out.push_str(&format!("  {name} [{display_path}]\n"));
6266                    out.push_str(&format!(
6267                        "    State: {state} | Last run: {last} | Result: {res}\n"
6268                    ));
6269                    if !exec.is_empty() && exec != "(no exec)" {
6270                        let short = if exec.len() > 80 { &exec[..80] } else { exec };
6271                        out.push_str(&format!("    Runs: {short}\n"));
6272                    }
6273                }
6274            }
6275        }
6276    }
6277
6278    #[cfg(not(target_os = "windows"))]
6279    {
6280        if let Ok(o) = Command::new("systemctl")
6281            .args(["list-timers", "--no-pager", "--all"])
6282            .output()
6283        {
6284            let text = String::from_utf8_lossy(&o.stdout);
6285            out.push_str("Systemd timers:\n");
6286            for l in text
6287                .lines()
6288                .filter(|l| {
6289                    !l.trim().is_empty() && !l.starts_with("NEXT") && !l.starts_with("timers")
6290                })
6291                .take(n)
6292            {
6293                out.push_str(&format!("  {l}\n"));
6294            }
6295            out.push('\n');
6296        }
6297        if let Ok(o) = Command::new("crontab").arg("-l").output() {
6298            let text = String::from_utf8_lossy(&o.stdout);
6299            let jobs: Vec<&str> = text
6300                .lines()
6301                .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
6302                .collect();
6303            if !jobs.is_empty() {
6304                out.push_str("User crontab:\n");
6305                for j in jobs.iter().take(n) {
6306                    out.push_str(&format!("  {j}\n"));
6307                }
6308            }
6309        }
6310    }
6311
6312    Ok(out.trim_end().to_string())
6313}
6314
6315// ── dev_conflicts ─────────────────────────────────────────────────────────────
6316
6317fn inspect_dev_conflicts() -> Result<String, String> {
6318    let mut out = String::from("Host inspection: dev_conflicts\n\n");
6319    let mut conflicts: Vec<String> = Vec::new();
6320    let mut notes: Vec<String> = Vec::new();
6321
6322    // ── Node.js / version managers ────────────────────────────────────────────
6323    {
6324        let node_ver = Command::new("node")
6325            .arg("--version")
6326            .output()
6327            .ok()
6328            .and_then(|o| String::from_utf8(o.stdout).ok())
6329            .map(|s| s.trim().to_string());
6330        let nvm_active = Command::new("nvm")
6331            .arg("current")
6332            .output()
6333            .ok()
6334            .and_then(|o| String::from_utf8(o.stdout).ok())
6335            .map(|s| s.trim().to_string())
6336            .filter(|s| !s.is_empty() && !s.contains("none") && !s.contains("No current"));
6337        let fnm_active = Command::new("fnm")
6338            .arg("current")
6339            .output()
6340            .ok()
6341            .and_then(|o| String::from_utf8(o.stdout).ok())
6342            .map(|s| s.trim().to_string())
6343            .filter(|s| !s.is_empty() && !s.contains("none"));
6344        let volta_active = Command::new("volta")
6345            .args(["which", "node"])
6346            .output()
6347            .ok()
6348            .and_then(|o| String::from_utf8(o.stdout).ok())
6349            .map(|s| s.trim().to_string())
6350            .filter(|s| !s.is_empty());
6351
6352        out.push_str("Node.js:\n");
6353        if let Some(ref v) = node_ver {
6354            out.push_str(&format!("  Active: {v}\n"));
6355        } else {
6356            out.push_str("  Not installed\n");
6357        }
6358        let managers: Vec<&str> = [
6359            nvm_active.as_deref(),
6360            fnm_active.as_deref(),
6361            volta_active.as_deref(),
6362        ]
6363        .iter()
6364        .filter_map(|x| *x)
6365        .collect();
6366        if managers.len() > 1 {
6367            conflicts.push("Multiple Node.js version managers detected (nvm/fnm/volta). Only one should be active to avoid PATH conflicts.".to_string());
6368        } else if !managers.is_empty() {
6369            out.push_str(&format!("  Version manager: {}\n", managers[0]));
6370        }
6371        out.push('\n');
6372    }
6373
6374    // ── Python ────────────────────────────────────────────────────────────────
6375    {
6376        let py3 = Command::new("python3")
6377            .arg("--version")
6378            .output()
6379            .ok()
6380            .and_then(|o| {
6381                let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6382                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6383                let v = if stdout.is_empty() { stderr } else { stdout };
6384                if v.is_empty() {
6385                    None
6386                } else {
6387                    Some(v)
6388                }
6389            });
6390        let py = Command::new("python")
6391            .arg("--version")
6392            .output()
6393            .ok()
6394            .and_then(|o| {
6395                let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6396                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6397                let v = if stdout.is_empty() { stderr } else { stdout };
6398                if v.is_empty() {
6399                    None
6400                } else {
6401                    Some(v)
6402                }
6403            });
6404        let pyenv = Command::new("pyenv")
6405            .arg("version")
6406            .output()
6407            .ok()
6408            .and_then(|o| String::from_utf8(o.stdout).ok())
6409            .map(|s| s.trim().to_string())
6410            .filter(|s| !s.is_empty());
6411        let conda_env = std::env::var("CONDA_DEFAULT_ENV").ok();
6412
6413        out.push_str("Python:\n");
6414        match (&py3, &py) {
6415            (Some(v3), Some(v)) if v3 != v => {
6416                out.push_str(&format!("  python3: {v3}\n  python:  {v}\n"));
6417                if v.contains("2.") {
6418                    conflicts.push(
6419                        "python and python3 point to different major versions (2.x vs 3.x). Scripts using 'python' may break unexpectedly.".to_string()
6420                    );
6421                } else {
6422                    notes.push(
6423                        "python and python3 resolve to different minor versions.".to_string(),
6424                    );
6425                }
6426            }
6427            (Some(v3), None) => out.push_str(&format!("  python3: {v3}\n")),
6428            (None, Some(v)) => out.push_str(&format!("  python: {v}\n")),
6429            (Some(v3), Some(_)) => out.push_str(&format!("  {v3}\n")),
6430            (None, None) => out.push_str("  Not installed\n"),
6431        }
6432        if let Some(ref pe) = pyenv {
6433            out.push_str(&format!("  pyenv: {pe}\n"));
6434        }
6435        if let Some(env) = conda_env {
6436            if env == "base" {
6437                notes.push("Conda base environment is active — may shadow system Python. Run 'conda deactivate' if unexpected.".to_string());
6438            } else {
6439                out.push_str(&format!("  conda env: {env}\n"));
6440            }
6441        }
6442        out.push('\n');
6443    }
6444
6445    // ── Rust / Cargo ──────────────────────────────────────────────────────────
6446    {
6447        let toolchain = Command::new("rustup")
6448            .args(["show", "active-toolchain"])
6449            .output()
6450            .ok()
6451            .and_then(|o| String::from_utf8(o.stdout).ok())
6452            .map(|s| s.trim().to_string())
6453            .filter(|s| !s.is_empty());
6454        let cargo_ver = Command::new("cargo")
6455            .arg("--version")
6456            .output()
6457            .ok()
6458            .and_then(|o| String::from_utf8(o.stdout).ok())
6459            .map(|s| s.trim().to_string());
6460        let rustc_ver = Command::new("rustc")
6461            .arg("--version")
6462            .output()
6463            .ok()
6464            .and_then(|o| String::from_utf8(o.stdout).ok())
6465            .map(|s| s.trim().to_string());
6466
6467        out.push_str("Rust:\n");
6468        if let Some(ref t) = toolchain {
6469            out.push_str(&format!("  Active toolchain: {t}\n"));
6470        }
6471        if let Some(ref c) = cargo_ver {
6472            out.push_str(&format!("  {c}\n"));
6473        }
6474        if let Some(ref r) = rustc_ver {
6475            out.push_str(&format!("  {r}\n"));
6476        }
6477        if cargo_ver.is_none() && rustc_ver.is_none() {
6478            out.push_str("  Not installed\n");
6479        }
6480
6481        // Detect system rust that might shadow rustup
6482        #[cfg(not(target_os = "windows"))]
6483        if let Ok(o) = Command::new("which").arg("rustc").output() {
6484            let path = String::from_utf8_lossy(&o.stdout).trim().to_string();
6485            if !path.is_empty() && !path.contains(".cargo") && !path.contains("rustup") {
6486                conflicts.push(format!(
6487                    "rustc found at non-rustup path '{path}' — may conflict with rustup-managed toolchain"
6488                ));
6489            }
6490        }
6491        out.push('\n');
6492    }
6493
6494    // ── Git ───────────────────────────────────────────────────────────────────
6495    {
6496        let git_ver = Command::new("git")
6497            .arg("--version")
6498            .output()
6499            .ok()
6500            .and_then(|o| String::from_utf8(o.stdout).ok())
6501            .map(|s| s.trim().to_string());
6502        out.push_str("Git:\n");
6503        if let Some(ref v) = git_ver {
6504            out.push_str(&format!("  {v}\n"));
6505            let email = Command::new("git")
6506                .args(["config", "--global", "user.email"])
6507                .output()
6508                .ok()
6509                .and_then(|o| String::from_utf8(o.stdout).ok())
6510                .map(|s| s.trim().to_string());
6511            if let Some(ref e) = email {
6512                if e.is_empty() {
6513                    notes.push("Git user.email is not configured globally — commits may fail or use wrong identity.".to_string());
6514                } else {
6515                    out.push_str(&format!("  user.email: {e}\n"));
6516                }
6517            }
6518            let gpg_sign = Command::new("git")
6519                .args(["config", "--global", "commit.gpgsign"])
6520                .output()
6521                .ok()
6522                .and_then(|o| String::from_utf8(o.stdout).ok())
6523                .map(|s| s.trim().to_string());
6524            if gpg_sign.as_deref() == Some("true") {
6525                let key = Command::new("git")
6526                    .args(["config", "--global", "user.signingkey"])
6527                    .output()
6528                    .ok()
6529                    .and_then(|o| String::from_utf8(o.stdout).ok())
6530                    .map(|s| s.trim().to_string());
6531                if key.as_deref().map(|k| k.is_empty()).unwrap_or(true) {
6532                    conflicts.push("Git commit signing is enabled but no signing key is configured — commits will fail.".to_string());
6533                }
6534            }
6535        } else {
6536            out.push_str("  Not installed\n");
6537        }
6538        out.push('\n');
6539    }
6540
6541    // ── PATH duplicates ───────────────────────────────────────────────────────
6542    {
6543        let path_env = std::env::var("PATH").unwrap_or_default();
6544        let sep = if cfg!(windows) { ';' } else { ':' };
6545        let mut seen = HashSet::new();
6546        let mut dupes: Vec<String> = Vec::new();
6547        for p in path_env.split(sep) {
6548            let norm = p.trim().to_lowercase();
6549            if !norm.is_empty() && !seen.insert(norm) {
6550                dupes.push(p.to_string());
6551            }
6552        }
6553        if !dupes.is_empty() {
6554            let shown: Vec<&str> = dupes.iter().take(3).map(|s| s.as_str()).collect();
6555            notes.push(format!(
6556                "Duplicate PATH entries: {} {}",
6557                shown.join(", "),
6558                if dupes.len() > 3 {
6559                    format!("+{} more", dupes.len() - 3)
6560                } else {
6561                    String::new()
6562                }
6563            ));
6564        }
6565    }
6566
6567    // ── Summary ───────────────────────────────────────────────────────────────
6568    if conflicts.is_empty() && notes.is_empty() {
6569        out.push_str("No conflicts detected — dev environment looks clean.\n");
6570    } else {
6571        if !conflicts.is_empty() {
6572            out.push_str("CONFLICTS:\n");
6573            for c in &conflicts {
6574                out.push_str(&format!("  [!] {c}\n"));
6575            }
6576            out.push('\n');
6577        }
6578        if !notes.is_empty() {
6579            out.push_str("NOTES:\n");
6580            for n in &notes {
6581                out.push_str(&format!("  [-] {n}\n"));
6582            }
6583        }
6584    }
6585
6586    Ok(out.trim_end().to_string())
6587}
6588
6589// ── connectivity ──────────────────────────────────────────────────────────────
6590
6591async fn inspect_public_ip() -> Result<String, String> {
6592    let mut out = String::from("Host inspection: public_ip\n\n");
6593
6594    let client = reqwest::Client::builder()
6595        .timeout(std::time::Duration::from_secs(5))
6596        .build()
6597        .map_err(|e| format!("Failed to build HTTP client: {}", e))?;
6598
6599    match client.get("https://api.ipify.org?format=json").send().await {
6600        Ok(resp) => {
6601            if let Ok(json) = resp.json::<serde_json::Value>().await {
6602                let ip = json.get("ip").and_then(|v| v.as_str()).unwrap_or("Unknown");
6603                out.push_str(&format!("Public IP: {}\n", ip));
6604
6605                // Geo info
6606                if let Ok(geo_resp) = client
6607                    .get(format!("http://ip-api.com/json/{}", ip))
6608                    .send()
6609                    .await
6610                {
6611                    if let Ok(geo_json) = geo_resp.json::<serde_json::Value>().await {
6612                        if let (Some(city), Some(region), Some(country), Some(isp)) = (
6613                            geo_json.get("city").and_then(|v| v.as_str()),
6614                            geo_json.get("regionName").and_then(|v| v.as_str()),
6615                            geo_json.get("country").and_then(|v| v.as_str()),
6616                            geo_json.get("isp").and_then(|v| v.as_str()),
6617                        ) {
6618                            out.push_str(&format!(
6619                                "Location:  {}, {} ({})\n",
6620                                city, region, country
6621                            ));
6622                            out.push_str(&format!("ISP:       {}\n", isp));
6623                        }
6624                    }
6625                }
6626            } else {
6627                out.push_str("Error: Failed to parse public IP response.\n");
6628            }
6629        }
6630        Err(e) => {
6631            out.push_str(&format!(
6632                "Error: Failed to fetch public IP ({}). Check internet connectivity.\n",
6633                e
6634            ));
6635        }
6636    }
6637
6638    Ok(out)
6639}
6640
6641fn inspect_ssl_cert(host: &str) -> Result<String, String> {
6642    let mut out = format!("Host inspection: ssl_cert (Target: {})\n\n", host);
6643
6644    #[cfg(target_os = "windows")]
6645    {
6646        use std::process::Command;
6647        let script = format!(
6648            r#"$domain = "{host}"
6649try {{
6650    $tcpClient = New-Object System.Net.Sockets.TcpClient($domain, 443)
6651    $sslStream = New-Object System.Net.Security.SslStream($tcpClient.GetStream(), $false, ({{ $true }} -as [System.Net.Security.RemoteCertificateValidationCallback]))
6652    $sslStream.AuthenticateAsClient($domain)
6653    $cert = $sslStream.RemoteCertificate
6654    $tcpClient.Close()
6655    if ($cert) {{
6656        $cert2 = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($cert)
6657        $cert2 | Select-Object Subject, Issuer, NotBefore, NotAfter, Thumbprint, SerialNumber | ConvertTo-Json
6658    }} else {{
6659        "null"
6660    }}
6661}} catch {{
6662    "ERROR:" + $_.Exception.Message
6663}}"#
6664        );
6665
6666        let ps_out = Command::new("powershell")
6667            .args(["-NoProfile", "-NonInteractive", "-Command", &script])
6668            .output()
6669            .map_err(|e| format!("powershell launch failed: {e}"))?;
6670
6671        let text = String::from_utf8_lossy(&ps_out.stdout).trim().to_string();
6672        if text.starts_with("ERROR:") {
6673            out.push_str(&format!("Error: {}\n", text.trim_start_matches("ERROR:")));
6674        } else if text == "null" || text.is_empty() {
6675            out.push_str("Error: Could not retrieve certificate. Target may be unreachable or not using SSL.\n");
6676        } else {
6677            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
6678                if let Some(obj) = json.as_object() {
6679                    for (k, v) in obj {
6680                        let val_str = v.as_str().unwrap_or("");
6681                        out.push_str(&format!("{:<12}: {}\n", k, val_str));
6682                    }
6683
6684                    if let Some(not_after_raw) = obj.get("NotAfter").and_then(|v| v.as_str()) {
6685                        if not_after_raw.starts_with("/Date(") {
6686                            let ts = not_after_raw
6687                                .trim_start_matches("/Date(")
6688                                .trim_end_matches(")/")
6689                                .parse::<i64>()
6690                                .unwrap_or(0);
6691                            let expiry =
6692                                chrono::DateTime::from_timestamp(ts / 1000, 0).unwrap_or_default();
6693                            let now = chrono::Utc::now();
6694                            let days_left = expiry.signed_duration_since(now).num_days();
6695                            if days_left < 0 {
6696                                out.push_str("\nSTATUS: [!!] EXPIRED\n");
6697                            } else if days_left < 30 {
6698                                out.push_str(&format!(
6699                                    "\nSTATUS: [!] EXPIRING SOON ({} days left)\n",
6700                                    days_left
6701                                ));
6702                            } else {
6703                                out.push_str(&format!(
6704                                    "\nSTATUS: Valid ({} days left)\n",
6705                                    days_left
6706                                ));
6707                            }
6708                        } else {
6709                            if let Ok(expiry) = chrono::DateTime::parse_from_rfc3339(not_after_raw)
6710                            {
6711                                let now = chrono::Utc::now();
6712                                let days_left = expiry.signed_duration_since(now).num_days();
6713                                if days_left < 0 {
6714                                    out.push_str("\nSTATUS: [!!] EXPIRED\n");
6715                                } else if days_left < 30 {
6716                                    out.push_str(&format!(
6717                                        "\nSTATUS: [!] EXPIRING SOON ({} days left)\n",
6718                                        days_left
6719                                    ));
6720                                } else {
6721                                    out.push_str(&format!(
6722                                        "\nSTATUS: Valid ({} days left)\n",
6723                                        days_left
6724                                    ));
6725                                }
6726                            }
6727                        }
6728                    }
6729                }
6730            } else {
6731                out.push_str(&format!("Raw Output: {}\n", text));
6732            }
6733        }
6734    }
6735
6736    #[cfg(not(target_os = "windows"))]
6737    {
6738        out.push_str(
6739            "Note: Deep SSL inspection currently optimized for Windows (PowerShell/SslStream).\n",
6740        );
6741    }
6742
6743    Ok(out)
6744}
6745
6746async fn inspect_data_audit(path: PathBuf, _max_entries: usize) -> Result<String, String> {
6747    let mut out = format!("Host inspection: data_audit (Path: {:?})\n\n", path);
6748
6749    if !path.exists() {
6750        return Err(format!("File not found: {:?}", path));
6751    }
6752    if !path.is_file() {
6753        return Err(format!("Not a file: {:?}", path));
6754    }
6755
6756    let file_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
6757    out.push_str(&format!(
6758        "File Size: {} bytes ({:.2} MB)\n",
6759        file_size,
6760        file_size as f64 / 1_048_576.0
6761    ));
6762
6763    let ext = path
6764        .extension()
6765        .and_then(|s| s.to_str())
6766        .unwrap_or("")
6767        .to_lowercase();
6768    out.push_str(&format!("Format:    {}\n\n", ext.to_uppercase()));
6769
6770    match ext.as_str() {
6771        "csv" | "tsv" | "txt" | "log" => {
6772            let content = std::fs::read_to_string(&path)
6773                .map_err(|e| format!("Failed to read file: {}", e))?;
6774            let lines: Vec<&str> = content.lines().collect();
6775            out.push_str(&format!("Row Count: {} (total lines)\n", lines.len()));
6776
6777            if let Some(header) = lines.get(0) {
6778                out.push_str("Columns (Guessed from header):\n");
6779                let delimiter = if ext == "tsv" {
6780                    "\t"
6781                } else if header.contains(',') {
6782                    ","
6783                } else {
6784                    " "
6785                };
6786                let cols: Vec<&str> = header.split(delimiter).map(|s| s.trim()).collect();
6787                for (i, col) in cols.iter().enumerate() {
6788                    out.push_str(&format!("  {}. {}\n", i + 1, col));
6789                }
6790            }
6791
6792            out.push_str("\nSample Data (First 5 rows):\n");
6793            for line in lines.iter().take(6) {
6794                out.push_str(&format!("  {}\n", line));
6795            }
6796        }
6797        "json" => {
6798            let content = std::fs::read_to_string(&path)
6799                .map_err(|e| format!("Failed to read file: {}", e))?;
6800            if let Ok(json) = serde_json::from_str::<Value>(&content) {
6801                if let Some(arr) = json.as_array() {
6802                    out.push_str(&format!("Record Count: {}\n", arr.len()));
6803                    if let Some(first) = arr.get(0) {
6804                        if let Some(obj) = first.as_object() {
6805                            out.push_str("Fields (from first record):\n");
6806                            for k in obj.keys() {
6807                                out.push_str(&format!("  - {}\n", k));
6808                            }
6809                        }
6810                    }
6811                    out.push_str("\nSample Record:\n");
6812                    out.push_str(&serde_json::to_string_pretty(&arr.get(0)).unwrap_or_default());
6813                } else if let Some(obj) = json.as_object() {
6814                    out.push_str("Top-level Keys:\n");
6815                    for k in obj.keys() {
6816                        out.push_str(&format!("  - {}\n", k));
6817                    }
6818                }
6819            } else {
6820                out.push_str("Error: Failed to parse as JSON.\n");
6821            }
6822        }
6823        "db" | "sqlite" | "sqlite3" => {
6824            out.push_str("SQLite Database detected.\n");
6825            out.push_str("Use `query_data` to execute SQL against this database.\n");
6826        }
6827        _ => {
6828            out.push_str("Unsupported format for deep audit. Showing first 10 lines:\n\n");
6829            let content = std::fs::read_to_string(&path)
6830                .map_err(|e| format!("Failed to read file: {}", e))?;
6831            for line in content.lines().take(10) {
6832                out.push_str(&format!("  {}\n", line));
6833            }
6834        }
6835    }
6836
6837    Ok(out)
6838}
6839
6840fn inspect_connectivity() -> Result<String, String> {
6841    let mut out = String::from("Host inspection: connectivity\n\n");
6842
6843    #[cfg(target_os = "windows")]
6844    {
6845        let inet_script = r#"
6846try {
6847    $r = Test-NetConnection -ComputerName 8.8.8.8 -Port 53 -InformationLevel Quiet -WarningAction SilentlyContinue
6848    if ($r) { "REACHABLE" } else { "UNREACHABLE" }
6849} catch { "ERROR:" + $_.Exception.Message }
6850"#;
6851        if let Ok(o) = Command::new("powershell")
6852            .args(["-NoProfile", "-Command", inet_script])
6853            .output()
6854        {
6855            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6856            match text.as_str() {
6857                "REACHABLE" => out.push_str("Internet: reachable\n"),
6858                "UNREACHABLE" => out.push_str("Internet: unreachable [!]\n"),
6859                _ => out.push_str(&format!(
6860                    "Internet: {}\n",
6861                    text.trim_start_matches("ERROR:").trim()
6862                )),
6863            }
6864        }
6865
6866        let dns_script = r#"
6867try {
6868    Resolve-DnsName -Name "dns.google" -Type A -ErrorAction Stop | Out-Null
6869    "DNS:ok"
6870} catch { "DNS:fail:" + $_.Exception.Message }
6871"#;
6872        if let Ok(o) = Command::new("powershell")
6873            .args(["-NoProfile", "-Command", dns_script])
6874            .output()
6875        {
6876            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6877            if text == "DNS:ok" {
6878                out.push_str("DNS: resolving correctly\n");
6879            } else {
6880                let detail = text.trim_start_matches("DNS:fail:").trim();
6881                out.push_str(&format!("DNS: failed — {}\n", detail));
6882            }
6883        }
6884
6885        let gw_script = r#"
6886(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop
6887"#;
6888        if let Ok(o) = Command::new("powershell")
6889            .args(["-NoProfile", "-Command", gw_script])
6890            .output()
6891        {
6892            let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
6893            if !gw.is_empty() && gw != "0.0.0.0" {
6894                out.push_str(&format!("Default gateway: {}\n", gw));
6895            }
6896        }
6897    }
6898
6899    #[cfg(not(target_os = "windows"))]
6900    {
6901        let reachable = Command::new("ping")
6902            .args(["-c", "1", "-W", "2", "8.8.8.8"])
6903            .output()
6904            .map(|o| o.status.success())
6905            .unwrap_or(false);
6906        out.push_str(if reachable {
6907            "Internet: reachable\n"
6908        } else {
6909            "Internet: unreachable\n"
6910        });
6911        let dns_ok = Command::new("getent")
6912            .args(["hosts", "dns.google"])
6913            .output()
6914            .map(|o| o.status.success())
6915            .unwrap_or(false);
6916        out.push_str(if dns_ok {
6917            "DNS: resolving correctly\n"
6918        } else {
6919            "DNS: failed\n"
6920        });
6921        if let Ok(o) = Command::new("ip")
6922            .args(["route", "show", "default"])
6923            .output()
6924        {
6925            let text = String::from_utf8_lossy(&o.stdout);
6926            if let Some(line) = text.lines().next() {
6927                out.push_str(&format!("Default gateway: {}\n", line.trim()));
6928            }
6929        }
6930    }
6931
6932    Ok(out.trim_end().to_string())
6933}
6934
6935// ── wifi ──────────────────────────────────────────────────────────────────────
6936
6937fn inspect_wifi() -> Result<String, String> {
6938    let mut out = String::from("Host inspection: wifi\n\n");
6939
6940    #[cfg(target_os = "windows")]
6941    {
6942        let output = Command::new("netsh")
6943            .args(["wlan", "show", "interfaces"])
6944            .output()
6945            .map_err(|e| format!("wifi: {e}"))?;
6946        let text = String::from_utf8_lossy(&output.stdout).to_string();
6947
6948        if text.contains("There is no wireless interface") || text.trim().is_empty() {
6949            out.push_str("No wireless interface detected on this machine.\n");
6950            return Ok(out.trim_end().to_string());
6951        }
6952
6953        let fields = [
6954            ("SSID", "SSID"),
6955            ("State", "State"),
6956            ("Signal", "Signal"),
6957            ("Radio type", "Radio type"),
6958            ("Channel", "Channel"),
6959            ("Receive rate (Mbps)", "Download speed (Mbps)"),
6960            ("Transmit rate (Mbps)", "Upload speed (Mbps)"),
6961            ("Authentication", "Authentication"),
6962            ("Network type", "Network type"),
6963        ];
6964
6965        let mut any = false;
6966        for line in text.lines() {
6967            let trimmed = line.trim();
6968            for (key, label) in &fields {
6969                if trimmed.starts_with(key) && trimmed.contains(':') {
6970                    let val = trimmed.splitn(2, ':').nth(1).unwrap_or("").trim();
6971                    if !val.is_empty() {
6972                        out.push_str(&format!("  {label}: {val}\n"));
6973                        any = true;
6974                    }
6975                }
6976            }
6977        }
6978        if !any {
6979            out.push_str("  (Wi-Fi adapter disconnected or no active connection)\n");
6980        }
6981    }
6982
6983    #[cfg(not(target_os = "windows"))]
6984    {
6985        if let Ok(o) = Command::new("nmcli")
6986            .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
6987            .output()
6988        {
6989            let text = String::from_utf8_lossy(&o.stdout).to_string();
6990            let lines: Vec<&str> = text.lines().filter(|l| l.contains(":wifi:")).collect();
6991            if lines.is_empty() {
6992                out.push_str("No Wi-Fi devices found.\n");
6993            } else {
6994                for l in lines {
6995                    out.push_str(&format!("  {l}\n"));
6996                }
6997            }
6998        } else if let Ok(o) = Command::new("iwconfig").output() {
6999            let text = String::from_utf8_lossy(&o.stdout).to_string();
7000            if !text.trim().is_empty() {
7001                out.push_str(text.trim());
7002                out.push('\n');
7003            }
7004        } else {
7005            out.push_str("No wireless tool available (install nmcli or wireless-tools).\n");
7006        }
7007    }
7008
7009    Ok(out.trim_end().to_string())
7010}
7011
7012// ── connections ───────────────────────────────────────────────────────────────
7013
7014fn inspect_connections(max_entries: usize) -> Result<String, String> {
7015    let mut out = String::from("Host inspection: connections\n\n");
7016    let n = max_entries.clamp(1, 25);
7017
7018    #[cfg(target_os = "windows")]
7019    {
7020        let script = format!(
7021            r#"
7022try {{
7023    $procs = @{{}}
7024    Get-Process -ErrorAction SilentlyContinue | ForEach-Object {{ $procs[$_.Id] = $_.Name }}
7025    $all = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
7026        Sort-Object OwningProcess
7027    "TOTAL:" + $all.Count
7028    $all | Select-Object -First {n} | ForEach-Object {{
7029        $pname = if ($procs.ContainsKey($_.OwningProcess)) {{ $procs[$_.OwningProcess] }} else {{ "unknown" }}
7030        $pname + "|" + $_.OwningProcess + "|" + $_.LocalAddress + ":" + $_.LocalPort + "|" + $_.RemoteAddress + ":" + $_.RemotePort
7031    }}
7032}} catch {{ "ERROR:" + $_.Exception.Message }}"#
7033        );
7034
7035        let output = Command::new("powershell")
7036            .args(["-NoProfile", "-Command", &script])
7037            .output()
7038            .map_err(|e| format!("connections: {e}"))?;
7039
7040        let raw = String::from_utf8_lossy(&output.stdout);
7041        let text = raw.trim();
7042
7043        if text.starts_with("ERROR:") {
7044            out.push_str(&format!("Unable to query connections: {text}\n"));
7045        } else {
7046            let mut total = 0usize;
7047            let mut rows = Vec::new();
7048            for line in text.lines() {
7049                if let Some(rest) = line.strip_prefix("TOTAL:") {
7050                    total = rest.trim().parse().unwrap_or(0);
7051                } else {
7052                    rows.push(line);
7053                }
7054            }
7055            out.push_str(&format!("Established TCP connections: {total}\n\n"));
7056            for row in &rows {
7057                let parts: Vec<&str> = row.splitn(4, '|').collect();
7058                if parts.len() == 4 {
7059                    out.push_str(&format!(
7060                        "  {:<15} (pid {:<5}) | {} → {}\n",
7061                        parts[0], parts[1], parts[2], parts[3]
7062                    ));
7063                }
7064            }
7065            if total > n {
7066                out.push_str(&format!(
7067                    "\n  ... {} more connections not shown\n",
7068                    total.saturating_sub(n)
7069                ));
7070            }
7071        }
7072    }
7073
7074    #[cfg(not(target_os = "windows"))]
7075    {
7076        if let Ok(o) = Command::new("ss")
7077            .args(["-tnp", "state", "established"])
7078            .output()
7079        {
7080            let text = String::from_utf8_lossy(&o.stdout);
7081            let lines: Vec<&str> = text
7082                .lines()
7083                .skip(1)
7084                .filter(|l| !l.trim().is_empty())
7085                .collect();
7086            out.push_str(&format!("Established TCP connections: {}\n\n", lines.len()));
7087            for line in lines.iter().take(n) {
7088                out.push_str(&format!("  {}\n", line.trim()));
7089            }
7090            if lines.len() > n {
7091                out.push_str(&format!("\n  ... {} more not shown\n", lines.len() - n));
7092            }
7093        } else {
7094            out.push_str("ss not available — install iproute2\n");
7095        }
7096    }
7097
7098    Ok(out.trim_end().to_string())
7099}
7100
7101// ── vpn ───────────────────────────────────────────────────────────────────────
7102
7103fn inspect_vpn() -> Result<String, String> {
7104    let mut out = String::from("Host inspection: vpn\n\n");
7105
7106    #[cfg(target_os = "windows")]
7107    {
7108        let script = r#"
7109try {
7110    $vpn = Get-NetAdapter -ErrorAction Stop | Where-Object {
7111        $_.InterfaceDescription -match 'VPN|TAP|WireGuard|OpenVPN|Cisco|Palo Alto|GlobalProtect|Juniper|Pulse|NordVPN|ExpressVPN|Mullvad|ProtonVPN' -or
7112        $_.Name -match 'VPN|TAP|WireGuard|tun|ppp|wg\d'
7113    }
7114    if ($vpn) {
7115        foreach ($a in $vpn) {
7116            $a.Name + "|" + $a.InterfaceDescription + "|" + $a.Status + "|" + $a.MediaConnectionState
7117        }
7118    } else { "NONE" }
7119} catch { "ERROR:" + $_.Exception.Message }
7120"#;
7121        let output = Command::new("powershell")
7122            .args(["-NoProfile", "-Command", script])
7123            .output()
7124            .map_err(|e| format!("vpn: {e}"))?;
7125
7126        let raw = String::from_utf8_lossy(&output.stdout);
7127        let text = raw.trim();
7128
7129        if text == "NONE" {
7130            out.push_str("No VPN adapters detected — no active VPN connection found.\n");
7131        } else if text.starts_with("ERROR:") {
7132            out.push_str(&format!("Unable to query adapters: {text}\n"));
7133        } else {
7134            out.push_str("VPN adapters:\n\n");
7135            for line in text.lines() {
7136                let parts: Vec<&str> = line.splitn(4, '|').collect();
7137                if parts.len() >= 3 {
7138                    let name = parts[0];
7139                    let desc = parts[1];
7140                    let status = parts[2];
7141                    let media = parts.get(3).unwrap_or(&"unknown");
7142                    let label = if status.trim() == "Up" {
7143                        "CONNECTED"
7144                    } else {
7145                        "disconnected"
7146                    };
7147                    out.push_str(&format!(
7148                        "  {name} [{label}]\n    {desc}\n    Status: {status} | Media: {media}\n\n"
7149                    ));
7150                }
7151            }
7152        }
7153
7154        // Windows built-in VPN connections
7155        let ras_script = r#"
7156try {
7157    $c = Get-VpnConnection -ErrorAction Stop
7158    if ($c) { foreach ($v in $c) { $v.Name + "|" + $v.ConnectionStatus + "|" + $v.ServerAddress } }
7159    else { "NO_RAS" }
7160} catch { "NO_RAS" }
7161"#;
7162        if let Ok(o) = Command::new("powershell")
7163            .args(["-NoProfile", "-Command", ras_script])
7164            .output()
7165        {
7166            let t = String::from_utf8_lossy(&o.stdout).trim().to_string();
7167            if t != "NO_RAS" && !t.is_empty() {
7168                out.push_str("Windows VPN connections:\n");
7169                for line in t.lines() {
7170                    let parts: Vec<&str> = line.splitn(3, '|').collect();
7171                    if parts.len() >= 2 {
7172                        let name = parts[0];
7173                        let status = parts[1];
7174                        let server = parts.get(2).unwrap_or(&"");
7175                        out.push_str(&format!("  {name} → {server} [{status}]\n"));
7176                    }
7177                }
7178            }
7179        }
7180    }
7181
7182    #[cfg(not(target_os = "windows"))]
7183    {
7184        if let Ok(o) = Command::new("ip").args(["link", "show"]).output() {
7185            let text = String::from_utf8_lossy(&o.stdout);
7186            let vpn_ifaces: Vec<&str> = text
7187                .lines()
7188                .filter(|l| {
7189                    l.contains("tun") || l.contains("tap") || l.contains(" wg") || l.contains("ppp")
7190                })
7191                .collect();
7192            if vpn_ifaces.is_empty() {
7193                out.push_str("No VPN interfaces (tun/tap/wg/ppp) detected.\n");
7194            } else {
7195                out.push_str(&format!("VPN-like interfaces ({}):\n", vpn_ifaces.len()));
7196                for l in vpn_ifaces {
7197                    out.push_str(&format!("  {}\n", l.trim()));
7198                }
7199            }
7200        }
7201    }
7202
7203    Ok(out.trim_end().to_string())
7204}
7205
7206// ── proxy ─────────────────────────────────────────────────────────────────────
7207
7208fn inspect_proxy() -> Result<String, String> {
7209    let mut out = String::from("Host inspection: proxy\n\n");
7210
7211    #[cfg(target_os = "windows")]
7212    {
7213        let script = r#"
7214$ie = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
7215if ($ie) {
7216    "ENABLE:" + $ie.ProxyEnable + "|SERVER:" + $ie.ProxyServer + "|OVERRIDE:" + $ie.ProxyOverride
7217} else { "NONE" }
7218"#;
7219        if let Ok(o) = Command::new("powershell")
7220            .args(["-NoProfile", "-Command", script])
7221            .output()
7222        {
7223            let raw = String::from_utf8_lossy(&o.stdout);
7224            let text = raw.trim();
7225            if text != "NONE" && !text.is_empty() {
7226                let get = |key: &str| -> &str {
7227                    text.split('|')
7228                        .find(|s| s.starts_with(key))
7229                        .and_then(|s| s.splitn(2, ':').nth(1))
7230                        .unwrap_or("")
7231                };
7232                let enabled = get("ENABLE");
7233                let server = get("SERVER");
7234                let overrides = get("OVERRIDE");
7235                out.push_str("WinINET / IE proxy:\n");
7236                out.push_str(&format!(
7237                    "  Enabled: {}\n",
7238                    if enabled == "1" { "yes" } else { "no" }
7239                ));
7240                if !server.is_empty() && server != "None" {
7241                    out.push_str(&format!("  Proxy server: {server}\n"));
7242                }
7243                if !overrides.is_empty() && overrides != "None" {
7244                    out.push_str(&format!("  Bypass list: {overrides}\n"));
7245                }
7246                out.push('\n');
7247            }
7248        }
7249
7250        if let Ok(o) = Command::new("netsh")
7251            .args(["winhttp", "show", "proxy"])
7252            .output()
7253        {
7254            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7255            out.push_str("WinHTTP proxy:\n");
7256            for line in text.lines() {
7257                let l = line.trim();
7258                if !l.is_empty() {
7259                    out.push_str(&format!("  {l}\n"));
7260                }
7261            }
7262            out.push('\n');
7263        }
7264
7265        let mut env_found = false;
7266        for var in &[
7267            "http_proxy",
7268            "https_proxy",
7269            "HTTP_PROXY",
7270            "HTTPS_PROXY",
7271            "no_proxy",
7272            "NO_PROXY",
7273        ] {
7274            if let Ok(val) = std::env::var(var) {
7275                if !env_found {
7276                    out.push_str("Environment proxy variables:\n");
7277                    env_found = true;
7278                }
7279                out.push_str(&format!("  {var}: {val}\n"));
7280            }
7281        }
7282        if !env_found {
7283            out.push_str("No proxy environment variables set.\n");
7284        }
7285    }
7286
7287    #[cfg(not(target_os = "windows"))]
7288    {
7289        let mut found = false;
7290        for var in &[
7291            "http_proxy",
7292            "https_proxy",
7293            "HTTP_PROXY",
7294            "HTTPS_PROXY",
7295            "no_proxy",
7296            "NO_PROXY",
7297            "ALL_PROXY",
7298            "all_proxy",
7299        ] {
7300            if let Ok(val) = std::env::var(var) {
7301                if !found {
7302                    out.push_str("Proxy environment variables:\n");
7303                    found = true;
7304                }
7305                out.push_str(&format!("  {var}: {val}\n"));
7306            }
7307        }
7308        if !found {
7309            out.push_str("No proxy environment variables set.\n");
7310        }
7311        if let Ok(content) = std::fs::read_to_string("/etc/environment") {
7312            let proxy_lines: Vec<&str> = content
7313                .lines()
7314                .filter(|l| l.to_lowercase().contains("proxy"))
7315                .collect();
7316            if !proxy_lines.is_empty() {
7317                out.push_str("\nSystem proxy (/etc/environment):\n");
7318                for l in proxy_lines {
7319                    out.push_str(&format!("  {l}\n"));
7320                }
7321            }
7322        }
7323    }
7324
7325    Ok(out.trim_end().to_string())
7326}
7327
7328// ── firewall_rules ────────────────────────────────────────────────────────────
7329
7330fn inspect_firewall_rules(max_entries: usize) -> Result<String, String> {
7331    let mut out = String::from("Host inspection: firewall_rules\n\n");
7332    let n = max_entries.clamp(1, 20);
7333
7334    #[cfg(target_os = "windows")]
7335    {
7336        let script = format!(
7337            r#"
7338try {{
7339    $rules = Get-NetFirewallRule -Enabled True -ErrorAction Stop |
7340        Where-Object {{
7341            $_.DisplayGroup -notmatch '^(@|Core Networking|Windows|File and Printer)' -and
7342            $_.Owner -eq $null
7343        }} | Select-Object -First {n} DisplayName, Direction, Action, Profile
7344    "TOTAL:" + $rules.Count
7345    $rules | ForEach-Object {{
7346        $dir = switch ($_.Direction) {{ 1 {{ "Inbound" }}; 2 {{ "Outbound" }}; default {{ "?" }} }}
7347        $act = switch ($_.Action) {{ 2 {{ "Allow" }}; 4 {{ "Block" }}; default {{ "?" }} }}
7348        $_.DisplayName + "|" + $dir + "|" + $act + "|" + $_.Profile
7349    }}
7350}} catch {{ "ERROR:" + $_.Exception.Message }}"#
7351        );
7352
7353        let output = Command::new("powershell")
7354            .args(["-NoProfile", "-Command", &script])
7355            .output()
7356            .map_err(|e| format!("firewall_rules: {e}"))?;
7357
7358        let raw = String::from_utf8_lossy(&output.stdout);
7359        let text = raw.trim();
7360
7361        if text.starts_with("ERROR:") {
7362            out.push_str(&format!(
7363                "Unable to query firewall rules: {}\n",
7364                text.trim_start_matches("ERROR:").trim()
7365            ));
7366            out.push_str("This query may require running as administrator.\n");
7367        } else if text.is_empty() {
7368            out.push_str("No non-default enabled firewall rules found.\n");
7369        } else {
7370            let mut total = 0usize;
7371            for line in text.lines() {
7372                if let Some(rest) = line.strip_prefix("TOTAL:") {
7373                    total = rest.trim().parse().unwrap_or(0);
7374                    out.push_str(&format!(
7375                        "Non-default enabled rules (showing up to {n}):\n\n"
7376                    ));
7377                } else {
7378                    let parts: Vec<&str> = line.splitn(4, '|').collect();
7379                    if parts.len() >= 3 {
7380                        let name = parts[0];
7381                        let dir = parts[1];
7382                        let action = parts[2];
7383                        let profile = parts.get(3).unwrap_or(&"Any");
7384                        let icon = if action == "Block" { "[!]" } else { "   " };
7385                        out.push_str(&format!(
7386                            "  {icon} [{dir}] {action}: {name} (profile: {profile})\n"
7387                        ));
7388                    }
7389                }
7390            }
7391            if total == 0 {
7392                out.push_str("No non-default enabled rules found.\n");
7393            }
7394        }
7395    }
7396
7397    #[cfg(not(target_os = "windows"))]
7398    {
7399        if let Ok(o) = Command::new("ufw").args(["status", "numbered"]).output() {
7400            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7401            if !text.is_empty() {
7402                out.push_str(&text);
7403                out.push('\n');
7404            }
7405        } else if let Ok(o) = Command::new("iptables")
7406            .args(["-L", "-n", "--line-numbers"])
7407            .output()
7408        {
7409            let text = String::from_utf8_lossy(&o.stdout);
7410            for l in text.lines().take(n * 2) {
7411                out.push_str(&format!("  {l}\n"));
7412            }
7413        } else {
7414            out.push_str("ufw and iptables not available or insufficient permissions.\n");
7415        }
7416    }
7417
7418    Ok(out.trim_end().to_string())
7419}
7420
7421// ── traceroute ────────────────────────────────────────────────────────────────
7422
7423fn inspect_traceroute(host: &str, max_entries: usize) -> Result<String, String> {
7424    let mut out = format!("Host inspection: traceroute\n\nTarget: {host}\n\n");
7425    let hops = max_entries.clamp(5, 30);
7426
7427    #[cfg(target_os = "windows")]
7428    {
7429        let output = Command::new("tracert")
7430            .args(["-d", "-h", &hops.to_string(), host])
7431            .output()
7432            .map_err(|e| format!("tracert: {e}"))?;
7433        let raw = String::from_utf8_lossy(&output.stdout);
7434        let mut hop_count = 0usize;
7435        for line in raw.lines() {
7436            let trimmed = line.trim();
7437            if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
7438                hop_count += 1;
7439                out.push_str(&format!("  {trimmed}\n"));
7440            } else if trimmed.starts_with("Tracing") || trimmed.starts_with("Trace complete") {
7441                out.push_str(&format!("{trimmed}\n"));
7442            }
7443        }
7444        if hop_count == 0 {
7445            out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
7446        }
7447    }
7448
7449    #[cfg(not(target_os = "windows"))]
7450    {
7451        let cmd = if std::path::Path::new("/usr/bin/traceroute").exists()
7452            || std::path::Path::new("/usr/sbin/traceroute").exists()
7453        {
7454            "traceroute"
7455        } else {
7456            "tracepath"
7457        };
7458        let output = Command::new(cmd)
7459            .args(["-m", &hops.to_string(), "-n", host])
7460            .output()
7461            .map_err(|e| format!("{cmd}: {e}"))?;
7462        let raw = String::from_utf8_lossy(&output.stdout);
7463        let mut hop_count = 0usize;
7464        for line in raw.lines().take(hops + 2) {
7465            let trimmed = line.trim();
7466            if !trimmed.is_empty() {
7467                hop_count += 1;
7468                out.push_str(&format!("  {trimmed}\n"));
7469            }
7470        }
7471        if hop_count == 0 {
7472            out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
7473        }
7474    }
7475
7476    Ok(out.trim_end().to_string())
7477}
7478
7479// ── dns_cache ─────────────────────────────────────────────────────────────────
7480
7481fn inspect_dns_cache(max_entries: usize) -> Result<String, String> {
7482    let mut out = String::from("Host inspection: dns_cache\n\n");
7483    let n = max_entries.clamp(10, 100);
7484
7485    #[cfg(target_os = "windows")]
7486    {
7487        let output = Command::new("powershell")
7488            .args([
7489                "-NoProfile",
7490                "-Command",
7491                "Get-DnsClientCache | Select-Object -First 200 Entry,RecordType,Data,TimeToLive | ConvertTo-Csv -NoTypeInformation",
7492            ])
7493            .output()
7494            .map_err(|e| format!("dns_cache: {e}"))?;
7495
7496        let raw = String::from_utf8_lossy(&output.stdout);
7497        let lines: Vec<&str> = raw.lines().skip(1).collect();
7498        let total = lines.len();
7499
7500        if total == 0 {
7501            out.push_str("DNS cache is empty or could not be read.\n");
7502        } else {
7503            out.push_str(&format!(
7504                "DNS cache entries (showing up to {n} of {total}):\n\n"
7505            ));
7506            let mut shown = 0usize;
7507            for line in lines.iter().take(n) {
7508                let cols: Vec<&str> = line.splitn(4, ',').collect();
7509                if cols.len() >= 3 {
7510                    let entry = cols[0].trim_matches('"');
7511                    let rtype = cols[1].trim_matches('"');
7512                    let data = cols[2].trim_matches('"');
7513                    let ttl = cols.get(3).map(|s| s.trim_matches('"')).unwrap_or("?");
7514                    out.push_str(&format!("  {entry:<45} {rtype:<6} {data}  (TTL {ttl}s)\n"));
7515                    shown += 1;
7516                }
7517            }
7518            if total > shown {
7519                out.push_str(&format!("\n  ... and {} more entries\n", total - shown));
7520            }
7521        }
7522    }
7523
7524    #[cfg(not(target_os = "windows"))]
7525    {
7526        if let Ok(o) = Command::new("resolvectl").args(["statistics"]).output() {
7527            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7528            if !text.is_empty() {
7529                out.push_str("systemd-resolved statistics:\n");
7530                for line in text.lines().take(n) {
7531                    out.push_str(&format!("  {line}\n"));
7532                }
7533                out.push('\n');
7534            }
7535        }
7536        if let Ok(o) = Command::new("dscacheutil")
7537            .args(["-cachedump", "-entries", "Host"])
7538            .output()
7539        {
7540            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7541            if !text.is_empty() {
7542                out.push_str("DNS cache (macOS dscacheutil):\n");
7543                for line in text.lines().take(n) {
7544                    out.push_str(&format!("  {line}\n"));
7545                }
7546            } else {
7547                out.push_str("DNS cache is empty or not accessible on this platform.\n");
7548            }
7549        } else {
7550            out.push_str(
7551                "DNS cache inspection not available (no resolvectl or dscacheutil found).\n",
7552            );
7553        }
7554    }
7555
7556    Ok(out.trim_end().to_string())
7557}
7558
7559// ── arp ───────────────────────────────────────────────────────────────────────
7560
7561fn inspect_arp() -> Result<String, String> {
7562    let mut out = String::from("Host inspection: arp\n\n");
7563
7564    #[cfg(target_os = "windows")]
7565    {
7566        let output = Command::new("arp")
7567            .args(["-a"])
7568            .output()
7569            .map_err(|e| format!("arp: {e}"))?;
7570        let raw = String::from_utf8_lossy(&output.stdout);
7571        let mut count = 0usize;
7572        for line in raw.lines() {
7573            let t = line.trim();
7574            if t.is_empty() {
7575                continue;
7576            }
7577            out.push_str(&format!("  {t}\n"));
7578            if t.contains("dynamic") || t.contains("static") {
7579                count += 1;
7580            }
7581        }
7582        out.push_str(&format!("\nTotal entries: {count}\n"));
7583    }
7584
7585    #[cfg(not(target_os = "windows"))]
7586    {
7587        if let Ok(o) = Command::new("arp").args(["-n"]).output() {
7588            let raw = String::from_utf8_lossy(&o.stdout);
7589            let mut count = 0usize;
7590            for line in raw.lines() {
7591                let t = line.trim();
7592                if !t.is_empty() {
7593                    out.push_str(&format!("  {t}\n"));
7594                    count += 1;
7595                }
7596            }
7597            out.push_str(&format!("\nTotal entries: {}\n", count.saturating_sub(1)));
7598        } else if let Ok(o) = Command::new("ip").args(["neigh"]).output() {
7599            let raw = String::from_utf8_lossy(&o.stdout);
7600            let mut count = 0usize;
7601            for line in raw.lines() {
7602                let t = line.trim();
7603                if !t.is_empty() {
7604                    out.push_str(&format!("  {t}\n"));
7605                    count += 1;
7606                }
7607            }
7608            out.push_str(&format!("\nTotal entries: {count}\n"));
7609        } else {
7610            out.push_str("arp and ip neigh not available.\n");
7611        }
7612    }
7613
7614    Ok(out.trim_end().to_string())
7615}
7616
7617// ── route_table ───────────────────────────────────────────────────────────────
7618
7619fn inspect_route_table(max_entries: usize) -> Result<String, String> {
7620    let mut out = String::from("Host inspection: route_table\n\n");
7621    let n = max_entries.clamp(10, 50);
7622
7623    #[cfg(target_os = "windows")]
7624    {
7625        let script = r#"
7626try {
7627    $routes = Get-NetRoute -ErrorAction Stop |
7628        Where-Object { $_.RouteMetric -lt 9000 } |
7629        Sort-Object RouteMetric |
7630        Select-Object DestinationPrefix, NextHop, RouteMetric, InterfaceAlias
7631    "TOTAL:" + $routes.Count
7632    $routes | ForEach-Object {
7633        $_.DestinationPrefix + "|" + $_.NextHop + "|" + $_.RouteMetric + "|" + $_.InterfaceAlias
7634    }
7635} catch { "ERROR:" + $_.Exception.Message }
7636"#;
7637        let output = Command::new("powershell")
7638            .args(["-NoProfile", "-Command", script])
7639            .output()
7640            .map_err(|e| format!("route_table: {e}"))?;
7641        let raw = String::from_utf8_lossy(&output.stdout);
7642        let text = raw.trim();
7643
7644        if text.starts_with("ERROR:") {
7645            out.push_str(&format!(
7646                "Unable to read route table: {}\n",
7647                text.trim_start_matches("ERROR:").trim()
7648            ));
7649        } else {
7650            let mut shown = 0usize;
7651            for line in text.lines() {
7652                if let Some(rest) = line.strip_prefix("TOTAL:") {
7653                    let total: usize = rest.trim().parse().unwrap_or(0);
7654                    out.push_str(&format!(
7655                        "Routing table (showing up to {n} of {total} routes):\n\n"
7656                    ));
7657                    out.push_str(&format!(
7658                        "  {:<22} {:<18} {:>8}  Interface\n",
7659                        "Destination", "Next Hop", "Metric"
7660                    ));
7661                    out.push_str(&format!("  {}\n", "-".repeat(70)));
7662                } else if shown < n {
7663                    let parts: Vec<&str> = line.splitn(4, '|').collect();
7664                    if parts.len() == 4 {
7665                        let dest = parts[0];
7666                        let hop =
7667                            if parts[1].is_empty() || parts[1] == "0.0.0.0" || parts[1] == "::" {
7668                                "on-link"
7669                            } else {
7670                                parts[1]
7671                            };
7672                        let metric = parts[2];
7673                        let iface = parts[3];
7674                        out.push_str(&format!("  {dest:<22} {hop:<18} {metric:>8}  {iface}\n"));
7675                        shown += 1;
7676                    }
7677                }
7678            }
7679        }
7680    }
7681
7682    #[cfg(not(target_os = "windows"))]
7683    {
7684        if let Ok(o) = Command::new("ip").args(["route", "show"]).output() {
7685            let raw = String::from_utf8_lossy(&o.stdout);
7686            let lines: Vec<&str> = raw.lines().collect();
7687            let total = lines.len();
7688            out.push_str(&format!(
7689                "Routing table (showing up to {n} of {total} routes):\n\n"
7690            ));
7691            for line in lines.iter().take(n) {
7692                out.push_str(&format!("  {line}\n"));
7693            }
7694            if total > n {
7695                out.push_str(&format!("\n  ... and {} more routes\n", total - n));
7696            }
7697        } else if let Ok(o) = Command::new("netstat").args(["-rn"]).output() {
7698            let raw = String::from_utf8_lossy(&o.stdout);
7699            for line in raw.lines().take(n) {
7700                out.push_str(&format!("  {line}\n"));
7701            }
7702        } else {
7703            out.push_str("ip route and netstat not available.\n");
7704        }
7705    }
7706
7707    Ok(out.trim_end().to_string())
7708}
7709
7710// ── env ───────────────────────────────────────────────────────────────────────
7711
7712fn inspect_env(max_entries: usize) -> Result<String, String> {
7713    let mut out = String::from("Host inspection: env\n\n");
7714    let n = max_entries.clamp(10, 50);
7715
7716    fn looks_like_secret(name: &str) -> bool {
7717        let n = name.to_uppercase();
7718        n.contains("KEY")
7719            || n.contains("SECRET")
7720            || n.contains("TOKEN")
7721            || n.contains("PASSWORD")
7722            || n.contains("PASSWD")
7723            || n.contains("CREDENTIAL")
7724            || n.contains("AUTH")
7725            || n.contains("CERT")
7726            || n.contains("PRIVATE")
7727    }
7728
7729    let known_dev_vars: &[&str] = &[
7730        "CARGO_HOME",
7731        "RUSTUP_HOME",
7732        "GOPATH",
7733        "GOROOT",
7734        "GOBIN",
7735        "JAVA_HOME",
7736        "ANDROID_HOME",
7737        "ANDROID_SDK_ROOT",
7738        "PYTHONPATH",
7739        "PYTHONHOME",
7740        "VIRTUAL_ENV",
7741        "CONDA_DEFAULT_ENV",
7742        "CONDA_PREFIX",
7743        "NODE_PATH",
7744        "NVM_DIR",
7745        "NVM_BIN",
7746        "PNPM_HOME",
7747        "DENO_INSTALL",
7748        "DENO_DIR",
7749        "DOTNET_ROOT",
7750        "NUGET_PACKAGES",
7751        "CMAKE_HOME",
7752        "VCPKG_ROOT",
7753        "AWS_PROFILE",
7754        "AWS_REGION",
7755        "AWS_DEFAULT_REGION",
7756        "GCP_PROJECT",
7757        "GOOGLE_CLOUD_PROJECT",
7758        "GOOGLE_APPLICATION_CREDENTIALS",
7759        "AZURE_SUBSCRIPTION_ID",
7760        "DATABASE_URL",
7761        "REDIS_URL",
7762        "MONGO_URI",
7763        "EDITOR",
7764        "VISUAL",
7765        "SHELL",
7766        "TERM",
7767        "XDG_CONFIG_HOME",
7768        "XDG_DATA_HOME",
7769        "XDG_CACHE_HOME",
7770        "HOME",
7771        "USERPROFILE",
7772        "APPDATA",
7773        "LOCALAPPDATA",
7774        "TEMP",
7775        "TMP",
7776        "COMPUTERNAME",
7777        "USERNAME",
7778        "USERDOMAIN",
7779        "PROCESSOR_ARCHITECTURE",
7780        "NUMBER_OF_PROCESSORS",
7781        "OS",
7782        "HOMEDRIVE",
7783        "HOMEPATH",
7784        "HTTP_PROXY",
7785        "HTTPS_PROXY",
7786        "NO_PROXY",
7787        "ALL_PROXY",
7788        "http_proxy",
7789        "https_proxy",
7790        "no_proxy",
7791        "DOCKER_HOST",
7792        "DOCKER_BUILDKIT",
7793        "COMPOSE_PROJECT_NAME",
7794        "KUBECONFIG",
7795        "KUBE_CONTEXT",
7796        "CI",
7797        "GITHUB_ACTIONS",
7798        "GITLAB_CI",
7799        "LMSTUDIO_HOME",
7800        "HEMATITE_URL",
7801    ];
7802
7803    let mut all_vars: Vec<(String, String)> = std::env::vars().collect();
7804    all_vars.sort_by(|a, b| a.0.cmp(&b.0));
7805    let total = all_vars.len();
7806
7807    let mut dev_found: Vec<String> = Vec::new();
7808    let mut secret_found: Vec<String> = Vec::new();
7809
7810    for (k, v) in &all_vars {
7811        if k == "PATH" {
7812            continue;
7813        }
7814        if looks_like_secret(k) {
7815            secret_found.push(format!("{k} = [SET, {} chars]", v.len()));
7816        } else {
7817            let k_upper = k.to_uppercase();
7818            let is_known = known_dev_vars
7819                .iter()
7820                .any(|kv| k_upper.as_str() == kv.to_uppercase().as_str());
7821            if is_known {
7822                let display = if v.len() > 120 {
7823                    format!("{k} = {}…", &v[..117])
7824                } else {
7825                    format!("{k} = {v}")
7826                };
7827                dev_found.push(display);
7828            }
7829        }
7830    }
7831
7832    out.push_str(&format!("Total environment variables: {total}\n\n"));
7833
7834    if let Ok(p) = std::env::var("PATH") {
7835        let sep = if cfg!(target_os = "windows") {
7836            ';'
7837        } else {
7838            ':'
7839        };
7840        let count = p.split(sep).count();
7841        out.push_str(&format!(
7842            "PATH: {count} entries (use topic=path for full audit)\n\n"
7843        ));
7844    }
7845
7846    if !secret_found.is_empty() {
7847        out.push_str(&format!(
7848            "=== Secret/credential variables ({} detected, values hidden) ===\n",
7849            secret_found.len()
7850        ));
7851        for s in secret_found.iter().take(n) {
7852            out.push_str(&format!("  {s}\n"));
7853        }
7854        out.push('\n');
7855    }
7856
7857    if !dev_found.is_empty() {
7858        out.push_str(&format!(
7859            "=== Developer & tool variables ({}) ===\n",
7860            dev_found.len()
7861        ));
7862        for d in dev_found.iter().take(n) {
7863            out.push_str(&format!("  {d}\n"));
7864        }
7865        out.push('\n');
7866    }
7867
7868    let other_count = all_vars
7869        .iter()
7870        .filter(|(k, _)| {
7871            k != "PATH"
7872                && !looks_like_secret(k)
7873                && !known_dev_vars
7874                    .iter()
7875                    .any(|kv| k.to_uppercase().as_str() == kv.to_uppercase().as_str())
7876        })
7877        .count();
7878    if other_count > 0 {
7879        out.push_str(&format!(
7880            "Other variables: {other_count} (use 'env' in shell to see all)\n"
7881        ));
7882    }
7883
7884    Ok(out.trim_end().to_string())
7885}
7886
7887// ── hosts_file ────────────────────────────────────────────────────────────────
7888
7889fn inspect_hosts_file() -> Result<String, String> {
7890    let mut out = String::from("Host inspection: hosts_file\n\n");
7891
7892    let hosts_path = if cfg!(target_os = "windows") {
7893        std::path::PathBuf::from(r"C:\Windows\System32\drivers\etc\hosts")
7894    } else {
7895        std::path::PathBuf::from("/etc/hosts")
7896    };
7897
7898    out.push_str(&format!("Path: {}\n\n", hosts_path.display()));
7899
7900    match fs::read_to_string(&hosts_path) {
7901        Ok(content) => {
7902            let mut active_entries: Vec<String> = Vec::new();
7903            let mut comment_lines = 0usize;
7904            let mut blank_lines = 0usize;
7905
7906            for line in content.lines() {
7907                let t = line.trim();
7908                if t.is_empty() {
7909                    blank_lines += 1;
7910                } else if t.starts_with('#') {
7911                    comment_lines += 1;
7912                } else {
7913                    active_entries.push(line.to_string());
7914                }
7915            }
7916
7917            out.push_str(&format!(
7918                "Active entries: {}  |  Comment lines: {}  |  Blank lines: {}\n\n",
7919                active_entries.len(),
7920                comment_lines,
7921                blank_lines
7922            ));
7923
7924            if active_entries.is_empty() {
7925                out.push_str(
7926                    "No active host entries (file contains only comments/blanks — standard default state).\n",
7927                );
7928            } else {
7929                out.push_str("=== Active entries ===\n");
7930                for entry in &active_entries {
7931                    out.push_str(&format!("  {entry}\n"));
7932                }
7933                out.push('\n');
7934
7935                let custom: Vec<&String> = active_entries
7936                    .iter()
7937                    .filter(|e| {
7938                        let t = e.trim_start();
7939                        !t.starts_with("127.") && !t.starts_with("::1") && !t.starts_with("0.0.0.0")
7940                    })
7941                    .collect();
7942                if !custom.is_empty() {
7943                    out.push_str(&format!(
7944                        "[!] Custom (non-loopback) entries: {}\n",
7945                        custom.len()
7946                    ));
7947                    for e in &custom {
7948                        out.push_str(&format!("  {e}\n"));
7949                    }
7950                } else {
7951                    out.push_str("All active entries are standard loopback or block entries.\n");
7952                }
7953            }
7954
7955            out.push_str("\n=== Full file ===\n");
7956            for line in content.lines() {
7957                out.push_str(&format!("  {line}\n"));
7958            }
7959        }
7960        Err(e) => {
7961            out.push_str(&format!("Could not read hosts file: {e}\n"));
7962            if cfg!(target_os = "windows") {
7963                out.push_str(
7964                    "On Windows, run Hematite as Administrator if permission is denied.\n",
7965                );
7966            }
7967        }
7968    }
7969
7970    Ok(out.trim_end().to_string())
7971}
7972
7973// ── docker ────────────────────────────────────────────────────────────────────
7974
7975struct AuditFinding {
7976    finding: String,
7977    impact: String,
7978    fix: String,
7979}
7980
7981#[cfg(target_os = "windows")]
7982#[derive(Debug, Clone)]
7983struct WindowsPnpDevice {
7984    name: String,
7985    status: String,
7986    problem: Option<u64>,
7987    class_name: Option<String>,
7988    instance_id: Option<String>,
7989}
7990
7991#[cfg(target_os = "windows")]
7992#[derive(Debug, Clone)]
7993struct WindowsSoundDevice {
7994    name: String,
7995    status: String,
7996    manufacturer: Option<String>,
7997}
7998
7999struct DockerMountAudit {
8000    mount_type: String,
8001    source: Option<String>,
8002    destination: String,
8003    name: Option<String>,
8004    read_write: Option<bool>,
8005    driver: Option<String>,
8006    exists_on_host: Option<bool>,
8007}
8008
8009struct DockerContainerAudit {
8010    name: String,
8011    image: String,
8012    status: String,
8013    mounts: Vec<DockerMountAudit>,
8014}
8015
8016struct DockerVolumeAudit {
8017    name: String,
8018    driver: String,
8019    mountpoint: Option<String>,
8020    scope: Option<String>,
8021}
8022
8023#[cfg(target_os = "windows")]
8024struct WslDistroAudit {
8025    name: String,
8026    state: String,
8027    version: String,
8028}
8029
8030#[cfg(target_os = "windows")]
8031struct WslRootUsage {
8032    total_kb: u64,
8033    used_kb: u64,
8034    avail_kb: u64,
8035    use_percent: String,
8036    mnt_c_present: Option<bool>,
8037}
8038
8039fn docker_engine_version() -> Result<String, String> {
8040    let version_output = Command::new("docker")
8041        .args(["version", "--format", "{{.Server.Version}}"])
8042        .output();
8043
8044    match version_output {
8045        Err(_) => Err(
8046            "Docker: not found on PATH.\nInstall Docker Desktop: https://www.docker.com/products/docker-desktop".to_string(),
8047        ),
8048        Ok(o) if !o.status.success() => {
8049            let stderr = String::from_utf8_lossy(&o.stderr);
8050            if stderr.contains("cannot connect")
8051                || stderr.contains("Is the docker daemon running")
8052                || stderr.contains("pipe")
8053                || stderr.contains("socket")
8054            {
8055                Err(
8056                    "Docker: installed but daemon is NOT running.\nStart Docker Desktop or run: sudo systemctl start docker".to_string(),
8057                )
8058            } else {
8059                Err(format!("Docker: error - {}", stderr.trim()))
8060            }
8061        }
8062        Ok(o) => Ok(String::from_utf8_lossy(&o.stdout).trim().to_string()),
8063    }
8064}
8065
8066fn parse_docker_mounts(raw: &str) -> Vec<DockerMountAudit> {
8067    let Ok(value) = serde_json::from_str::<Value>(raw.trim()) else {
8068        return Vec::new();
8069    };
8070    let Value::Array(entries) = value else {
8071        return Vec::new();
8072    };
8073
8074    let mut mounts = Vec::new();
8075    for entry in entries {
8076        let mount_type = entry
8077            .get("Type")
8078            .and_then(|v| v.as_str())
8079            .unwrap_or("unknown")
8080            .to_string();
8081        let source = entry
8082            .get("Source")
8083            .and_then(|v| v.as_str())
8084            .map(|v| v.to_string());
8085        let destination = entry
8086            .get("Destination")
8087            .and_then(|v| v.as_str())
8088            .unwrap_or("?")
8089            .to_string();
8090        let name = entry
8091            .get("Name")
8092            .and_then(|v| v.as_str())
8093            .map(|v| v.to_string());
8094        let read_write = entry.get("RW").and_then(|v| v.as_bool());
8095        let driver = entry
8096            .get("Driver")
8097            .and_then(|v| v.as_str())
8098            .map(|v| v.to_string());
8099        let exists_on_host = if mount_type == "bind" {
8100            source.as_deref().map(|path| Path::new(path).exists())
8101        } else {
8102            None
8103        };
8104        mounts.push(DockerMountAudit {
8105            mount_type,
8106            source,
8107            destination,
8108            name,
8109            read_write,
8110            driver,
8111            exists_on_host,
8112        });
8113    }
8114
8115    mounts
8116}
8117
8118fn inspect_docker_volume(name: &str) -> DockerVolumeAudit {
8119    let mut audit = DockerVolumeAudit {
8120        name: name.to_string(),
8121        driver: "unknown".to_string(),
8122        mountpoint: None,
8123        scope: None,
8124    };
8125
8126    if let Ok(output) = Command::new("docker")
8127        .args(["volume", "inspect", name, "--format", "{{json .}}"])
8128        .output()
8129    {
8130        if output.status.success() {
8131            if let Ok(value) =
8132                serde_json::from_str::<Value>(String::from_utf8_lossy(&output.stdout).trim())
8133            {
8134                audit.driver = value
8135                    .get("Driver")
8136                    .and_then(|v| v.as_str())
8137                    .unwrap_or("unknown")
8138                    .to_string();
8139                audit.mountpoint = value
8140                    .get("Mountpoint")
8141                    .and_then(|v| v.as_str())
8142                    .map(|v| v.to_string());
8143                audit.scope = value
8144                    .get("Scope")
8145                    .and_then(|v| v.as_str())
8146                    .map(|v| v.to_string());
8147            }
8148        }
8149    }
8150
8151    audit
8152}
8153
8154#[cfg(target_os = "windows")]
8155fn docker_desktop_disk_image() -> Option<(PathBuf, u64)> {
8156    let local_app_data = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)?;
8157    for file_name in ["docker_data.vhdx", "ext4.vhdx"] {
8158        let path = local_app_data
8159            .join("Docker")
8160            .join("wsl")
8161            .join("disk")
8162            .join(file_name);
8163        if let Ok(metadata) = fs::metadata(&path) {
8164            return Some((path, metadata.len()));
8165        }
8166    }
8167    None
8168}
8169
8170#[cfg(target_os = "windows")]
8171fn clean_wsl_text(raw: &[u8]) -> String {
8172    String::from_utf8_lossy(raw)
8173        .chars()
8174        .filter(|c| *c != '\0')
8175        .collect()
8176}
8177
8178#[cfg(target_os = "windows")]
8179fn parse_wsl_distros(raw: &str) -> Vec<WslDistroAudit> {
8180    let mut distros = Vec::new();
8181    for line in raw.lines() {
8182        let trimmed = line.trim();
8183        if trimmed.is_empty()
8184            || trimmed.to_uppercase().starts_with("NAME")
8185            || trimmed.starts_with("---")
8186        {
8187            continue;
8188        }
8189        let normalized = trimmed.trim_start_matches('*').trim();
8190        let cols: Vec<&str> = normalized.split_whitespace().collect();
8191        if cols.len() < 3 {
8192            continue;
8193        }
8194        let version = cols[cols.len() - 1].to_string();
8195        let state = cols[cols.len() - 2].to_string();
8196        let name = cols[..cols.len() - 2].join(" ");
8197        if !name.is_empty() {
8198            distros.push(WslDistroAudit {
8199                name,
8200                state,
8201                version,
8202            });
8203        }
8204    }
8205    distros
8206}
8207
8208#[cfg(target_os = "windows")]
8209fn wsl_root_usage(distro_name: &str) -> Option<WslRootUsage> {
8210    let output = Command::new("wsl")
8211        .args([
8212            "-d",
8213            distro_name,
8214            "--",
8215            "sh",
8216            "-lc",
8217            "df -k / 2>/dev/null | tail -n 1; if [ -d /mnt/c ]; then echo __MNTC__:ok; else echo __MNTC__:missing; fi",
8218        ])
8219        .output()
8220        .ok()?;
8221    if !output.status.success() {
8222        return None;
8223    }
8224
8225    let text = clean_wsl_text(&output.stdout);
8226    let mut total_kb = 0;
8227    let mut used_kb = 0;
8228    let mut avail_kb = 0;
8229    let mut use_percent = String::from("unknown");
8230    let mut mnt_c_present = None;
8231
8232    for line in text.lines() {
8233        let trimmed = line.trim();
8234        if trimmed.starts_with("__MNTC__:") {
8235            mnt_c_present = Some(trimmed.ends_with("ok"));
8236            continue;
8237        }
8238        let cols: Vec<&str> = trimmed.split_whitespace().collect();
8239        if cols.len() >= 6 {
8240            total_kb = cols[1].parse::<u64>().unwrap_or(0);
8241            used_kb = cols[2].parse::<u64>().unwrap_or(0);
8242            avail_kb = cols[3].parse::<u64>().unwrap_or(0);
8243            use_percent = cols[4].to_string();
8244        }
8245    }
8246
8247    Some(WslRootUsage {
8248        total_kb,
8249        used_kb,
8250        avail_kb,
8251        use_percent,
8252        mnt_c_present,
8253    })
8254}
8255
8256#[cfg(target_os = "windows")]
8257fn collect_wsl_vhdx_files() -> Vec<(PathBuf, u64)> {
8258    let mut vhds = Vec::new();
8259    let Some(local_app_data) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) else {
8260        return vhds;
8261    };
8262    let packages_dir = local_app_data.join("Packages");
8263    let Ok(entries) = fs::read_dir(packages_dir) else {
8264        return vhds;
8265    };
8266
8267    for entry in entries.flatten() {
8268        let path = entry.path().join("LocalState").join("ext4.vhdx");
8269        if let Ok(metadata) = fs::metadata(&path) {
8270            vhds.push((path, metadata.len()));
8271        }
8272    }
8273    vhds.sort_by(|a, b| b.1.cmp(&a.1));
8274    vhds
8275}
8276
8277fn inspect_docker(max_entries: usize) -> Result<String, String> {
8278    let mut out = String::from("Host inspection: docker\n\n");
8279    let n = max_entries.clamp(5, 25);
8280
8281    let version_output = Command::new("docker")
8282        .args(["version", "--format", "{{.Server.Version}}"])
8283        .output();
8284
8285    match version_output {
8286        Err(_) => {
8287            out.push_str("Docker: not found on PATH.\n");
8288            out.push_str(
8289                "Install Docker Desktop: https://www.docker.com/products/docker-desktop\n",
8290            );
8291            return Ok(out.trim_end().to_string());
8292        }
8293        Ok(o) if !o.status.success() => {
8294            let stderr = String::from_utf8_lossy(&o.stderr);
8295            if stderr.contains("cannot connect")
8296                || stderr.contains("Is the docker daemon running")
8297                || stderr.contains("pipe")
8298                || stderr.contains("socket")
8299            {
8300                out.push_str("Docker: installed but daemon is NOT running.\n");
8301                out.push_str("Start Docker Desktop or run: sudo systemctl start docker\n");
8302            } else {
8303                out.push_str(&format!("Docker: error — {}\n", stderr.trim()));
8304            }
8305            return Ok(out.trim_end().to_string());
8306        }
8307        Ok(o) => {
8308            let version = String::from_utf8_lossy(&o.stdout).trim().to_string();
8309            out.push_str(&format!("Docker Engine: {version}\n"));
8310        }
8311    }
8312
8313    if let Ok(o) = Command::new("docker")
8314        .args([
8315            "info",
8316            "--format",
8317            "Containers: {{.Containers}} (running: {{.ContainersRunning}}, stopped: {{.ContainersStopped}})\nImages: {{.Images}}\nStorage driver: {{.Driver}}\nOS/Arch: {{.OSType}}/{{.Architecture}}\nCPUs: {{.NCPU}}",
8318        ])
8319        .output()
8320    {
8321        let info = String::from_utf8_lossy(&o.stdout);
8322        for line in info.lines() {
8323            let t = line.trim();
8324            if !t.is_empty() {
8325                out.push_str(&format!("  {t}\n"));
8326            }
8327        }
8328        out.push('\n');
8329    }
8330
8331    if let Ok(o) = Command::new("docker")
8332        .args([
8333            "ps",
8334            "--format",
8335            "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
8336        ])
8337        .output()
8338    {
8339        let raw = String::from_utf8_lossy(&o.stdout);
8340        let lines: Vec<&str> = raw.lines().collect();
8341        if lines.len() <= 1 {
8342            out.push_str("Running containers: none\n\n");
8343        } else {
8344            out.push_str(&format!(
8345                "=== Running containers ({}) ===\n",
8346                lines.len().saturating_sub(1)
8347            ));
8348            for line in lines.iter().take(n + 1) {
8349                out.push_str(&format!("  {line}\n"));
8350            }
8351            if lines.len() > n + 1 {
8352                out.push_str(&format!("  ... and {} more\n", lines.len() - n - 1));
8353            }
8354            out.push('\n');
8355        }
8356    }
8357
8358    if let Ok(o) = Command::new("docker")
8359        .args([
8360            "images",
8361            "--format",
8362            "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}",
8363        ])
8364        .output()
8365    {
8366        let raw = String::from_utf8_lossy(&o.stdout);
8367        let lines: Vec<&str> = raw.lines().collect();
8368        if lines.len() > 1 {
8369            out.push_str(&format!(
8370                "=== Local images ({}) ===\n",
8371                lines.len().saturating_sub(1)
8372            ));
8373            for line in lines.iter().take(n + 1) {
8374                out.push_str(&format!("  {line}\n"));
8375            }
8376            if lines.len() > n + 1 {
8377                out.push_str(&format!("  ... and {} more\n", lines.len() - n - 1));
8378            }
8379            out.push('\n');
8380        }
8381    }
8382
8383    if let Ok(o) = Command::new("docker")
8384        .args([
8385            "compose",
8386            "ls",
8387            "--format",
8388            "table {{.Name}}\t{{.Status}}\t{{.ConfigFiles}}",
8389        ])
8390        .output()
8391    {
8392        let raw = String::from_utf8_lossy(&o.stdout);
8393        let lines: Vec<&str> = raw.lines().collect();
8394        if lines.len() > 1 {
8395            out.push_str(&format!(
8396                "=== Compose projects ({}) ===\n",
8397                lines.len().saturating_sub(1)
8398            ));
8399            for line in lines.iter().take(n + 1) {
8400                out.push_str(&format!("  {line}\n"));
8401            }
8402            out.push('\n');
8403        }
8404    }
8405
8406    if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
8407        let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
8408        if !ctx.is_empty() {
8409            out.push_str(&format!("Active context: {ctx}\n"));
8410        }
8411    }
8412
8413    Ok(out.trim_end().to_string())
8414}
8415
8416// ── wsl ───────────────────────────────────────────────────────────────────────
8417
8418fn inspect_docker_filesystems(max_entries: usize) -> Result<String, String> {
8419    let mut out = String::from("Host inspection: docker_filesystems\n\n");
8420    let n = max_entries.clamp(3, 12);
8421
8422    match docker_engine_version() {
8423        Ok(version) => out.push_str(&format!("Docker Engine: {version}\n")),
8424        Err(message) => {
8425            out.push_str(&message);
8426            return Ok(out.trim_end().to_string());
8427        }
8428    }
8429
8430    if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
8431        let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
8432        if !ctx.is_empty() {
8433            out.push_str(&format!("Active context: {ctx}\n"));
8434        }
8435    }
8436    out.push('\n');
8437
8438    let mut containers = Vec::new();
8439    if let Ok(o) = Command::new("docker")
8440        .args([
8441            "ps",
8442            "-a",
8443            "--format",
8444            "{{.Names}}\t{{.Image}}\t{{.Status}}",
8445        ])
8446        .output()
8447    {
8448        for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
8449            let cols: Vec<&str> = line.split('\t').collect();
8450            if cols.len() < 3 {
8451                continue;
8452            }
8453            let name = cols[0].trim().to_string();
8454            if name.is_empty() {
8455                continue;
8456            }
8457            let inspect_output = Command::new("docker")
8458                .args(["inspect", &name, "--format", "{{json .Mounts}}"])
8459                .output();
8460            let mounts = match inspect_output {
8461                Ok(result) if result.status.success() => {
8462                    parse_docker_mounts(String::from_utf8_lossy(&result.stdout).trim())
8463                }
8464                _ => Vec::new(),
8465            };
8466            containers.push(DockerContainerAudit {
8467                name,
8468                image: cols[1].trim().to_string(),
8469                status: cols[2].trim().to_string(),
8470                mounts,
8471            });
8472        }
8473    }
8474
8475    let mut volumes = Vec::new();
8476    if let Ok(o) = Command::new("docker")
8477        .args(["volume", "ls", "--format", "{{.Name}}\t{{.Driver}}"])
8478        .output()
8479    {
8480        for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
8481            let cols: Vec<&str> = line.split('\t').collect();
8482            let Some(name) = cols.first().map(|v| v.trim()).filter(|v| !v.is_empty()) else {
8483                continue;
8484            };
8485            let mut audit = inspect_docker_volume(name);
8486            if audit.driver == "unknown" {
8487                audit.driver = cols
8488                    .get(1)
8489                    .map(|v| v.trim())
8490                    .filter(|v| !v.is_empty())
8491                    .unwrap_or("unknown")
8492                    .to_string();
8493            }
8494            volumes.push(audit);
8495        }
8496    }
8497
8498    let mut findings = Vec::new();
8499    for container in &containers {
8500        for mount in &container.mounts {
8501            if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
8502                let source = mount.source.as_deref().unwrap_or("<unknown>");
8503                findings.push(AuditFinding {
8504                    finding: format!(
8505                        "Container '{}' has a bind mount whose host source is missing: {} -> {}",
8506                        container.name, source, mount.destination
8507                    ),
8508                    impact: "The container may fail to start, or it may see an empty or incomplete directory at the target path.".to_string(),
8509                    fix: "Create the host path or correct the bind source in docker-compose.yml / docker run, then recreate the container.".to_string(),
8510                });
8511            }
8512        }
8513    }
8514
8515    #[cfg(target_os = "windows")]
8516    if let Some((path, size_bytes)) = docker_desktop_disk_image() {
8517        if size_bytes >= 20 * 1024 * 1024 * 1024 {
8518            findings.push(AuditFinding {
8519                finding: format!(
8520                    "Docker Desktop disk image is large: {} at {}",
8521                    human_bytes(size_bytes),
8522                    path.display()
8523                ),
8524                impact: "Unused layers, volumes, and build cache can silently consume Windows disk even after projects are deleted.".to_string(),
8525                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(),
8526            });
8527        }
8528    }
8529
8530    out.push_str("=== Findings ===\n");
8531    if findings.is_empty() {
8532        out.push_str("- Finding: No missing bind-mount sources or oversized Docker Desktop disk images were detected.\n");
8533        out.push_str("  Impact: The Docker host-side filesystem wiring looks sane from this inspection pass.\n");
8534        out.push_str("  Fix: If a workload still cannot see files, compare the mount destinations below against the app's expected paths.\n");
8535    } else {
8536        for finding in &findings {
8537            out.push_str(&format!("- Finding: {}\n", finding.finding));
8538            out.push_str(&format!("  Impact: {}\n", finding.impact));
8539            out.push_str(&format!("  Fix: {}\n", finding.fix));
8540        }
8541    }
8542
8543    out.push_str("\n=== Container mount summary ===\n");
8544    if containers.is_empty() {
8545        out.push_str("- No containers found.\n");
8546    } else {
8547        for container in &containers {
8548            out.push_str(&format!(
8549                "- {} ({}) [{}]\n",
8550                container.name, container.image, container.status
8551            ));
8552            if container.mounts.is_empty() {
8553                out.push_str("  - no mounts reported\n");
8554                continue;
8555            }
8556            for mount in &container.mounts {
8557                let mut source = mount
8558                    .name
8559                    .clone()
8560                    .or_else(|| mount.source.clone())
8561                    .unwrap_or_else(|| "<unknown>".to_string());
8562                if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
8563                    source.push_str(" [missing]");
8564                }
8565                let mut extras = Vec::new();
8566                if let Some(rw) = mount.read_write {
8567                    extras.push(if rw { "rw" } else { "ro" }.to_string());
8568                }
8569                if let Some(driver) = &mount.driver {
8570                    extras.push(format!("driver={driver}"));
8571                }
8572                let extra_suffix = if extras.is_empty() {
8573                    String::new()
8574                } else {
8575                    format!(" ({})", extras.join(", "))
8576                };
8577                out.push_str(&format!(
8578                    "  - {}: {} -> {}{}\n",
8579                    mount.mount_type, source, mount.destination, extra_suffix
8580                ));
8581            }
8582        }
8583    }
8584
8585    out.push_str("\n=== Named volumes ===\n");
8586    if volumes.is_empty() {
8587        out.push_str("- No named volumes found.\n");
8588    } else {
8589        for volume in &volumes {
8590            let mut detail = format!("- {} (driver: {})", volume.name, volume.driver);
8591            if let Some(scope) = &volume.scope {
8592                detail.push_str(&format!(", scope: {scope}"));
8593            }
8594            if let Some(mountpoint) = &volume.mountpoint {
8595                detail.push_str(&format!(", mountpoint: {mountpoint}"));
8596            }
8597            out.push_str(&format!("{detail}\n"));
8598        }
8599    }
8600
8601    #[cfg(target_os = "windows")]
8602    if let Some((path, size_bytes)) = docker_desktop_disk_image() {
8603        out.push_str("\n=== Docker Desktop disk ===\n");
8604        out.push_str(&format!(
8605            "- {} at {}\n",
8606            human_bytes(size_bytes),
8607            path.display()
8608        ));
8609    }
8610
8611    Ok(out.trim_end().to_string())
8612}
8613
8614fn inspect_wsl() -> Result<String, String> {
8615    let mut out = String::from("Host inspection: wsl\n\n");
8616
8617    #[cfg(target_os = "windows")]
8618    {
8619        if let Ok(o) = Command::new("wsl").args(["--version"]).output() {
8620            let raw = String::from_utf8_lossy(&o.stdout);
8621            let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8622            for line in cleaned.lines().take(4) {
8623                let t = line.trim();
8624                if !t.is_empty() {
8625                    out.push_str(&format!("  {t}\n"));
8626                }
8627            }
8628            out.push('\n');
8629        }
8630
8631        let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8632        match list_output {
8633            Err(e) => {
8634                out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
8635                out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8636            }
8637            Ok(o) if !o.status.success() => {
8638                let stderr = String::from_utf8_lossy(&o.stderr);
8639                let cleaned: String = stderr.chars().filter(|c| *c != '\0').collect();
8640                out.push_str(&format!("WSL: error — {}\n", cleaned.trim()));
8641                out.push_str("Run: wsl --install\n");
8642            }
8643            Ok(o) => {
8644                let raw = String::from_utf8_lossy(&o.stdout);
8645                let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8646                let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
8647                let distro_lines: Vec<&str> = lines
8648                    .iter()
8649                    .filter(|l| {
8650                        let t = l.trim();
8651                        !t.is_empty()
8652                            && !t.to_uppercase().starts_with("NAME")
8653                            && !t.starts_with("---")
8654                    })
8655                    .copied()
8656                    .collect();
8657
8658                if distro_lines.is_empty() {
8659                    out.push_str("WSL: installed but no distributions found.\n");
8660                    out.push_str("Install a distro: wsl --install -d Ubuntu\n");
8661                } else {
8662                    out.push_str("=== WSL Distributions ===\n");
8663                    for line in &lines {
8664                        out.push_str(&format!("  {}\n", line.trim()));
8665                    }
8666                    out.push_str(&format!("\nTotal distributions: {}\n", distro_lines.len()));
8667                }
8668            }
8669        }
8670
8671        if let Ok(o) = Command::new("wsl").args(["--status"]).output() {
8672            let raw = String::from_utf8_lossy(&o.stdout);
8673            let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8674            let status_lines: Vec<&str> = cleaned
8675                .lines()
8676                .filter(|l| !l.trim().is_empty())
8677                .take(8)
8678                .collect();
8679            if !status_lines.is_empty() {
8680                out.push_str("\n=== WSL status ===\n");
8681                for line in status_lines {
8682                    out.push_str(&format!("  {}\n", line.trim()));
8683                }
8684            }
8685        }
8686    }
8687
8688    #[cfg(not(target_os = "windows"))]
8689    {
8690        out.push_str("WSL (Windows Subsystem for Linux) is a Windows-only feature.\n");
8691        out.push_str("On Linux/macOS, use native virtualization (KVM, UTM, Parallels) instead.\n");
8692    }
8693
8694    Ok(out.trim_end().to_string())
8695}
8696
8697// ── ssh ───────────────────────────────────────────────────────────────────────
8698
8699fn inspect_wsl_filesystems(max_entries: usize) -> Result<String, String> {
8700    let mut out = String::from("Host inspection: wsl_filesystems\n\n");
8701
8702    #[cfg(target_os = "windows")]
8703    {
8704        let n = max_entries.clamp(3, 12);
8705        let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8706        let distros = match list_output {
8707            Err(e) => {
8708                out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
8709                out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8710                return Ok(out.trim_end().to_string());
8711            }
8712            Ok(o) if !o.status.success() => {
8713                let cleaned = clean_wsl_text(&o.stderr);
8714                out.push_str(&format!("WSL: error - {}\n", cleaned.trim()));
8715                out.push_str("Run: wsl --install\n");
8716                return Ok(out.trim_end().to_string());
8717            }
8718            Ok(o) => parse_wsl_distros(&clean_wsl_text(&o.stdout)),
8719        };
8720
8721        out.push_str(&format!("Distributions detected: {}\n\n", distros.len()));
8722
8723        let vhdx_files = collect_wsl_vhdx_files();
8724        let mut findings = Vec::new();
8725        let mut live_usage = Vec::new();
8726
8727        for distro in distros.iter().take(n) {
8728            if distro.state.eq_ignore_ascii_case("Running") {
8729                if let Some(usage) = wsl_root_usage(&distro.name) {
8730                    if let Some(false) = usage.mnt_c_present {
8731                        findings.push(AuditFinding {
8732                            finding: format!(
8733                                "Distro '{}' is running without /mnt/c available",
8734                                distro.name
8735                            ),
8736                            impact: "Windows to WSL path bridging is broken, so projects under C:\\ may not be reachable from Linux tools.".to_string(),
8737                            fix: "Check /etc/wsl.conf automount settings, restart WSL with `wsl --shutdown`, then confirm drvfs automount is enabled.".to_string(),
8738                        });
8739                    }
8740
8741                    let percent_num = usage
8742                        .use_percent
8743                        .trim_end_matches('%')
8744                        .parse::<u32>()
8745                        .unwrap_or(0);
8746                    if percent_num >= 85 {
8747                        findings.push(AuditFinding {
8748                            finding: format!(
8749                                "Distro '{}' root filesystem is {} full",
8750                                distro.name, usage.use_percent
8751                            ),
8752                            impact: "Package installs, git checkouts, and build caches inside WSL can start failing even when Windows still has free space.".to_string(),
8753                            fix: "Free space inside the distro first, then shut WSL down and compact the VHDX if the host-side file stays large.".to_string(),
8754                        });
8755                    }
8756                    live_usage.push((distro.name.clone(), usage));
8757                }
8758            }
8759        }
8760
8761        for (path, size_bytes) in vhdx_files.iter().take(n) {
8762            if *size_bytes >= 20 * 1024 * 1024 * 1024 {
8763                findings.push(AuditFinding {
8764                    finding: format!(
8765                        "Host-side WSL disk image is large: {} at {}",
8766                        human_bytes(*size_bytes),
8767                        path.display()
8768                    ),
8769                    impact: "Sparse VHDX files can keep consuming Windows disk after files are deleted inside the distro.".to_string(),
8770                    fix: "Clean files inside the distro first, then shut WSL down and compact the VHDX with your normal maintenance workflow.".to_string(),
8771                });
8772            }
8773        }
8774
8775        out.push_str("=== Findings ===\n");
8776        if findings.is_empty() {
8777            out.push_str("- Finding: No oversized WSL disk images or broken /mnt/c bridge mounts were detected in the sampled distros.\n");
8778            out.push_str("  Impact: WSL storage and Windows path bridging look healthy from this inspection pass.\n");
8779            out.push_str("  Fix: If a specific project path still fails, inspect the per-distro bridge and disk details below.\n");
8780        } else {
8781            for finding in &findings {
8782                out.push_str(&format!("- Finding: {}\n", finding.finding));
8783                out.push_str(&format!("  Impact: {}\n", finding.impact));
8784                out.push_str(&format!("  Fix: {}\n", finding.fix));
8785            }
8786        }
8787
8788        out.push_str("\n=== Distro bridge and root usage ===\n");
8789        if distros.is_empty() {
8790            out.push_str("- No WSL distributions found.\n");
8791        } else {
8792            for distro in distros.iter().take(n) {
8793                out.push_str(&format!(
8794                    "- {} [state: {}, version: {}]\n",
8795                    distro.name, distro.state, distro.version
8796                ));
8797                if let Some((_, usage)) = live_usage.iter().find(|(name, _)| name == &distro.name) {
8798                    out.push_str(&format!(
8799                        "  - rootfs: {} used / {} total ({}), free: {}\n",
8800                        human_bytes(usage.used_kb * 1024),
8801                        human_bytes(usage.total_kb * 1024),
8802                        usage.use_percent,
8803                        human_bytes(usage.avail_kb * 1024)
8804                    ));
8805                    match usage.mnt_c_present {
8806                        Some(true) => out.push_str("  - /mnt/c bridge: present\n"),
8807                        Some(false) => out.push_str("  - /mnt/c bridge: missing\n"),
8808                        None => out.push_str("  - /mnt/c bridge: unknown\n"),
8809                    }
8810                } else if distro.state.eq_ignore_ascii_case("Running") {
8811                    out.push_str("  - live rootfs check: unavailable\n");
8812                } else {
8813                    out.push_str(
8814                        "  - live rootfs check: skipped to avoid starting a stopped distro\n",
8815                    );
8816                }
8817            }
8818        }
8819
8820        out.push_str("\n=== Host-side VHDX files ===\n");
8821        if vhdx_files.is_empty() {
8822            out.push_str("- No ext4.vhdx files found under %LOCALAPPDATA%\\Packages. Imported distros may live elsewhere.\n");
8823        } else {
8824            for (path, size_bytes) in vhdx_files.iter().take(n) {
8825                out.push_str(&format!(
8826                    "- {} at {}\n",
8827                    human_bytes(*size_bytes),
8828                    path.display()
8829                ));
8830            }
8831        }
8832    }
8833
8834    #[cfg(not(target_os = "windows"))]
8835    {
8836        let _ = max_entries;
8837        out.push_str("WSL filesystem auditing is a Windows-only inspection.\n");
8838        out.push_str("On Linux/macOS, use native VM/container storage inspection instead.\n");
8839    }
8840
8841    Ok(out.trim_end().to_string())
8842}
8843
8844fn dirs_home() -> Option<PathBuf> {
8845    std::env::var("HOME")
8846        .ok()
8847        .map(PathBuf::from)
8848        .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
8849}
8850
8851fn inspect_ssh() -> Result<String, String> {
8852    let mut out = String::from("Host inspection: ssh\n\n");
8853
8854    if let Ok(o) = Command::new("ssh").args(["-V"]).output() {
8855        let ver = if o.stdout.is_empty() {
8856            String::from_utf8_lossy(&o.stderr).trim().to_string()
8857        } else {
8858            String::from_utf8_lossy(&o.stdout).trim().to_string()
8859        };
8860        if !ver.is_empty() {
8861            out.push_str(&format!("SSH client: {ver}\n"));
8862        }
8863    } else {
8864        out.push_str("SSH client: not found on PATH.\n");
8865    }
8866
8867    #[cfg(target_os = "windows")]
8868    {
8869        let script = r#"
8870$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue
8871if ($svc) { "SSHD:" + $svc.Status + " | StartType:" + $svc.StartType }
8872else { "SSHD:not_installed" }
8873"#;
8874        if let Ok(o) = Command::new("powershell")
8875            .args(["-NoProfile", "-Command", script])
8876            .output()
8877        {
8878            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8879            if text.contains("not_installed") {
8880                out.push_str("SSH server (sshd): not installed\n");
8881            } else {
8882                out.push_str(&format!(
8883                    "SSH server (sshd): {}\n",
8884                    text.trim_start_matches("SSHD:")
8885                ));
8886            }
8887        }
8888    }
8889
8890    #[cfg(not(target_os = "windows"))]
8891    {
8892        if let Ok(o) = Command::new("systemctl")
8893            .args(["is-active", "sshd"])
8894            .output()
8895        {
8896            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8897            out.push_str(&format!("SSH server (sshd): {status}\n"));
8898        } else if let Ok(o) = Command::new("systemctl")
8899            .args(["is-active", "ssh"])
8900            .output()
8901        {
8902            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8903            out.push_str(&format!("SSH server (ssh): {status}\n"));
8904        }
8905    }
8906
8907    out.push('\n');
8908
8909    if let Some(ssh_dir) = dirs_home().map(|h| h.join(".ssh")) {
8910        if ssh_dir.exists() {
8911            out.push_str(&format!("~/.ssh: {}\n", ssh_dir.display()));
8912
8913            let kh = ssh_dir.join("known_hosts");
8914            if kh.exists() {
8915                let count = fs::read_to_string(&kh)
8916                    .map(|c| {
8917                        c.lines()
8918                            .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8919                            .count()
8920                    })
8921                    .unwrap_or(0);
8922                out.push_str(&format!("  known_hosts: {count} entries\n"));
8923            } else {
8924                out.push_str("  known_hosts: not present\n");
8925            }
8926
8927            let ak = ssh_dir.join("authorized_keys");
8928            if ak.exists() {
8929                let count = fs::read_to_string(&ak)
8930                    .map(|c| {
8931                        c.lines()
8932                            .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8933                            .count()
8934                    })
8935                    .unwrap_or(0);
8936                out.push_str(&format!("  authorized_keys: {count} public keys\n"));
8937            } else {
8938                out.push_str("  authorized_keys: not present\n");
8939            }
8940
8941            let key_names = [
8942                "id_rsa",
8943                "id_ed25519",
8944                "id_ecdsa",
8945                "id_dsa",
8946                "id_ecdsa_sk",
8947                "id_ed25519_sk",
8948            ];
8949            let found_keys: Vec<&str> = key_names
8950                .iter()
8951                .filter(|k| ssh_dir.join(k).exists())
8952                .copied()
8953                .collect();
8954            if !found_keys.is_empty() {
8955                out.push_str(&format!("  Private keys: {}\n", found_keys.join(", ")));
8956            } else {
8957                out.push_str("  Private keys: none found\n");
8958            }
8959
8960            let config_path = ssh_dir.join("config");
8961            if config_path.exists() {
8962                out.push_str("\n=== SSH config hosts ===\n");
8963                match fs::read_to_string(&config_path) {
8964                    Ok(content) => {
8965                        let mut hosts: Vec<(String, Vec<String>)> = Vec::new();
8966                        let mut current: Option<(String, Vec<String>)> = None;
8967                        for line in content.lines() {
8968                            let t = line.trim();
8969                            if t.is_empty() || t.starts_with('#') {
8970                                continue;
8971                            }
8972                            if let Some(host) = t.strip_prefix("Host ") {
8973                                if let Some(prev) = current.take() {
8974                                    hosts.push(prev);
8975                                }
8976                                current = Some((host.trim().to_string(), Vec::new()));
8977                            } else if let Some((_, ref mut details)) = current {
8978                                let tu = t.to_uppercase();
8979                                if tu.starts_with("HOSTNAME ")
8980                                    || tu.starts_with("USER ")
8981                                    || tu.starts_with("PORT ")
8982                                    || tu.starts_with("IDENTITYFILE ")
8983                                {
8984                                    details.push(t.to_string());
8985                                }
8986                            }
8987                        }
8988                        if let Some(prev) = current {
8989                            hosts.push(prev);
8990                        }
8991
8992                        if hosts.is_empty() {
8993                            out.push_str("  No Host entries found.\n");
8994                        } else {
8995                            for (h, details) in &hosts {
8996                                if details.is_empty() {
8997                                    out.push_str(&format!("  Host {h}\n"));
8998                                } else {
8999                                    out.push_str(&format!(
9000                                        "  Host {h}  [{}]\n",
9001                                        details.join(", ")
9002                                    ));
9003                                }
9004                            }
9005                            out.push_str(&format!("\n  Total configured hosts: {}\n", hosts.len()));
9006                        }
9007                    }
9008                    Err(e) => out.push_str(&format!("  Could not read config: {e}\n")),
9009                }
9010            } else {
9011                out.push_str("  SSH config: not present\n");
9012            }
9013        } else {
9014            out.push_str("~/.ssh: directory not found (no SSH keys configured).\n");
9015        }
9016    }
9017
9018    Ok(out.trim_end().to_string())
9019}
9020
9021// ── installed_software ────────────────────────────────────────────────────────
9022
9023fn inspect_installed_software(max_entries: usize) -> Result<String, String> {
9024    let mut out = String::from("Host inspection: installed_software\n\n");
9025    let n = max_entries.clamp(10, 50);
9026
9027    #[cfg(target_os = "windows")]
9028    {
9029        let winget_out = Command::new("winget")
9030            .args(["list", "--accept-source-agreements"])
9031            .output();
9032
9033        if let Ok(o) = winget_out {
9034            if o.status.success() {
9035                let raw = String::from_utf8_lossy(&o.stdout);
9036                let mut header_done = false;
9037                let mut packages: Vec<&str> = Vec::new();
9038                for line in raw.lines() {
9039                    let t = line.trim();
9040                    if t.starts_with("---") {
9041                        header_done = true;
9042                        continue;
9043                    }
9044                    if header_done && !t.is_empty() {
9045                        packages.push(line);
9046                    }
9047                }
9048                let total = packages.len();
9049                out.push_str(&format!(
9050                    "=== Installed software via winget ({total} packages) ===\n\n"
9051                ));
9052                for line in packages.iter().take(n) {
9053                    out.push_str(&format!("  {line}\n"));
9054                }
9055                if total > n {
9056                    out.push_str(&format!("\n  ... and {} more packages\n", total - n));
9057                }
9058                out.push_str("\nFor full list: winget list\n");
9059                return Ok(out.trim_end().to_string());
9060            }
9061        }
9062
9063        // Fallback: registry scan
9064        let script = format!(
9065            r#"
9066$apps = @()
9067$reg_paths = @(
9068    'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
9069    'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
9070    'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
9071)
9072foreach ($p in $reg_paths) {{
9073    try {{
9074        $apps += Get-ItemProperty $p -ErrorAction SilentlyContinue |
9075            Where-Object {{ $_.DisplayName }} |
9076            Select-Object DisplayName, DisplayVersion, Publisher
9077    }} catch {{}}
9078}}
9079$sorted = $apps | Sort-Object DisplayName -Unique
9080"TOTAL:" + $sorted.Count
9081$sorted | Select-Object -First {n} | ForEach-Object {{
9082    $_.DisplayName + "|" + $_.DisplayVersion + "|" + $_.Publisher
9083}}
9084"#
9085        );
9086        if let Ok(o) = Command::new("powershell")
9087            .args(["-NoProfile", "-Command", &script])
9088            .output()
9089        {
9090            let raw = String::from_utf8_lossy(&o.stdout);
9091            out.push_str("=== Installed software (registry scan) ===\n");
9092            out.push_str(&format!("  {:<50} {:<18} Publisher\n", "Name", "Version"));
9093            out.push_str(&format!("  {}\n", "-".repeat(90)));
9094            for line in raw.lines() {
9095                if let Some(rest) = line.strip_prefix("TOTAL:") {
9096                    let total: usize = rest.trim().parse().unwrap_or(0);
9097                    out.push_str(&format!("  (Total: {total}, showing first {n})\n\n"));
9098                } else if !line.trim().is_empty() {
9099                    let parts: Vec<&str> = line.splitn(3, '|').collect();
9100                    let name = parts.first().map(|s| s.trim()).unwrap_or("");
9101                    let ver = parts.get(1).map(|s| s.trim()).unwrap_or("");
9102                    let pub_ = parts.get(2).map(|s| s.trim()).unwrap_or("");
9103                    out.push_str(&format!("  {:<50} {:<18} {pub_}\n", name, ver));
9104                }
9105            }
9106        } else {
9107            out.push_str(
9108                "Could not query installed software (winget and registry scan both failed).\n",
9109            );
9110        }
9111    }
9112
9113    #[cfg(target_os = "linux")]
9114    {
9115        let mut found = false;
9116        if let Ok(o) = Command::new("dpkg").args(["--get-selections"]).output() {
9117            if o.status.success() {
9118                let raw = String::from_utf8_lossy(&o.stdout);
9119                let installed: Vec<&str> = raw.lines().filter(|l| l.contains("install")).collect();
9120                let total = installed.len();
9121                out.push_str(&format!("=== Installed packages via dpkg ({total}) ===\n"));
9122                for line in installed.iter().take(n) {
9123                    out.push_str(&format!("  {}\n", line.trim()));
9124                }
9125                if total > n {
9126                    out.push_str(&format!("  ... and {} more\n", total - n));
9127                }
9128                out.push_str("\nFor full list: dpkg --get-selections | grep install\n");
9129                found = true;
9130            }
9131        }
9132        if !found {
9133            if let Ok(o) = Command::new("rpm")
9134                .args(["-qa", "--queryformat", "%{NAME} %{VERSION}\n"])
9135                .output()
9136            {
9137                if o.status.success() {
9138                    let raw = String::from_utf8_lossy(&o.stdout);
9139                    let lines: Vec<&str> = raw.lines().collect();
9140                    let total = lines.len();
9141                    out.push_str(&format!("=== Installed packages via rpm ({total}) ===\n"));
9142                    for line in lines.iter().take(n) {
9143                        out.push_str(&format!("  {line}\n"));
9144                    }
9145                    if total > n {
9146                        out.push_str(&format!("  ... and {} more\n", total - n));
9147                    }
9148                    found = true;
9149                }
9150            }
9151        }
9152        if !found {
9153            if let Ok(o) = Command::new("pacman").args(["-Q"]).output() {
9154                if o.status.success() {
9155                    let raw = String::from_utf8_lossy(&o.stdout);
9156                    let lines: Vec<&str> = raw.lines().collect();
9157                    let total = lines.len();
9158                    out.push_str(&format!(
9159                        "=== Installed packages via pacman ({total}) ===\n"
9160                    ));
9161                    for line in lines.iter().take(n) {
9162                        out.push_str(&format!("  {line}\n"));
9163                    }
9164                    if total > n {
9165                        out.push_str(&format!("  ... and {} more\n", total - n));
9166                    }
9167                    found = true;
9168                }
9169            }
9170        }
9171        if !found {
9172            out.push_str("No package manager found (tried dpkg, rpm, pacman).\n");
9173        }
9174    }
9175
9176    #[cfg(target_os = "macos")]
9177    {
9178        if let Ok(o) = Command::new("brew").args(["list", "--versions"]).output() {
9179            if o.status.success() {
9180                let raw = String::from_utf8_lossy(&o.stdout);
9181                let lines: Vec<&str> = raw.lines().collect();
9182                let total = lines.len();
9183                out.push_str(&format!("=== Homebrew packages ({total}) ===\n"));
9184                for line in lines.iter().take(n) {
9185                    out.push_str(&format!("  {line}\n"));
9186                }
9187                if total > n {
9188                    out.push_str(&format!("  ... and {} more\n", total - n));
9189                }
9190                out.push_str("\nFor full list: brew list --versions\n");
9191            }
9192        } else {
9193            out.push_str("Homebrew not found.\n");
9194        }
9195        if let Ok(o) = Command::new("mas").args(["list"]).output() {
9196            if o.status.success() {
9197                let raw = String::from_utf8_lossy(&o.stdout);
9198                let lines: Vec<&str> = raw.lines().collect();
9199                out.push_str(&format!("\n=== Mac App Store apps ({}) ===\n", lines.len()));
9200                for line in lines.iter().take(n) {
9201                    out.push_str(&format!("  {line}\n"));
9202                }
9203            }
9204        }
9205    }
9206
9207    Ok(out.trim_end().to_string())
9208}
9209
9210// ── git_config ────────────────────────────────────────────────────────────────
9211
9212fn inspect_git_config() -> Result<String, String> {
9213    let mut out = String::from("Host inspection: git_config\n\n");
9214
9215    if let Ok(o) = Command::new("git").args(["--version"]).output() {
9216        let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
9217        out.push_str(&format!("Git: {ver}\n\n"));
9218    } else {
9219        out.push_str("Git: not found on PATH.\n");
9220        return Ok(out.trim_end().to_string());
9221    }
9222
9223    if let Ok(o) = Command::new("git")
9224        .args(["config", "--global", "--list"])
9225        .output()
9226    {
9227        if o.status.success() {
9228            let raw = String::from_utf8_lossy(&o.stdout);
9229            let mut pairs: Vec<(String, String)> = raw
9230                .lines()
9231                .filter_map(|l| {
9232                    let mut parts = l.splitn(2, '=');
9233                    let k = parts.next()?.trim().to_string();
9234                    let v = parts.next().unwrap_or("").trim().to_string();
9235                    Some((k, v))
9236                })
9237                .collect();
9238            pairs.sort_by(|a, b| a.0.cmp(&b.0));
9239
9240            out.push_str("=== Global git config ===\n");
9241
9242            let sections: &[(&str, &[&str])] = &[
9243                ("Identity", &["user.name", "user.email", "user.signingkey"]),
9244                (
9245                    "Core",
9246                    &[
9247                        "core.editor",
9248                        "core.autocrlf",
9249                        "core.eol",
9250                        "core.ignorecase",
9251                        "core.filemode",
9252                    ],
9253                ),
9254                (
9255                    "Commit/Signing",
9256                    &[
9257                        "commit.gpgsign",
9258                        "tag.gpgsign",
9259                        "gpg.format",
9260                        "gpg.ssh.allowedsignersfile",
9261                    ],
9262                ),
9263                (
9264                    "Push/Pull",
9265                    &[
9266                        "push.default",
9267                        "push.autosetupremote",
9268                        "pull.rebase",
9269                        "pull.ff",
9270                    ],
9271                ),
9272                ("Credential", &["credential.helper"]),
9273                ("Branch", &["init.defaultbranch", "branch.autosetuprebase"]),
9274            ];
9275
9276            let mut shown_keys: HashSet<String> = HashSet::new();
9277            for (section, keys) in sections {
9278                let mut section_lines: Vec<String> = Vec::new();
9279                for key in *keys {
9280                    if let Some((k, v)) = pairs.iter().find(|(kk, _)| kk == key) {
9281                        section_lines.push(format!("  {k} = {v}"));
9282                        shown_keys.insert(k.clone());
9283                    }
9284                }
9285                if !section_lines.is_empty() {
9286                    out.push_str(&format!("\n[{section}]\n"));
9287                    for line in section_lines {
9288                        out.push_str(&format!("{line}\n"));
9289                    }
9290                }
9291            }
9292
9293            let other: Vec<&(String, String)> = pairs
9294                .iter()
9295                .filter(|(k, _)| !shown_keys.contains(k) && !k.starts_with("alias."))
9296                .collect();
9297            if !other.is_empty() {
9298                out.push_str("\n[Other]\n");
9299                for (k, v) in other.iter().take(20) {
9300                    out.push_str(&format!("  {k} = {v}\n"));
9301                }
9302                if other.len() > 20 {
9303                    out.push_str(&format!("  ... and {} more\n", other.len() - 20));
9304                }
9305            }
9306
9307            out.push_str(&format!("\nTotal global config keys: {}\n", pairs.len()));
9308        } else {
9309            out.push_str("No global git config found.\n");
9310            out.push_str("Set up with:\n");
9311            out.push_str("  git config --global user.name \"Your Name\"\n");
9312            out.push_str("  git config --global user.email \"you@example.com\"\n");
9313        }
9314    }
9315
9316    if let Ok(o) = Command::new("git")
9317        .args(["config", "--local", "--list"])
9318        .output()
9319    {
9320        if o.status.success() {
9321            let raw = String::from_utf8_lossy(&o.stdout);
9322            let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
9323            if !lines.is_empty() {
9324                out.push_str(&format!(
9325                    "\n=== Local repo config ({} keys) ===\n",
9326                    lines.len()
9327                ));
9328                for line in lines.iter().take(15) {
9329                    out.push_str(&format!("  {line}\n"));
9330                }
9331                if lines.len() > 15 {
9332                    out.push_str(&format!("  ... and {} more\n", lines.len() - 15));
9333                }
9334            }
9335        }
9336    }
9337
9338    if let Ok(o) = Command::new("git")
9339        .args(["config", "--global", "--get-regexp", r"alias\."])
9340        .output()
9341    {
9342        if o.status.success() {
9343            let raw = String::from_utf8_lossy(&o.stdout);
9344            let aliases: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
9345            if !aliases.is_empty() {
9346                out.push_str(&format!("\n=== Git aliases ({}) ===\n", aliases.len()));
9347                for a in aliases.iter().take(20) {
9348                    out.push_str(&format!("  {a}\n"));
9349                }
9350                if aliases.len() > 20 {
9351                    out.push_str(&format!("  ... and {} more\n", aliases.len() - 20));
9352                }
9353            }
9354        }
9355    }
9356
9357    Ok(out.trim_end().to_string())
9358}
9359
9360// ── databases ─────────────────────────────────────────────────────────────────
9361
9362fn inspect_databases() -> Result<String, String> {
9363    let mut out = String::from("Host inspection: databases\n\n");
9364    out.push_str("Scanning for local database engines (service state, port, version)...\n\n");
9365
9366    struct DbEngine {
9367        name: &'static str,
9368        service_names: &'static [&'static str],
9369        default_port: u16,
9370        cli_name: &'static str,
9371        cli_version_args: &'static [&'static str],
9372    }
9373
9374    let engines: &[DbEngine] = &[
9375        DbEngine {
9376            name: "PostgreSQL",
9377            service_names: &[
9378                "postgresql",
9379                "postgresql-x64-14",
9380                "postgresql-x64-15",
9381                "postgresql-x64-16",
9382                "postgresql-x64-17",
9383            ],
9384
9385            default_port: 5432,
9386            cli_name: "psql",
9387            cli_version_args: &["--version"],
9388        },
9389        DbEngine {
9390            name: "MySQL",
9391            service_names: &["mysql", "mysql80", "mysql57"],
9392
9393            default_port: 3306,
9394            cli_name: "mysql",
9395            cli_version_args: &["--version"],
9396        },
9397        DbEngine {
9398            name: "MariaDB",
9399            service_names: &["mariadb", "mariadb.exe"],
9400
9401            default_port: 3306,
9402            cli_name: "mariadb",
9403            cli_version_args: &["--version"],
9404        },
9405        DbEngine {
9406            name: "MongoDB",
9407            service_names: &["mongodb", "mongod"],
9408
9409            default_port: 27017,
9410            cli_name: "mongod",
9411            cli_version_args: &["--version"],
9412        },
9413        DbEngine {
9414            name: "Redis",
9415            service_names: &["redis", "redis-server"],
9416
9417            default_port: 6379,
9418            cli_name: "redis-server",
9419            cli_version_args: &["--version"],
9420        },
9421        DbEngine {
9422            name: "SQL Server",
9423            service_names: &["mssqlserver", "mssql$sqlexpress", "mssql$localdb"],
9424
9425            default_port: 1433,
9426            cli_name: "sqlcmd",
9427            cli_version_args: &["-?"],
9428        },
9429        DbEngine {
9430            name: "SQLite",
9431            service_names: &[], // no service — file-based
9432
9433            default_port: 0, // no port — file-based
9434            cli_name: "sqlite3",
9435            cli_version_args: &["--version"],
9436        },
9437        DbEngine {
9438            name: "CouchDB",
9439            service_names: &["couchdb", "apache-couchdb"],
9440
9441            default_port: 5984,
9442            cli_name: "couchdb",
9443            cli_version_args: &["--version"],
9444        },
9445        DbEngine {
9446            name: "Cassandra",
9447            service_names: &["cassandra"],
9448
9449            default_port: 9042,
9450            cli_name: "cqlsh",
9451            cli_version_args: &["--version"],
9452        },
9453        DbEngine {
9454            name: "Elasticsearch",
9455            service_names: &["elasticsearch-service-x64", "elasticsearch"],
9456
9457            default_port: 9200,
9458            cli_name: "elasticsearch",
9459            cli_version_args: &["--version"],
9460        },
9461    ];
9462
9463    // Helper: check if port is listening
9464    fn port_listening(port: u16) -> bool {
9465        if port == 0 {
9466            return false;
9467        }
9468        // Use netstat-style check via connecting
9469        std::net::TcpStream::connect_timeout(
9470            &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
9471            std::time::Duration::from_millis(150),
9472        )
9473        .is_ok()
9474    }
9475
9476    let mut found_any = false;
9477
9478    for engine in engines {
9479        let mut status_parts: Vec<String> = Vec::new();
9480        let mut detected = false;
9481
9482        // 1. CLI version check (fastest — works cross-platform)
9483        let version = Command::new(engine.cli_name)
9484            .args(engine.cli_version_args)
9485            .output()
9486            .ok()
9487            .and_then(|o| {
9488                let combined = if o.stdout.is_empty() {
9489                    String::from_utf8_lossy(&o.stderr).trim().to_string()
9490                } else {
9491                    String::from_utf8_lossy(&o.stdout).trim().to_string()
9492                };
9493                // Take just the first line
9494                combined.lines().next().map(|l| l.trim().to_string())
9495            });
9496
9497        if let Some(ref ver) = version {
9498            if !ver.is_empty() {
9499                status_parts.push(format!("version: {ver}"));
9500                detected = true;
9501            }
9502        }
9503
9504        // 2. Port check
9505        if engine.default_port > 0 && port_listening(engine.default_port) {
9506            status_parts.push(format!("listening on :{}", engine.default_port));
9507            detected = true;
9508        } else if engine.default_port > 0 && detected {
9509            status_parts.push(format!("not listening on :{}", engine.default_port));
9510        }
9511
9512        // 3. Windows service check
9513        #[cfg(target_os = "windows")]
9514        {
9515            if !engine.service_names.is_empty() {
9516                let service_list = engine.service_names.join("','");
9517                let script = format!(
9518                    r#"$names = @('{}'); foreach ($n in $names) {{ $s = Get-Service -Name $n -ErrorAction SilentlyContinue; if ($s) {{ $n + ':' + $s.Status; break }} }}"#,
9519                    service_list
9520                );
9521                if let Ok(o) = Command::new("powershell")
9522                    .args(["-NoProfile", "-Command", &script])
9523                    .output()
9524                {
9525                    let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
9526                    if !text.is_empty() {
9527                        let parts: Vec<&str> = text.splitn(2, ':').collect();
9528                        let svc_name = parts.first().map(|s| s.trim()).unwrap_or("");
9529                        let svc_state = parts.get(1).map(|s| s.trim()).unwrap_or("unknown");
9530                        status_parts.push(format!("service '{svc_name}': {svc_state}"));
9531                        detected = true;
9532                    }
9533                }
9534            }
9535        }
9536
9537        // 4. Linux/macOS systemctl / launchctl check
9538        #[cfg(not(target_os = "windows"))]
9539        {
9540            for svc in engine.service_names {
9541                if let Ok(o) = Command::new("systemctl").args(["is-active", svc]).output() {
9542                    let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
9543                    if !state.is_empty() && state != "inactive" {
9544                        status_parts.push(format!("systemd '{svc}': {state}"));
9545                        detected = true;
9546                        break;
9547                    }
9548                }
9549            }
9550        }
9551
9552        if detected {
9553            found_any = true;
9554            let label = if engine.default_port > 0 {
9555                format!("{} (default port: {})", engine.name, engine.default_port)
9556            } else {
9557                format!("{} (file-based, no port)", engine.name)
9558            };
9559            out.push_str(&format!("[FOUND] {label}\n"));
9560            for part in &status_parts {
9561                out.push_str(&format!("  {part}\n"));
9562            }
9563            out.push('\n');
9564        }
9565    }
9566
9567    if !found_any {
9568        out.push_str("No local database engines detected.\n");
9569        out.push_str("(Checked: PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQL Server, SQLite, CouchDB, Cassandra, Elasticsearch)\n\n");
9570        out.push_str(
9571            "Note: databases running inside Docker containers are listed under topic='docker'.\n",
9572        );
9573    } else {
9574        out.push_str("---\n");
9575        out.push_str(
9576            "Note: databases running inside Docker containers are listed under topic='docker'.\n",
9577        );
9578        out.push_str("This topic checks service state and port reachability only — no credentials or queries are used.\n");
9579    }
9580
9581    Ok(out.trim_end().to_string())
9582}
9583
9584// ── user_accounts ─────────────────────────────────────────────────────────────
9585
9586fn inspect_user_accounts(max_entries: usize) -> Result<String, String> {
9587    let mut out = String::from("Host inspection: user_accounts\n\n");
9588
9589    #[cfg(target_os = "windows")]
9590    {
9591        let users_out = Command::new("powershell")
9592            .args([
9593                "-NoProfile", "-NonInteractive", "-Command",
9594                "Get-LocalUser | ForEach-Object { $logon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'never' }; \"  $($_.Name) | Enabled: $($_.Enabled) | LastLogon: $logon | PwdRequired: $($_.PasswordRequired) | $($_.Description)\" }",
9595            ])
9596            .output()
9597            .ok()
9598            .and_then(|o| String::from_utf8(o.stdout).ok())
9599            .unwrap_or_default();
9600
9601        out.push_str("=== Local User Accounts ===\n");
9602        if users_out.trim().is_empty() {
9603            out.push_str("  (requires elevation or Get-LocalUser unavailable)\n");
9604        } else {
9605            for line in users_out.lines().take(max_entries) {
9606                if !line.trim().is_empty() {
9607                    out.push_str(line);
9608                    out.push('\n');
9609                }
9610            }
9611        }
9612
9613        let admins_out = Command::new("powershell")
9614            .args([
9615                "-NoProfile", "-NonInteractive", "-Command",
9616                "Get-LocalGroupMember -Group 'Administrators' 2>$null | ForEach-Object { \"  $($_.ObjectClass): $($_.Name)\" }",
9617            ])
9618            .output()
9619            .ok()
9620            .and_then(|o| String::from_utf8(o.stdout).ok())
9621            .unwrap_or_default();
9622
9623        out.push_str("\n=== Administrators Group Members ===\n");
9624        if admins_out.trim().is_empty() {
9625            out.push_str("  (unable to retrieve)\n");
9626        } else {
9627            out.push_str(admins_out.trim());
9628            out.push('\n');
9629        }
9630
9631        let sessions_out = Command::new("powershell")
9632            .args([
9633                "-NoProfile",
9634                "-NonInteractive",
9635                "-Command",
9636                "query user 2>$null",
9637            ])
9638            .output()
9639            .ok()
9640            .and_then(|o| String::from_utf8(o.stdout).ok())
9641            .unwrap_or_default();
9642
9643        out.push_str("\n=== Active Logon Sessions ===\n");
9644        if sessions_out.trim().is_empty() {
9645            out.push_str("  (none or requires elevation)\n");
9646        } else {
9647            for line in sessions_out.lines().take(max_entries) {
9648                if !line.trim().is_empty() {
9649                    out.push_str(&format!("  {}\n", line));
9650                }
9651            }
9652        }
9653
9654        let is_admin = Command::new("powershell")
9655            .args([
9656                "-NoProfile", "-NonInteractive", "-Command",
9657                "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
9658            ])
9659            .output()
9660            .ok()
9661            .and_then(|o| String::from_utf8(o.stdout).ok())
9662            .map(|s| s.trim().to_lowercase())
9663            .unwrap_or_default();
9664
9665        out.push_str("\n=== Current Session Elevation ===\n");
9666        out.push_str(&format!(
9667            "  Running as Administrator: {}\n",
9668            if is_admin.contains("true") {
9669                "YES"
9670            } else {
9671                "no"
9672            }
9673        ));
9674    }
9675
9676    #[cfg(not(target_os = "windows"))]
9677    {
9678        let who_out = Command::new("who")
9679            .output()
9680            .ok()
9681            .and_then(|o| String::from_utf8(o.stdout).ok())
9682            .unwrap_or_default();
9683        out.push_str("=== Active Sessions ===\n");
9684        if who_out.trim().is_empty() {
9685            out.push_str("  (none)\n");
9686        } else {
9687            for line in who_out.lines().take(max_entries) {
9688                out.push_str(&format!("  {}\n", line));
9689            }
9690        }
9691        let id_out = Command::new("id")
9692            .output()
9693            .ok()
9694            .and_then(|o| String::from_utf8(o.stdout).ok())
9695            .unwrap_or_default();
9696        out.push_str(&format!("\n=== Current User ===\n  {}\n", id_out.trim()));
9697    }
9698
9699    Ok(out.trim_end().to_string())
9700}
9701
9702// ── audit_policy ──────────────────────────────────────────────────────────────
9703
9704fn inspect_audit_policy() -> Result<String, String> {
9705    let mut out = String::from("Host inspection: audit_policy\n\n");
9706
9707    #[cfg(target_os = "windows")]
9708    {
9709        let auditpol_out = Command::new("auditpol")
9710            .args(["/get", "/category:*"])
9711            .output()
9712            .ok()
9713            .and_then(|o| String::from_utf8(o.stdout).ok())
9714            .unwrap_or_default();
9715
9716        if auditpol_out.trim().is_empty()
9717            || auditpol_out.to_lowercase().contains("access is denied")
9718        {
9719            out.push_str("Audit policy requires Administrator elevation to read.\n");
9720            out.push_str(
9721                "Run Hematite as Administrator, or check manually: auditpol /get /category:*\n",
9722            );
9723        } else {
9724            out.push_str("=== Windows Audit Policy ===\n");
9725            let mut any_enabled = false;
9726            for line in auditpol_out.lines() {
9727                let trimmed = line.trim();
9728                if trimmed.is_empty() {
9729                    continue;
9730                }
9731                if trimmed.contains("Success") || trimmed.contains("Failure") {
9732                    out.push_str(&format!("  [ENABLED] {}\n", trimmed));
9733                    any_enabled = true;
9734                } else {
9735                    out.push_str(&format!("  {}\n", trimmed));
9736                }
9737            }
9738            if !any_enabled {
9739                out.push_str("\n[WARNING] No audit categories are enabled — security events will not be logged.\n");
9740                out.push_str(
9741                    "Minimum recommended: enable Logon/Logoff and Account Logon success+failure.\n",
9742                );
9743            }
9744        }
9745
9746        let evtlog = Command::new("powershell")
9747            .args([
9748                "-NoProfile", "-NonInteractive", "-Command",
9749                "Get-Service EventLog -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Status",
9750            ])
9751            .output()
9752            .ok()
9753            .and_then(|o| String::from_utf8(o.stdout).ok())
9754            .map(|s| s.trim().to_string())
9755            .unwrap_or_default();
9756
9757        out.push_str(&format!(
9758            "\n=== Windows Event Log Service ===\n  Status: {}\n",
9759            if evtlog.is_empty() {
9760                "unknown".to_string()
9761            } else {
9762                evtlog
9763            }
9764        ));
9765    }
9766
9767    #[cfg(not(target_os = "windows"))]
9768    {
9769        let auditd_status = Command::new("systemctl")
9770            .args(["is-active", "auditd"])
9771            .output()
9772            .ok()
9773            .and_then(|o| String::from_utf8(o.stdout).ok())
9774            .map(|s| s.trim().to_string())
9775            .unwrap_or_else(|| "not found".to_string());
9776
9777        out.push_str(&format!(
9778            "=== auditd service ===\n  Status: {}\n",
9779            auditd_status
9780        ));
9781
9782        if auditd_status == "active" {
9783            let rules = Command::new("auditctl")
9784                .args(["-l"])
9785                .output()
9786                .ok()
9787                .and_then(|o| String::from_utf8(o.stdout).ok())
9788                .unwrap_or_default();
9789            out.push_str("\n=== Active Audit Rules ===\n");
9790            if rules.trim().is_empty() || rules.contains("No rules") {
9791                out.push_str("  No rules configured.\n");
9792            } else {
9793                for line in rules.lines() {
9794                    out.push_str(&format!("  {}\n", line));
9795                }
9796            }
9797        }
9798    }
9799
9800    Ok(out.trim_end().to_string())
9801}
9802
9803// ── shares ────────────────────────────────────────────────────────────────────
9804
9805fn inspect_shares(max_entries: usize) -> Result<String, String> {
9806    let mut out = String::from("Host inspection: shares\n\n");
9807
9808    #[cfg(target_os = "windows")]
9809    {
9810        let smb_out = Command::new("powershell")
9811            .args([
9812                "-NoProfile", "-NonInteractive", "-Command",
9813                "Get-SmbShare | ForEach-Object { \"  $($_.Name) | Path: $($_.Path) | State: $($_.ShareState) | Encrypted: $($_.EncryptData) | $($_.Description)\" }",
9814            ])
9815            .output()
9816            .ok()
9817            .and_then(|o| String::from_utf8(o.stdout).ok())
9818            .unwrap_or_default();
9819
9820        out.push_str("=== SMB Shares (exposed by this machine) ===\n");
9821        let smb_lines: Vec<&str> = smb_out
9822            .lines()
9823            .filter(|l| !l.trim().is_empty())
9824            .take(max_entries)
9825            .collect();
9826        if smb_lines.is_empty() {
9827            out.push_str("  No SMB shares or unable to retrieve.\n");
9828        } else {
9829            for line in &smb_lines {
9830                let name = line.trim().split('|').next().unwrap_or("").trim();
9831                if name.ends_with('$') {
9832                    out.push_str(&format!("  {}\n", line.trim()));
9833                } else {
9834                    out.push_str(&format!("  [CUSTOM] {}\n", line.trim()));
9835                }
9836            }
9837        }
9838
9839        let smb_security = Command::new("powershell")
9840            .args([
9841                "-NoProfile", "-NonInteractive", "-Command",
9842                "Get-SmbServerConfiguration | ForEach-Object { \"  SMB1: $($_.EnableSMB1Protocol) | SMB2: $($_.EnableSMB2Protocol) | Signing Required: $($_.RequireSecuritySignature) | Encryption: $($_.EncryptData)\" }",
9843            ])
9844            .output()
9845            .ok()
9846            .and_then(|o| String::from_utf8(o.stdout).ok())
9847            .unwrap_or_default();
9848
9849        out.push_str("\n=== SMB Server Security Settings ===\n");
9850        if smb_security.trim().is_empty() {
9851            out.push_str("  (unable to retrieve)\n");
9852        } else {
9853            out.push_str(smb_security.trim());
9854            out.push('\n');
9855            if smb_security.to_lowercase().contains("smb1: true") {
9856                out.push_str("  [WARNING] SMB1 is ENABLED — disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\n");
9857            }
9858        }
9859
9860        let drives_out = Command::new("powershell")
9861            .args([
9862                "-NoProfile", "-NonInteractive", "-Command",
9863                "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { \"  $($_.Name): -> $($_.DisplayRoot)\" }",
9864            ])
9865            .output()
9866            .ok()
9867            .and_then(|o| String::from_utf8(o.stdout).ok())
9868            .unwrap_or_default();
9869
9870        out.push_str("\n=== Mapped Network Drives ===\n");
9871        if drives_out.trim().is_empty() {
9872            out.push_str("  None.\n");
9873        } else {
9874            for line in drives_out.lines().take(max_entries) {
9875                if !line.trim().is_empty() {
9876                    out.push_str(line);
9877                    out.push('\n');
9878                }
9879            }
9880        }
9881    }
9882
9883    #[cfg(not(target_os = "windows"))]
9884    {
9885        let smb_conf = std::fs::read_to_string("/etc/samba/smb.conf").unwrap_or_default();
9886        out.push_str("=== Samba Config (/etc/samba/smb.conf) ===\n");
9887        if smb_conf.is_empty() {
9888            out.push_str("  Not found or Samba not installed.\n");
9889        } else {
9890            for line in smb_conf.lines().take(max_entries) {
9891                out.push_str(&format!("  {}\n", line));
9892            }
9893        }
9894        let nfs_exports = std::fs::read_to_string("/etc/exports").unwrap_or_default();
9895        out.push_str("\n=== NFS Exports (/etc/exports) ===\n");
9896        if nfs_exports.is_empty() {
9897            out.push_str("  Not configured.\n");
9898        } else {
9899            for line in nfs_exports.lines().take(max_entries) {
9900                out.push_str(&format!("  {}\n", line));
9901            }
9902        }
9903    }
9904
9905    Ok(out.trim_end().to_string())
9906}
9907
9908// ── dns_servers ───────────────────────────────────────────────────────────────
9909
9910fn inspect_dns_servers() -> Result<String, String> {
9911    let mut out = String::from("Host inspection: dns_servers\n\n");
9912
9913    #[cfg(target_os = "windows")]
9914    {
9915        let dns_out = Command::new("powershell")
9916            .args([
9917                "-NoProfile", "-NonInteractive", "-Command",
9918                "Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses.Count -gt 0 } | ForEach-Object { $addrs = $_.ServerAddresses -join ', '; \"  $($_.InterfaceAlias) (AF $($_.AddressFamily)): $addrs\" }",
9919            ])
9920            .output()
9921            .ok()
9922            .and_then(|o| String::from_utf8(o.stdout).ok())
9923            .unwrap_or_default();
9924
9925        out.push_str("=== Configured DNS Resolvers (per adapter) ===\n");
9926        if dns_out.trim().is_empty() {
9927            out.push_str("  (unable to retrieve)\n");
9928        } else {
9929            for line in dns_out.lines() {
9930                if line.trim().is_empty() {
9931                    continue;
9932                }
9933                let mut annotation = "";
9934                if line.contains("8.8.8.8") || line.contains("8.8.4.4") {
9935                    annotation = "  <- Google Public DNS";
9936                } else if line.contains("1.1.1.1") || line.contains("1.0.0.1") {
9937                    annotation = "  <- Cloudflare DNS";
9938                } else if line.contains("9.9.9.9") {
9939                    annotation = "  <- Quad9";
9940                } else if line.contains("208.67.222") || line.contains("208.67.220") {
9941                    annotation = "  <- OpenDNS";
9942                }
9943                out.push_str(line);
9944                out.push_str(annotation);
9945                out.push('\n');
9946            }
9947        }
9948
9949        let doh_out = Command::new("powershell")
9950            .args([
9951                "-NoProfile", "-NonInteractive", "-Command",
9952                "Get-DnsClientDohServerAddress 2>$null | ForEach-Object { \"  $($_.ServerAddress): $($_.DohTemplate)\" }",
9953            ])
9954            .output()
9955            .ok()
9956            .and_then(|o| String::from_utf8(o.stdout).ok())
9957            .unwrap_or_default();
9958
9959        out.push_str("\n=== DNS over HTTPS (DoH) ===\n");
9960        if doh_out.trim().is_empty() {
9961            out.push_str("  Not configured (plain DNS).\n");
9962        } else {
9963            out.push_str(doh_out.trim());
9964            out.push('\n');
9965        }
9966
9967        let suffixes = Command::new("powershell")
9968            .args([
9969                "-NoProfile", "-NonInteractive", "-Command",
9970                "Get-DnsClientGlobalSetting | Select-Object -ExpandProperty SuffixSearchList | ForEach-Object { \"  $_\" }",
9971            ])
9972            .output()
9973            .ok()
9974            .and_then(|o| String::from_utf8(o.stdout).ok())
9975            .unwrap_or_default();
9976
9977        if !suffixes.trim().is_empty() {
9978            out.push_str("\n=== DNS Search Suffix List ===\n");
9979            out.push_str(suffixes.trim());
9980            out.push('\n');
9981        }
9982    }
9983
9984    #[cfg(not(target_os = "windows"))]
9985    {
9986        let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
9987        out.push_str("=== /etc/resolv.conf ===\n");
9988        if resolv.is_empty() {
9989            out.push_str("  Not found.\n");
9990        } else {
9991            for line in resolv.lines() {
9992                if !line.trim().is_empty() && !line.starts_with('#') {
9993                    out.push_str(&format!("  {}\n", line));
9994                }
9995            }
9996        }
9997        let resolved_out = Command::new("resolvectl")
9998            .args(["status", "--no-pager"])
9999            .output()
10000            .ok()
10001            .and_then(|o| String::from_utf8(o.stdout).ok())
10002            .unwrap_or_default();
10003        if !resolved_out.is_empty() {
10004            out.push_str("\n=== systemd-resolved ===\n");
10005            for line in resolved_out.lines().take(30) {
10006                out.push_str(&format!("  {}\n", line));
10007            }
10008        }
10009    }
10010
10011    Ok(out.trim_end().to_string())
10012}
10013
10014fn inspect_bitlocker() -> Result<String, String> {
10015    let mut out = String::from("Host inspection: bitlocker\n\n");
10016
10017    #[cfg(target_os = "windows")]
10018    {
10019        let ps_cmd = "Get-BitLockerVolume | Select-Object MountPoint, VolumeStatus, ProtectionStatus, EncryptionPercentage | ForEach-Object { \"$($_.MountPoint) [$($_.VolumeStatus)] Protection:$($_.ProtectionStatus) ($($_.EncryptionPercentage)%)\" }";
10020        let output = Command::new("powershell")
10021            .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
10022            .output()
10023            .map_err(|e| format!("Failed to execute PowerShell: {e}"))?;
10024
10025        let stdout = String::from_utf8(output.stdout).unwrap_or_default();
10026        let stderr = String::from_utf8(output.stderr).unwrap_or_default();
10027
10028        if !stdout.trim().is_empty() {
10029            out.push_str("=== BitLocker Volumes ===\n");
10030            for line in stdout.lines() {
10031                out.push_str(&format!("  {}\n", line));
10032            }
10033        } else if !stderr.trim().is_empty() {
10034            if stderr.contains("Access is denied") {
10035                out.push_str("Error: Access denied. BitLocker diagnostics require Administrator elevation.\n");
10036            } else {
10037                out.push_str(&format!(
10038                    "Error retrieving BitLocker info: {}\n",
10039                    stderr.trim()
10040                ));
10041            }
10042        } else {
10043            out.push_str("No BitLocker volumes detected or access denied.\n");
10044        }
10045    }
10046
10047    #[cfg(not(target_os = "windows"))]
10048    {
10049        out.push_str(
10050            "BitLocker is a Windows-specific technology. Checking for LUKS/dm-crypt...\n\n",
10051        );
10052        let lsblk = Command::new("lsblk")
10053            .args(["-f", "-o", "NAME,FSTYPE,MOUNTPOINT"])
10054            .output()
10055            .ok()
10056            .and_then(|o| String::from_utf8(o.stdout).ok())
10057            .unwrap_or_default();
10058        if lsblk.contains("crypto_LUKS") {
10059            out.push_str("=== LUKS Encrypted Volumes ===\n");
10060            for line in lsblk.lines().filter(|l| l.contains("crypto_LUKS")) {
10061                out.push_str(&format!("  {}\n", line));
10062            }
10063        } else {
10064            out.push_str("No LUKS encrypted volumes detected via lsblk.\n");
10065        }
10066    }
10067
10068    Ok(out.trim_end().to_string())
10069}
10070
10071fn inspect_rdp() -> Result<String, String> {
10072    let mut out = String::from("Host inspection: rdp\n\n");
10073
10074    #[cfg(target_os = "windows")]
10075    {
10076        let reg_path = "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server";
10077        let f_deny = Command::new("powershell")
10078            .args([
10079                "-NoProfile",
10080                "-Command",
10081                &format!("(Get-ItemProperty '{}').fDenyTSConnections", reg_path),
10082            ])
10083            .output()
10084            .ok()
10085            .and_then(|o| String::from_utf8(o.stdout).ok())
10086            .unwrap_or_default()
10087            .trim()
10088            .to_string();
10089
10090        let status = if f_deny == "0" { "ENABLED" } else { "DISABLED" };
10091        out.push_str(&format!("=== RDP Status: {} ===\n", status));
10092
10093        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"])
10094            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default().trim().to_string();
10095        out.push_str(&format!(
10096            "  Port: {}\n",
10097            if port.is_empty() {
10098                "3389 (default)"
10099            } else {
10100                &port
10101            }
10102        ));
10103
10104        let nla = Command::new("powershell")
10105            .args([
10106                "-NoProfile",
10107                "-Command",
10108                &format!("(Get-ItemProperty '{}').UserAuthentication", reg_path),
10109            ])
10110            .output()
10111            .ok()
10112            .and_then(|o| String::from_utf8(o.stdout).ok())
10113            .unwrap_or_default()
10114            .trim()
10115            .to_string();
10116        out.push_str(&format!(
10117            "  NLA Required: {}\n",
10118            if nla == "1" { "Yes" } else { "No" }
10119        ));
10120
10121        let rdp_tcp_path =
10122            "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp";
10123        let sec_layer = Command::new("powershell")
10124            .args([
10125                "-NoProfile",
10126                "-Command",
10127                &format!("(Get-ItemProperty '{}').SecurityLayer", rdp_tcp_path),
10128            ])
10129            .output()
10130            .ok()
10131            .and_then(|o| String::from_utf8(o.stdout).ok())
10132            .unwrap_or_default()
10133            .trim()
10134            .to_string();
10135        let sec_label = match sec_layer.as_str() {
10136            "0" => "RDP Security (no SSL)",
10137            "1" => "Negotiate (prefer TLS)",
10138            "2" => "SSL/TLS required",
10139            _ => &sec_layer,
10140        };
10141        out.push_str(&format!(
10142            "  Security Layer: {} ({})\n",
10143            sec_layer, sec_label
10144        ));
10145
10146        let enc_level = Command::new("powershell")
10147            .args([
10148                "-NoProfile",
10149                "-Command",
10150                &format!("(Get-ItemProperty '{}').MinEncryptionLevel", rdp_tcp_path),
10151            ])
10152            .output()
10153            .ok()
10154            .and_then(|o| String::from_utf8(o.stdout).ok())
10155            .unwrap_or_default()
10156            .trim()
10157            .to_string();
10158        let enc_label = match enc_level.as_str() {
10159            "1" => "Low",
10160            "2" => "Client Compatible",
10161            "3" => "High",
10162            "4" => "FIPS Compliant",
10163            _ => "Unknown",
10164        };
10165        out.push_str(&format!(
10166            "  Encryption Level: {} ({})\n",
10167            enc_level, enc_label
10168        ));
10169
10170        out.push_str("\n=== Active Sessions ===\n");
10171        let qwinsta = Command::new("qwinsta")
10172            .output()
10173            .ok()
10174            .and_then(|o| String::from_utf8(o.stdout).ok())
10175            .unwrap_or_default();
10176        if qwinsta.trim().is_empty() {
10177            out.push_str("  No active sessions listed.\n");
10178        } else {
10179            for line in qwinsta.lines() {
10180                out.push_str(&format!("  {}\n", line));
10181            }
10182        }
10183
10184        out.push_str("\n=== Firewall Rule Check ===\n");
10185        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))\" }"])
10186            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10187        if fw.trim().is_empty() {
10188            out.push_str("  No enabled RDP firewall rules found.\n");
10189        } else {
10190            out.push_str(fw.trim_end());
10191            out.push('\n');
10192        }
10193    }
10194
10195    #[cfg(not(target_os = "windows"))]
10196    {
10197        out.push_str("Checking for common RDP/VNC listeners (3389, 5900-5905)...\n");
10198        let ss = Command::new("ss")
10199            .args(["-tlnp"])
10200            .output()
10201            .ok()
10202            .and_then(|o| String::from_utf8(o.stdout).ok())
10203            .unwrap_or_default();
10204        let matches: Vec<&str> = ss
10205            .lines()
10206            .filter(|l| l.contains(":3389") || l.contains(":590"))
10207            .collect();
10208        if matches.is_empty() {
10209            out.push_str("  No RDP/VNC listeners detected via 'ss'.\n");
10210        } else {
10211            for m in matches {
10212                out.push_str(&format!("  {}\n", m));
10213            }
10214        }
10215    }
10216
10217    Ok(out.trim_end().to_string())
10218}
10219
10220fn inspect_shadow_copies() -> Result<String, String> {
10221    let mut out = String::from("Host inspection: shadow_copies\n\n");
10222
10223    #[cfg(target_os = "windows")]
10224    {
10225        let output = Command::new("vssadmin")
10226            .args(["list", "shadows"])
10227            .output()
10228            .map_err(|e| format!("Failed to run vssadmin: {e}"))?;
10229        let stdout = String::from_utf8(output.stdout).unwrap_or_default();
10230
10231        if stdout.contains("No items found") || stdout.trim().is_empty() {
10232            out.push_str("No Volume Shadow Copies found.\n");
10233        } else {
10234            out.push_str("=== Volume Shadow Copies ===\n");
10235            for line in stdout.lines().take(50) {
10236                if line.contains("Creation Time:")
10237                    || line.contains("Contents:")
10238                    || line.contains("Volume Name:")
10239                {
10240                    out.push_str(&format!("  {}\n", line.trim()));
10241                }
10242            }
10243        }
10244
10245        // Most recent snapshot age
10246        let age_script = r#"
10247try {
10248    $snaps = Get-WmiObject Win32_ShadowCopy -ErrorAction SilentlyContinue | Sort-Object InstallDate -Descending
10249    if ($snaps) {
10250        $newest = $snaps[0]
10251        $created = [Management.ManagementDateTimeConverter]::ToDateTime($newest.InstallDate)
10252        $age = [math]::Round(([datetime]::Now - $created).TotalDays, 1)
10253        $count = @($snaps).Count
10254        "Most recent snapshot: $($created.ToString('yyyy-MM-dd HH:mm'))  ($age days ago)  — $count total snapshots"
10255    } else { "No snapshots found via WMI." }
10256} catch { "WMI snapshot query unavailable: $_" }
10257"#;
10258        if let Ok(age_out) = Command::new("powershell")
10259            .args(["-NoProfile", "-Command", age_script])
10260            .output()
10261        {
10262            let age_text = String::from_utf8_lossy(&age_out.stdout).trim().to_string();
10263            if !age_text.is_empty() {
10264                out.push_str("\n=== Snapshot Age ===\n");
10265                out.push_str(&format!("  {}\n", age_text));
10266            }
10267        }
10268
10269        out.push_str("\n=== Shadow Copy Storage ===\n");
10270        let storage_out = Command::new("vssadmin")
10271            .args(["list", "shadowstorage"])
10272            .output()
10273            .ok();
10274        if let Some(o) = storage_out {
10275            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10276            for line in stdout.lines() {
10277                if line.contains("Used Shadow Copy Storage space:")
10278                    || line.contains("Max Shadow Copy Storage space:")
10279                {
10280                    out.push_str(&format!("  {}\n", line.trim()));
10281                }
10282            }
10283        }
10284    }
10285
10286    #[cfg(not(target_os = "windows"))]
10287    {
10288        out.push_str("Checking for LVM snapshots or Btrfs subvolumes...\n\n");
10289        let lvs = Command::new("lvs")
10290            .output()
10291            .ok()
10292            .and_then(|o| String::from_utf8(o.stdout).ok())
10293            .unwrap_or_default();
10294        if !lvs.is_empty() {
10295            out.push_str("=== LVM Volumes (checking for snapshots) ===\n");
10296            out.push_str(&lvs);
10297        } else {
10298            out.push_str("No LVM volumes detected.\n");
10299        }
10300    }
10301
10302    Ok(out.trim_end().to_string())
10303}
10304
10305fn inspect_pagefile() -> Result<String, String> {
10306    let mut out = String::from("Host inspection: pagefile\n\n");
10307
10308    #[cfg(target_os = "windows")]
10309    {
10310        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)\" }";
10311        let output = Command::new("powershell")
10312            .args(["-NoProfile", "-Command", ps_cmd])
10313            .output()
10314            .ok()
10315            .and_then(|o| String::from_utf8(o.stdout).ok())
10316            .unwrap_or_default();
10317
10318        if output.trim().is_empty() {
10319            out.push_str("No page files detected (system may be running without a page file or managed differently).\n");
10320            let managed = Command::new("powershell")
10321                .args([
10322                    "-NoProfile",
10323                    "-Command",
10324                    "(Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile",
10325                ])
10326                .output()
10327                .ok()
10328                .and_then(|o| String::from_utf8(o.stdout).ok())
10329                .unwrap_or_default()
10330                .trim()
10331                .to_string();
10332            out.push_str(&format!("Automatic Managed Pagefile: {}\n", managed));
10333        } else {
10334            out.push_str("=== Page File Usage ===\n");
10335            out.push_str(&output);
10336        }
10337    }
10338
10339    #[cfg(not(target_os = "windows"))]
10340    {
10341        out.push_str("=== Swap Usage (Linux/macOS) ===\n");
10342        let swap = Command::new("swapon")
10343            .args(["--show"])
10344            .output()
10345            .ok()
10346            .and_then(|o| String::from_utf8(o.stdout).ok())
10347            .unwrap_or_default();
10348        if swap.is_empty() {
10349            let free = Command::new("free")
10350                .args(["-h"])
10351                .output()
10352                .ok()
10353                .and_then(|o| String::from_utf8(o.stdout).ok())
10354                .unwrap_or_default();
10355            out.push_str(&free);
10356        } else {
10357            out.push_str(&swap);
10358        }
10359    }
10360
10361    Ok(out.trim_end().to_string())
10362}
10363
10364fn inspect_windows_features(max_entries: usize) -> Result<String, String> {
10365    let mut out = String::from("Host inspection: windows_features\n\n");
10366
10367    #[cfg(target_os = "windows")]
10368    {
10369        out.push_str("=== Quick Check: Notable Features ===\n");
10370        let quick_ps = "Get-WindowsOptionalFeature -Online | Where-Object { $_.FeatureName -match 'IIS|Hyper-V|VirtualMachinePlatform|Subsystem-Linux' -and $_.State -eq 'Enabled' } | Select-Object -ExpandProperty FeatureName";
10371        let output = Command::new("powershell")
10372            .args(["-NoProfile", "-Command", quick_ps])
10373            .output()
10374            .ok();
10375
10376        if let Some(o) = output {
10377            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10378            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10379
10380            if !stdout.trim().is_empty() {
10381                for f in stdout.lines() {
10382                    out.push_str(&format!("  [ENABLED] {}\n", f));
10383                }
10384            } else if stderr.contains("Access is denied") || stderr.contains("requires elevation") {
10385                out.push_str("  Error: Access denied. Listing Windows Features requires Administrator elevation.\n");
10386            } else if quick_ps.contains("-Online") && stdout.trim().is_empty() {
10387                out.push_str(
10388                    "  No major features (IIS, Hyper-V, WSL) appear enabled in the quick check.\n",
10389                );
10390            }
10391        }
10392
10393        out.push_str(&format!(
10394            "\n=== All Enabled Features (capped at {}) ===\n",
10395            max_entries
10396        ));
10397        let all_ps = format!("Get-WindowsOptionalFeature -Online | Where-Object {{$_.State -eq 'Enabled'}} | Select-Object -First {} -ExpandProperty FeatureName", max_entries);
10398        let all_out = Command::new("powershell")
10399            .args(["-NoProfile", "-Command", &all_ps])
10400            .output()
10401            .ok();
10402        if let Some(o) = all_out {
10403            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10404            if !stdout.trim().is_empty() {
10405                out.push_str(&stdout);
10406            }
10407        }
10408    }
10409
10410    #[cfg(not(target_os = "windows"))]
10411    {
10412        let _ = max_entries;
10413        out.push_str("Windows Optional Features are Windows-specific. On Linux, check your package manager.\n");
10414    }
10415
10416    Ok(out.trim_end().to_string())
10417}
10418
10419fn inspect_audio(max_entries: usize) -> Result<String, String> {
10420    let mut out = String::from("Host inspection: audio\n\n");
10421
10422    #[cfg(target_os = "windows")]
10423    {
10424        let n = max_entries.clamp(5, 20);
10425        let services = collect_services().unwrap_or_default();
10426        let core_service_names = ["Audiosrv", "AudioEndpointBuilder"];
10427        let bluetooth_audio_service_names = ["BthAvctpSvc", "BTAGService"];
10428
10429        let core_services: Vec<&ServiceEntry> = services
10430            .iter()
10431            .filter(|entry| {
10432                core_service_names
10433                    .iter()
10434                    .any(|name| entry.name.eq_ignore_ascii_case(name))
10435            })
10436            .collect();
10437        let bluetooth_audio_services: Vec<&ServiceEntry> = services
10438            .iter()
10439            .filter(|entry| {
10440                bluetooth_audio_service_names
10441                    .iter()
10442                    .any(|name| entry.name.eq_ignore_ascii_case(name))
10443            })
10444            .collect();
10445
10446        let probe_script = r#"
10447$media = @(Get-PnpDevice -Class Media -ErrorAction SilentlyContinue |
10448    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10449$endpoints = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
10450    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10451$sound = @(Get-CimInstance Win32_SoundDevice -ErrorAction SilentlyContinue |
10452    Select-Object Name, Status, Manufacturer, PNPDeviceID)
10453[pscustomobject]@{
10454    Media = $media
10455    Endpoints = $endpoints
10456    SoundDevices = $sound
10457} | ConvertTo-Json -Compress -Depth 4
10458"#;
10459        let probe_raw = Command::new("powershell")
10460            .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
10461            .output()
10462            .ok()
10463            .and_then(|o| String::from_utf8(o.stdout).ok())
10464            .unwrap_or_default();
10465        let probe_loaded = !probe_raw.trim().is_empty();
10466        let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
10467
10468        let endpoints = parse_windows_pnp_devices(probe_value.get("Endpoints"));
10469        let media_devices = parse_windows_pnp_devices(probe_value.get("Media"));
10470        let sound_devices = parse_windows_sound_devices(probe_value.get("SoundDevices"));
10471
10472        let playback_endpoints: Vec<&WindowsPnpDevice> = endpoints
10473            .iter()
10474            .filter(|device| !is_microphone_like_name(&device.name))
10475            .collect();
10476        let recording_endpoints: Vec<&WindowsPnpDevice> = endpoints
10477            .iter()
10478            .filter(|device| is_microphone_like_name(&device.name))
10479            .collect();
10480        let bluetooth_endpoints: Vec<&WindowsPnpDevice> = endpoints
10481            .iter()
10482            .filter(|device| is_bluetooth_like_name(&device.name))
10483            .collect();
10484        let endpoint_problems: Vec<&WindowsPnpDevice> = endpoints
10485            .iter()
10486            .filter(|device| windows_device_has_issue(device))
10487            .collect();
10488        let media_problems: Vec<&WindowsPnpDevice> = media_devices
10489            .iter()
10490            .filter(|device| windows_device_has_issue(device))
10491            .collect();
10492        let sound_problems: Vec<&WindowsSoundDevice> = sound_devices
10493            .iter()
10494            .filter(|device| windows_sound_device_has_issue(device))
10495            .collect();
10496
10497        let mut findings = Vec::new();
10498
10499        let stopped_core_services: Vec<&ServiceEntry> = core_services
10500            .iter()
10501            .copied()
10502            .filter(|service| !service_is_running(service))
10503            .collect();
10504        if !stopped_core_services.is_empty() {
10505            let names = stopped_core_services
10506                .iter()
10507                .map(|service| service.name.as_str())
10508                .collect::<Vec<_>>()
10509                .join(", ");
10510            findings.push(AuditFinding {
10511                finding: format!("Core audio services are not running: {names}"),
10512                impact: "Playback and recording devices can vanish or fail even when the hardware is physically present.".to_string(),
10513                fix: "Start Windows Audio (`Audiosrv`) and Windows Audio Endpoint Builder, then recheck the endpoint inventory before reinstalling drivers.".to_string(),
10514            });
10515        }
10516
10517        if probe_loaded
10518            && endpoints.is_empty()
10519            && media_devices.is_empty()
10520            && sound_devices.is_empty()
10521        {
10522            findings.push(AuditFinding {
10523                finding: "No audio endpoints or sound hardware were detected in the Windows device inventory.".to_string(),
10524                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(),
10525                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(),
10526            });
10527        }
10528
10529        if !endpoint_problems.is_empty() || !media_problems.is_empty() || !sound_problems.is_empty()
10530        {
10531            let mut problem_labels = Vec::new();
10532            problem_labels.extend(
10533                endpoint_problems
10534                    .iter()
10535                    .take(3)
10536                    .map(|device| device.name.clone()),
10537            );
10538            problem_labels.extend(
10539                media_problems
10540                    .iter()
10541                    .take(3)
10542                    .map(|device| device.name.clone()),
10543            );
10544            problem_labels.extend(
10545                sound_problems
10546                    .iter()
10547                    .take(3)
10548                    .map(|device| device.name.clone()),
10549            );
10550            findings.push(AuditFinding {
10551                finding: format!(
10552                    "Windows reports audio device issues for: {}",
10553                    problem_labels.join(", ")
10554                ),
10555                impact: "Apps can lose speakers, microphones, or headset paths when endpoint or media-class devices are degraded, disabled, or driver-broken.".to_string(),
10556                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(),
10557            });
10558        }
10559
10560        let stopped_bt_audio_services: Vec<&ServiceEntry> = bluetooth_audio_services
10561            .iter()
10562            .copied()
10563            .filter(|service| !service_is_running(service))
10564            .collect();
10565        if !bluetooth_endpoints.is_empty() && !stopped_bt_audio_services.is_empty() {
10566            let names = stopped_bt_audio_services
10567                .iter()
10568                .map(|service| service.name.as_str())
10569                .collect::<Vec<_>>()
10570                .join(", ");
10571            findings.push(AuditFinding {
10572                finding: format!(
10573                    "Bluetooth-branded audio endpoints exist, but Bluetooth audio services are not fully running: {names}"
10574                ),
10575                impact: "Headsets may pair yet fail to expose the correct playback or microphone profile, especially after wake or reconnect events.".to_string(),
10576                fix: "Restart the Bluetooth audio services and reconnect the headset before blaming the application layer.".to_string(),
10577            });
10578        }
10579
10580        out.push_str("=== Findings ===\n");
10581        if findings.is_empty() {
10582            out.push_str("- Finding: No obvious Windows audio-service outage or device-inventory failure was detected.\n");
10583            out.push_str("  Impact: Playback and recording look structurally present from this inspection pass.\n");
10584            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");
10585        } else {
10586            for finding in &findings {
10587                out.push_str(&format!("- Finding: {}\n", finding.finding));
10588                out.push_str(&format!("  Impact: {}\n", finding.impact));
10589                out.push_str(&format!("  Fix: {}\n", finding.fix));
10590            }
10591        }
10592
10593        out.push_str("\n=== Audio services ===\n");
10594        if core_services.is_empty() && bluetooth_audio_services.is_empty() {
10595            out.push_str(
10596                "- No Windows audio services were retrieved from the service inventory.\n",
10597            );
10598        } else {
10599            for service in core_services.iter().chain(bluetooth_audio_services.iter()) {
10600                out.push_str(&format!(
10601                    "- {} | Status: {} | Startup: {}\n",
10602                    service.name,
10603                    service.status,
10604                    service.startup.as_deref().unwrap_or("Unknown")
10605                ));
10606            }
10607        }
10608
10609        out.push_str("\n=== Playback and recording endpoints ===\n");
10610        if !probe_loaded {
10611            out.push_str("- Windows endpoint inventory probe returned no data.\n");
10612        } else if endpoints.is_empty() {
10613            out.push_str("- No audio endpoints detected.\n");
10614        } else {
10615            out.push_str(&format!(
10616                "- Playback-style endpoints: {} | Recording-style endpoints: {}\n",
10617                playback_endpoints.len(),
10618                recording_endpoints.len()
10619            ));
10620            for device in playback_endpoints.iter().take(n) {
10621                out.push_str(&format!(
10622                    "- [PLAYBACK] {} | Status: {}{}\n",
10623                    device.name,
10624                    device.status,
10625                    device
10626                        .problem
10627                        .filter(|problem| *problem != 0)
10628                        .map(|problem| format!(" | ProblemCode: {problem}"))
10629                        .unwrap_or_default()
10630                ));
10631            }
10632            for device in recording_endpoints.iter().take(n) {
10633                out.push_str(&format!(
10634                    "- [MIC] {} | Status: {}{}\n",
10635                    device.name,
10636                    device.status,
10637                    device
10638                        .problem
10639                        .filter(|problem| *problem != 0)
10640                        .map(|problem| format!(" | ProblemCode: {problem}"))
10641                        .unwrap_or_default()
10642                ));
10643            }
10644        }
10645
10646        out.push_str("\n=== Sound hardware devices ===\n");
10647        if sound_devices.is_empty() {
10648            out.push_str("- No Win32_SoundDevice entries were returned.\n");
10649        } else {
10650            for device in sound_devices.iter().take(n) {
10651                out.push_str(&format!(
10652                    "- {} | Status: {}{}\n",
10653                    device.name,
10654                    device.status,
10655                    device
10656                        .manufacturer
10657                        .as_deref()
10658                        .map(|manufacturer| format!(" | Vendor: {manufacturer}"))
10659                        .unwrap_or_default()
10660                ));
10661            }
10662        }
10663
10664        out.push_str("\n=== Media-class device inventory ===\n");
10665        if media_devices.is_empty() {
10666            out.push_str("- No media-class PnP devices were returned.\n");
10667        } else {
10668            for device in media_devices.iter().take(n) {
10669                out.push_str(&format!(
10670                    "- {} | Status: {}{}\n",
10671                    device.name,
10672                    device.status,
10673                    device
10674                        .class_name
10675                        .as_deref()
10676                        .map(|class_name| format!(" | Class: {class_name}"))
10677                        .unwrap_or_default()
10678                ));
10679            }
10680        }
10681    }
10682
10683    #[cfg(not(target_os = "windows"))]
10684    {
10685        let _ = max_entries;
10686        out.push_str(
10687            "Audio inspection currently provides deep endpoint and service coverage on Windows.\n",
10688        );
10689        out.push_str(
10690            "On Linux/macOS, ask narrower questions about PipeWire/PulseAudio/ALSA state if you want a dedicated native audit path added.\n",
10691        );
10692    }
10693
10694    Ok(out.trim_end().to_string())
10695}
10696
10697fn inspect_bluetooth(max_entries: usize) -> Result<String, String> {
10698    let mut out = String::from("Host inspection: bluetooth\n\n");
10699
10700    #[cfg(target_os = "windows")]
10701    {
10702        let n = max_entries.clamp(5, 20);
10703        let services = collect_services().unwrap_or_default();
10704        let bluetooth_services: Vec<&ServiceEntry> = services
10705            .iter()
10706            .filter(|entry| {
10707                entry.name.eq_ignore_ascii_case("bthserv")
10708                    || entry.name.eq_ignore_ascii_case("BthAvctpSvc")
10709                    || entry.name.eq_ignore_ascii_case("BTAGService")
10710                    || entry.name.starts_with("BluetoothUserService")
10711                    || entry
10712                        .display_name
10713                        .as_deref()
10714                        .unwrap_or("")
10715                        .to_ascii_lowercase()
10716                        .contains("bluetooth")
10717            })
10718            .collect();
10719
10720        let probe_script = r#"
10721$radios = @(Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue |
10722    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10723$devices = @(Get-PnpDevice -ErrorAction SilentlyContinue |
10724    Where-Object {
10725        $_.Class -eq 'Bluetooth' -or
10726        $_.FriendlyName -match 'Bluetooth' -or
10727        $_.InstanceId -like 'BTH*'
10728    } |
10729    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10730$audio = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
10731    Where-Object { $_.FriendlyName -match 'Bluetooth|Hands-Free|A2DP' } |
10732    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10733[pscustomobject]@{
10734    Radios = $radios
10735    Devices = $devices
10736    AudioEndpoints = $audio
10737} | ConvertTo-Json -Compress -Depth 4
10738"#;
10739        let probe_raw = Command::new("powershell")
10740            .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
10741            .output()
10742            .ok()
10743            .and_then(|o| String::from_utf8(o.stdout).ok())
10744            .unwrap_or_default();
10745        let probe_loaded = !probe_raw.trim().is_empty();
10746        let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
10747
10748        let radios = parse_windows_pnp_devices(probe_value.get("Radios"));
10749        let devices = parse_windows_pnp_devices(probe_value.get("Devices"));
10750        let audio_endpoints = parse_windows_pnp_devices(probe_value.get("AudioEndpoints"));
10751        let radio_problems: Vec<&WindowsPnpDevice> = radios
10752            .iter()
10753            .filter(|device| windows_device_has_issue(device))
10754            .collect();
10755        let device_problems: Vec<&WindowsPnpDevice> = devices
10756            .iter()
10757            .filter(|device| windows_device_has_issue(device))
10758            .collect();
10759
10760        let mut findings = Vec::new();
10761
10762        if probe_loaded && radios.is_empty() {
10763            findings.push(AuditFinding {
10764                finding: "No Bluetooth radio or adapter was detected in the device inventory.".to_string(),
10765                impact: "Pairing, reconnects, and Bluetooth audio paths cannot work without a healthy local radio.".to_string(),
10766                fix: "Check whether Bluetooth is disabled in firmware, turned off in Windows, or missing its vendor driver.".to_string(),
10767            });
10768        }
10769
10770        let stopped_bluetooth_services: Vec<&ServiceEntry> = bluetooth_services
10771            .iter()
10772            .copied()
10773            .filter(|service| !service_is_running(service))
10774            .collect();
10775        if !stopped_bluetooth_services.is_empty() {
10776            let names = stopped_bluetooth_services
10777                .iter()
10778                .map(|service| service.name.as_str())
10779                .collect::<Vec<_>>()
10780                .join(", ");
10781            findings.push(AuditFinding {
10782                finding: format!("Bluetooth-related services are not fully running: {names}"),
10783                impact: "Discovery, pairing, reconnects, and headset profile switching can all fail even when the adapter appears installed.".to_string(),
10784                fix: "Start the Bluetooth Support Service first, then reconnect the device and recheck the adapter and endpoint state.".to_string(),
10785            });
10786        }
10787
10788        if !radio_problems.is_empty() || !device_problems.is_empty() {
10789            let problem_labels = radio_problems
10790                .iter()
10791                .chain(device_problems.iter())
10792                .take(5)
10793                .map(|device| device.name.as_str())
10794                .collect::<Vec<_>>()
10795                .join(", ");
10796            findings.push(AuditFinding {
10797                finding: format!("Windows reports Bluetooth device issues for: {problem_labels}"),
10798                impact: "A degraded radio or paired-device node can cause pairing loops, sudden disconnects, or one-way headset behavior.".to_string(),
10799                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(),
10800            });
10801        }
10802
10803        if !audio_endpoints.is_empty()
10804            && bluetooth_services
10805                .iter()
10806                .any(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10807            && bluetooth_services
10808                .iter()
10809                .filter(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10810                .any(|service| !service_is_running(service))
10811        {
10812            findings.push(AuditFinding {
10813                finding: "Bluetooth audio endpoints exist, but the Bluetooth AVCTP service is not running.".to_string(),
10814                impact: "Headsets can connect yet expose the wrong audio role or lose media controls and microphone availability.".to_string(),
10815                fix: "Restart the AVCTP service and reconnect the headset before troubleshooting the app or conferencing tool.".to_string(),
10816            });
10817        }
10818
10819        out.push_str("=== Findings ===\n");
10820        if findings.is_empty() {
10821            out.push_str("- Finding: No obvious Bluetooth radio, service, or paired-device failure was detected.\n");
10822            out.push_str("  Impact: The Bluetooth stack looks structurally healthy from this inspection pass.\n");
10823            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");
10824        } else {
10825            for finding in &findings {
10826                out.push_str(&format!("- Finding: {}\n", finding.finding));
10827                out.push_str(&format!("  Impact: {}\n", finding.impact));
10828                out.push_str(&format!("  Fix: {}\n", finding.fix));
10829            }
10830        }
10831
10832        out.push_str("\n=== Bluetooth services ===\n");
10833        if bluetooth_services.is_empty() {
10834            out.push_str(
10835                "- No Bluetooth-related services were retrieved from the service inventory.\n",
10836            );
10837        } else {
10838            for service in bluetooth_services.iter().take(n) {
10839                out.push_str(&format!(
10840                    "- {} | Status: {} | Startup: {}\n",
10841                    service.name,
10842                    service.status,
10843                    service.startup.as_deref().unwrap_or("Unknown")
10844                ));
10845            }
10846        }
10847
10848        out.push_str("\n=== Bluetooth radios and adapters ===\n");
10849        if !probe_loaded {
10850            out.push_str("- Windows Bluetooth adapter inventory probe returned no data.\n");
10851        } else if radios.is_empty() {
10852            out.push_str("- No Bluetooth radios detected.\n");
10853        } else {
10854            for device in radios.iter().take(n) {
10855                out.push_str(&format!(
10856                    "- {} | Status: {}{}\n",
10857                    device.name,
10858                    device.status,
10859                    device
10860                        .problem
10861                        .filter(|problem| *problem != 0)
10862                        .map(|problem| format!(" | ProblemCode: {problem}"))
10863                        .unwrap_or_default()
10864                ));
10865            }
10866        }
10867
10868        out.push_str("\n=== Bluetooth-associated devices ===\n");
10869        if devices.is_empty() {
10870            out.push_str("- No Bluetooth-associated device nodes detected.\n");
10871        } else {
10872            for device in devices.iter().take(n) {
10873                out.push_str(&format!(
10874                    "- {} | Status: {}{}\n",
10875                    device.name,
10876                    device.status,
10877                    device
10878                        .class_name
10879                        .as_deref()
10880                        .map(|class_name| format!(" | Class: {class_name}"))
10881                        .unwrap_or_default()
10882                ));
10883            }
10884        }
10885
10886        out.push_str("\n=== Bluetooth audio endpoints ===\n");
10887        if audio_endpoints.is_empty() {
10888            out.push_str("- No Bluetooth-branded audio endpoints detected.\n");
10889        } else {
10890            for device in audio_endpoints.iter().take(n) {
10891                out.push_str(&format!(
10892                    "- {} | Status: {}{}\n",
10893                    device.name,
10894                    device.status,
10895                    device
10896                        .instance_id
10897                        .as_deref()
10898                        .map(|instance_id| format!(" | Instance: {instance_id}"))
10899                        .unwrap_or_default()
10900                ));
10901            }
10902        }
10903    }
10904
10905    #[cfg(not(target_os = "windows"))]
10906    {
10907        let _ = max_entries;
10908        out.push_str("Bluetooth inspection currently provides deep service and device coverage on Windows.\n");
10909        out.push_str(
10910            "On Linux/macOS, ask a narrower Bluetooth question if you want a dedicated native audit path added.\n",
10911        );
10912    }
10913
10914    Ok(out.trim_end().to_string())
10915}
10916
10917fn inspect_printers(max_entries: usize) -> Result<String, String> {
10918    let mut out = String::from("Host inspection: printers\n\n");
10919
10920    #[cfg(target_os = "windows")]
10921    {
10922        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)])
10923            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10924        if list.trim().is_empty() {
10925            out.push_str("No printers detected.\n");
10926        } else {
10927            out.push_str("=== Installed Printers ===\n");
10928            out.push_str(&list);
10929        }
10930
10931        let jobs = Command::new("powershell").args(["-NoProfile", "-Command", "Get-PrintJob | Select-Object PrinterName, ID, DocumentName, Status | ForEach-Object { \"  [$($_.PrinterName)] Job $($_.ID): $($_.DocumentName) - $($_.Status)\" }"])
10932            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10933        if !jobs.trim().is_empty() {
10934            out.push_str("\n=== Active Print Jobs ===\n");
10935            out.push_str(&jobs);
10936        }
10937    }
10938
10939    #[cfg(not(target_os = "windows"))]
10940    {
10941        let _ = max_entries;
10942        out.push_str("Checking LPSTAT for printers...\n");
10943        let lpstat = Command::new("lpstat")
10944            .args(["-p", "-d"])
10945            .output()
10946            .ok()
10947            .and_then(|o| String::from_utf8(o.stdout).ok())
10948            .unwrap_or_default();
10949        if lpstat.is_empty() {
10950            out.push_str("  No CUPS/LP printers found.\n");
10951        } else {
10952            out.push_str(&lpstat);
10953        }
10954    }
10955
10956    Ok(out.trim_end().to_string())
10957}
10958
10959fn inspect_winrm() -> Result<String, String> {
10960    let mut out = String::from("Host inspection: winrm\n\n");
10961
10962    #[cfg(target_os = "windows")]
10963    {
10964        let svc = Command::new("powershell")
10965            .args(["-NoProfile", "-Command", "(Get-Service WinRM).Status"])
10966            .output()
10967            .ok()
10968            .and_then(|o| String::from_utf8(o.stdout).ok())
10969            .unwrap_or_default()
10970            .trim()
10971            .to_string();
10972        out.push_str(&format!(
10973            "WinRM Service Status: {}\n\n",
10974            if svc.is_empty() { "NOT_FOUND" } else { &svc }
10975        ));
10976
10977        out.push_str("=== WinRM Listeners ===\n");
10978        let output = Command::new("powershell")
10979            .args([
10980                "-NoProfile",
10981                "-Command",
10982                "winrm enumerate winrm/config/listener 2>$null",
10983            ])
10984            .output()
10985            .ok();
10986        if let Some(o) = output {
10987            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10988            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10989
10990            if !stdout.trim().is_empty() {
10991                for line in stdout.lines() {
10992                    if line.contains("Address =")
10993                        || line.contains("Transport =")
10994                        || line.contains("Port =")
10995                    {
10996                        out.push_str(&format!("  {}\n", line.trim()));
10997                    }
10998                }
10999            } else if stderr.contains("Access is denied") {
11000                out.push_str("  Error: Access denied to WinRM configuration.\n");
11001            } else {
11002                out.push_str("  No listeners configured.\n");
11003            }
11004        }
11005
11006        out.push_str("\n=== PowerShell Remoting Test (Local) ===\n");
11007        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))\" }"])
11008            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11009        if test_out.trim().is_empty() {
11010            out.push_str("  WinRM not responding to local WS-Man requests.\n");
11011        } else {
11012            out.push_str(&test_out);
11013        }
11014    }
11015
11016    #[cfg(not(target_os = "windows"))]
11017    {
11018        out.push_str(
11019            "WinRM is primarily a Windows technology. Checking for listening port 5985/5986...\n",
11020        );
11021        let ss = Command::new("ss")
11022            .args(["-tln"])
11023            .output()
11024            .ok()
11025            .and_then(|o| String::from_utf8(o.stdout).ok())
11026            .unwrap_or_default();
11027        if ss.contains(":5985") || ss.contains(":5986") {
11028            out.push_str("  WinRM ports (5985/5986) are listening.\n");
11029        } else {
11030            out.push_str("  WinRM ports not detected.\n");
11031        }
11032    }
11033
11034    Ok(out.trim_end().to_string())
11035}
11036
11037fn inspect_network_stats(max_entries: usize) -> Result<String, String> {
11038    let mut out = String::from("Host inspection: network_stats\n\n");
11039
11040    #[cfg(target_os = "windows")]
11041    {
11042        let ps_cmd = format!(
11043            "$s1 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes; \
11044             Start-Sleep -Milliseconds 250; \
11045             $s2 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes, ReceivedPacketErrors, OutboundPacketErrors | Select-Object -First {}; \
11046             $s2 | ForEach-Object {{ \
11047                $name = $_.Name; \
11048                $prev = $s1 | Where-Object {{ $_.Name -eq $name }}; \
11049                if ($prev) {{ \
11050                    $rb = ($_.ReceivedBytes - $prev.ReceivedBytes) / 0.25; \
11051                    $sb = ($_.SentBytes - $prev.SentBytes) / 0.25; \
11052                    $rmbps = [math]::Round(($rb * 8) / 1000000, 2); \
11053                    $smbps = [math]::Round(($sb * 8) / 1000000, 2); \
11054                    $tr = [math]::Round($_.ReceivedBytes / 1MB, 2); \
11055                    $tt = [math]::Round($_.SentBytes / 1MB, 2); \
11056                    \"  $($name): Rate(RX/TX): $($rmbps)/$($smbps) Mbps | Total: $($tr)/$($tt) MB | Errors: $($_.ReceivedPacketErrors)/$($_.OutboundPacketErrors)\" \
11057                }} \
11058             }}",
11059            max_entries
11060        );
11061        let output = Command::new("powershell")
11062            .args(["-NoProfile", "-Command", &ps_cmd])
11063            .output()
11064            .ok()
11065            .and_then(|o| String::from_utf8(o.stdout).ok())
11066            .unwrap_or_default();
11067        if output.trim().is_empty() {
11068            out.push_str("No network adapter statistics available.\n");
11069        } else {
11070            out.push_str("=== Adapter Throughput (Mbps) & Health ===\n");
11071            out.push_str(&output);
11072        }
11073
11074        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)\" } }"])
11075            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11076        if !discards.trim().is_empty() {
11077            out.push_str("\n=== Packet Discards (Non-Zero Only) ===\n");
11078            out.push_str(&discards);
11079        }
11080    }
11081
11082    #[cfg(not(target_os = "windows"))]
11083    {
11084        let _ = max_entries;
11085        out.push_str("=== Network Stats (ip -s link) ===\n");
11086        let ip_s = Command::new("ip")
11087            .args(["-s", "link"])
11088            .output()
11089            .ok()
11090            .and_then(|o| String::from_utf8(o.stdout).ok())
11091            .unwrap_or_default();
11092        if ip_s.is_empty() {
11093            let netstat = Command::new("netstat")
11094                .args(["-i"])
11095                .output()
11096                .ok()
11097                .and_then(|o| String::from_utf8(o.stdout).ok())
11098                .unwrap_or_default();
11099            out.push_str(&netstat);
11100        } else {
11101            out.push_str(&ip_s);
11102        }
11103    }
11104
11105    Ok(out.trim_end().to_string())
11106}
11107
11108fn inspect_udp_ports(max_entries: usize) -> Result<String, String> {
11109    let mut out = String::from("Host inspection: udp_ports\n\n");
11110
11111    #[cfg(target_os = "windows")]
11112    {
11113        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);
11114        let output = Command::new("powershell")
11115            .args(["-NoProfile", "-Command", &ps_cmd])
11116            .output()
11117            .ok();
11118
11119        if let Some(o) = output {
11120            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11121            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
11122
11123            if !stdout.trim().is_empty() {
11124                out.push_str("=== UDP Listeners (Local:Port) ===\n");
11125                for line in stdout.lines() {
11126                    let mut note = "";
11127                    if line.contains(":53 ") {
11128                        note = " [DNS]";
11129                    } else if line.contains(":67 ") || line.contains(":68 ") {
11130                        note = " [DHCP]";
11131                    } else if line.contains(":123 ") {
11132                        note = " [NTP]";
11133                    } else if line.contains(":161 ") {
11134                        note = " [SNMP]";
11135                    } else if line.contains(":1900 ") {
11136                        note = " [SSDP/UPnP]";
11137                    } else if line.contains(":5353 ") {
11138                        note = " [mDNS]";
11139                    }
11140
11141                    out.push_str(&format!("{}{}\n", line, note));
11142                }
11143            } else if stderr.contains("Access is denied") {
11144                out.push_str("Error: Access denied. Full UDP listener details require Administrator elevation.\n");
11145            } else {
11146                out.push_str("No UDP listeners detected.\n");
11147            }
11148        }
11149    }
11150
11151    #[cfg(not(target_os = "windows"))]
11152    {
11153        let ss_out = Command::new("ss")
11154            .args(["-ulnp"])
11155            .output()
11156            .ok()
11157            .and_then(|o| String::from_utf8(o.stdout).ok())
11158            .unwrap_or_default();
11159        out.push_str("=== UDP Listeners (ss -ulnp) ===\n");
11160        if ss_out.is_empty() {
11161            let netstat_out = Command::new("netstat")
11162                .args(["-ulnp"])
11163                .output()
11164                .ok()
11165                .and_then(|o| String::from_utf8(o.stdout).ok())
11166                .unwrap_or_default();
11167            if netstat_out.is_empty() {
11168                out.push_str("  Neither 'ss' nor 'netstat' available.\n");
11169            } else {
11170                for line in netstat_out.lines().take(max_entries) {
11171                    out.push_str(&format!("  {}\n", line));
11172                }
11173            }
11174        } else {
11175            for line in ss_out.lines().take(max_entries) {
11176                out.push_str(&format!("  {}\n", line));
11177            }
11178        }
11179    }
11180
11181    Ok(out.trim_end().to_string())
11182}
11183
11184fn inspect_gpo() -> Result<String, String> {
11185    let mut out = String::from("Host inspection: gpo\n\n");
11186
11187    #[cfg(target_os = "windows")]
11188    {
11189        let output = Command::new("gpresult")
11190            .args(["/r", "/scope", "computer"])
11191            .output()
11192            .ok();
11193
11194        if let Some(o) = output {
11195            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11196            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
11197
11198            if stdout.contains("Applied Group Policy Objects") {
11199                out.push_str("=== Applied Group Policy Objects (Computer Scope) ===\n");
11200                let mut capture = false;
11201                for line in stdout.lines() {
11202                    if line.contains("Applied Group Policy Objects") {
11203                        capture = true;
11204                    } else if capture && line.contains("The following GPOs were not applied") {
11205                        break;
11206                    }
11207                    if capture && !line.trim().is_empty() {
11208                        out.push_str(&format!("  {}\n", line.trim()));
11209                    }
11210                }
11211            } else if stderr.contains("Access is denied") || stdout.contains("Access is denied") {
11212                out.push_str("Error: Access denied. Group Policy inspection requires Administrator elevation.\n");
11213            } else {
11214                out.push_str("No applied Group Policy Objects detected or insufficient permissions to query computer scope.\n");
11215            }
11216        }
11217    }
11218
11219    #[cfg(not(target_os = "windows"))]
11220    {
11221        out.push_str("Group Policy (GPO) is a Windows-only topic.\n");
11222    }
11223
11224    Ok(out.trim_end().to_string())
11225}
11226
11227fn inspect_certificates(max_entries: usize) -> Result<String, String> {
11228    let mut out = String::from("Host inspection: certificates\n\n");
11229
11230    #[cfg(target_os = "windows")]
11231    {
11232        let ps_cmd = format!(
11233            "Get-ChildItem -Path Cert:\\LocalMachine\\My | Select-Object Subject, NotAfter, Thumbprint | Select-Object -First {} | ForEach-Object {{ \
11234                $days = ($_.NotAfter - (Get-Date)).Days; \
11235                $status = if ($days -lt 0) {{ \"[EXPIRED]\" }} else if ($days -lt 30) {{ \"[EXPIRING SOON ($days days)]\" }} else {{ \"\" }}; \
11236                \"  $($_.Subject) - Expires: $($_.NotAfter.ToString('yyyy-MM-dd')) $status (Thumb: $($_.Thumbprint.Substring(0,8))...)\" \
11237            }}", 
11238            max_entries
11239        );
11240        let output = Command::new("powershell")
11241            .args(["-NoProfile", "-Command", &ps_cmd])
11242            .output()
11243            .ok();
11244
11245        if let Some(o) = output {
11246            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11247            if !stdout.trim().is_empty() {
11248                out.push_str("=== Local Machine Certificates (Personal Store) ===\n");
11249                out.push_str(&stdout);
11250            } else {
11251                out.push_str("No certificates found in the Local Machine Personal store.\n");
11252            }
11253        }
11254    }
11255
11256    #[cfg(not(target_os = "windows"))]
11257    {
11258        let _ = max_entries;
11259        out.push_str("Host inspection: certificates (Linux/macOS)\n\n");
11260        // Check standard cert locations
11261        for path in ["/etc/ssl/certs", "/etc/pki/tls/certs"] {
11262            if Path::new(path).exists() {
11263                out.push_str(&format!("  Cert directory found: {}\n", path));
11264            }
11265        }
11266    }
11267
11268    Ok(out.trim_end().to_string())
11269}
11270
11271fn inspect_integrity() -> Result<String, String> {
11272    let mut out = String::from("Host inspection: integrity\n\n");
11273
11274    #[cfg(target_os = "windows")]
11275    {
11276        let ps_cmd = "Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing' | Select-Object Corrupt, AutoRepairNeeded, LastRepairAttempted | ConvertTo-Json";
11277        let output = Command::new("powershell")
11278            .args(["-NoProfile", "-Command", &ps_cmd])
11279            .output()
11280            .ok();
11281
11282        if let Some(o) = output {
11283            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11284            if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
11285                out.push_str("=== Windows Component Store Health (CBS) ===\n");
11286                let corrupt = val.get("Corrupt").and_then(|v| v.as_u64()).unwrap_or(0);
11287                let repair = val
11288                    .get("AutoRepairNeeded")
11289                    .and_then(|v| v.as_u64())
11290                    .unwrap_or(0);
11291
11292                out.push_str(&format!(
11293                    "  Corruption Detected: {}\n",
11294                    if corrupt != 0 {
11295                        "YES (SFC/DISM recommended)"
11296                    } else {
11297                        "No"
11298                    }
11299                ));
11300                out.push_str(&format!(
11301                    "  Auto-Repair Needed: {}\n",
11302                    if repair != 0 { "YES" } else { "No" }
11303                ));
11304
11305                if let Some(last) = val.get("LastRepairAttempted").and_then(|v| v.as_u64()) {
11306                    out.push_str(&format!("  Last Repair Attempt: (Raw code: {})\n", last));
11307                }
11308            } else {
11309                out.push_str("Could not retrieve CBS health from registry. System may be healthy or state is unknown.\n");
11310            }
11311        }
11312
11313        if Path::new("C:\\Windows\\Logs\\CBS\\CBS.log").exists() {
11314            out.push_str(
11315                "\nNote: Detailed integrity logs available at C:\\Windows\\Logs\\CBS\\CBS.log\n",
11316            );
11317        }
11318    }
11319
11320    #[cfg(not(target_os = "windows"))]
11321    {
11322        out.push_str("System integrity check (Linux)\n\n");
11323        let pkg_check = Command::new("rpm")
11324            .args(["-Va"])
11325            .output()
11326            .or_else(|_| Command::new("dpkg").args(["--verify"]).output())
11327            .ok();
11328        if let Some(o) = pkg_check {
11329            out.push_str("  Package verification system active.\n");
11330            if o.status.success() {
11331                out.push_str("  No major package integrity issues detected.\n");
11332            }
11333        }
11334    }
11335
11336    Ok(out.trim_end().to_string())
11337}
11338
11339fn inspect_domain() -> Result<String, String> {
11340    let mut out = String::from("Host inspection: domain\n\n");
11341
11342    #[cfg(target_os = "windows")]
11343    {
11344        out.push_str("=== Windows Domain / Workgroup Identity ===\n");
11345        let ps_cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Name, Domain, PartOfDomain, Workgroup | ConvertTo-Json";
11346        let output = Command::new("powershell")
11347            .args(["-NoProfile", "-Command", &ps_cmd])
11348            .output()
11349            .ok();
11350
11351        if let Some(o) = output {
11352            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11353            if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
11354                let part_of_domain = val
11355                    .get("PartOfDomain")
11356                    .and_then(|v| v.as_bool())
11357                    .unwrap_or(false);
11358                let domain = val
11359                    .get("Domain")
11360                    .and_then(|v| v.as_str())
11361                    .unwrap_or("Unknown");
11362                let workgroup = val
11363                    .get("Workgroup")
11364                    .and_then(|v| v.as_str())
11365                    .unwrap_or("Unknown");
11366
11367                out.push_str(&format!(
11368                    "  Join Status: {}\n",
11369                    if part_of_domain {
11370                        "DOMAIN JOINED"
11371                    } else {
11372                        "WORKGROUP"
11373                    }
11374                ));
11375                if part_of_domain {
11376                    out.push_str(&format!("  Active Directory Domain: {}\n", domain));
11377                } else {
11378                    out.push_str(&format!("  Workgroup Name: {}\n", workgroup));
11379                }
11380
11381                if let Some(name) = val.get("Name").and_then(|v| v.as_str()) {
11382                    out.push_str(&format!("  NetBIOS Name: {}\n", name));
11383                }
11384            } else {
11385                out.push_str("  Domain identity data unavailable from WMI.\n");
11386            }
11387        } else {
11388            out.push_str("  Domain identity data unavailable from WMI.\n");
11389        }
11390    }
11391
11392    #[cfg(not(target_os = "windows"))]
11393    {
11394        let domainname = Command::new("domainname")
11395            .output()
11396            .ok()
11397            .and_then(|o| String::from_utf8(o.stdout).ok())
11398            .unwrap_or_default();
11399        out.push_str("=== Linux Domain Identity ===\n");
11400        if !domainname.trim().is_empty() && domainname.trim() != "(none)" {
11401            out.push_str(&format!("  NIS/YP Domain: {}\n", domainname.trim()));
11402        } else {
11403            out.push_str("  No NIS domain configured.\n");
11404        }
11405    }
11406
11407    Ok(out.trim_end().to_string())
11408}
11409
11410fn inspect_device_health() -> Result<String, String> {
11411    let mut out = String::from("Host inspection: device_health\n\n");
11412
11413    #[cfg(target_os = "windows")]
11414    {
11415        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)\" }";
11416        let output = Command::new("powershell")
11417            .args(["-NoProfile", "-Command", ps_cmd])
11418            .output()
11419            .ok()
11420            .and_then(|o| String::from_utf8(o.stdout).ok())
11421            .unwrap_or_default();
11422
11423        if output.trim().is_empty() {
11424            out.push_str("All PnP devices report as healthy (no ConfigManager errors detected).\n");
11425        } else {
11426            out.push_str("=== Malfunctioning Devices (Yellow Bangs) ===\n");
11427            out.push_str(&output);
11428            out.push_str(
11429                "\nTip: Error codes 10 and 28 usually indicate missing or incompatible drivers.\n",
11430            );
11431        }
11432    }
11433
11434    #[cfg(not(target_os = "windows"))]
11435    {
11436        out.push_str("Checking dmesg for hardware errors...\n");
11437        let dmesg = Command::new("dmesg")
11438            .args(["--level=err,crit,alert"])
11439            .output()
11440            .ok()
11441            .and_then(|o| String::from_utf8(o.stdout).ok())
11442            .unwrap_or_default();
11443        if dmesg.is_empty() {
11444            out.push_str("  No critical hardware errors found in dmesg.\n");
11445        } else {
11446            out.push_str(&dmesg.lines().take(20).collect::<Vec<_>>().join("\n"));
11447        }
11448    }
11449
11450    Ok(out.trim_end().to_string())
11451}
11452
11453fn inspect_drivers(max_entries: usize) -> Result<String, String> {
11454    let mut out = String::from("Host inspection: drivers\n\n");
11455
11456    #[cfg(target_os = "windows")]
11457    {
11458        out.push_str("=== Active System Drivers (CIM Snapshot) ===\n");
11459        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);
11460        let output = Command::new("powershell")
11461            .args(["-NoProfile", "-Command", &ps_cmd])
11462            .output()
11463            .ok()
11464            .and_then(|o| String::from_utf8(o.stdout).ok())
11465            .unwrap_or_default();
11466
11467        if output.trim().is_empty() {
11468            out.push_str("  No drivers retrieved via WMI.\n");
11469        } else {
11470            out.push_str(&output);
11471        }
11472    }
11473
11474    #[cfg(not(target_os = "windows"))]
11475    {
11476        out.push_str("=== Loaded Kernel Modules (lsmod) ===\n");
11477        let lsmod = Command::new("lsmod")
11478            .output()
11479            .ok()
11480            .and_then(|o| String::from_utf8(o.stdout).ok())
11481            .unwrap_or_default();
11482        out.push_str(
11483            &lsmod
11484                .lines()
11485                .take(max_entries)
11486                .collect::<Vec<_>>()
11487                .join("\n"),
11488        );
11489    }
11490
11491    Ok(out.trim_end().to_string())
11492}
11493
11494fn inspect_peripherals(max_entries: usize) -> Result<String, String> {
11495    let mut out = String::from("Host inspection: peripherals\n\n");
11496
11497    #[cfg(target_os = "windows")]
11498    {
11499        let _ = max_entries;
11500        out.push_str("=== USB Controllers & Hubs ===\n");
11501        let usb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_USBController | ForEach-Object { \"  $($_.Name) ($($_.Status))\" }"])
11502            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11503        out.push_str(if usb.is_empty() {
11504            "  None detected.\n"
11505        } else {
11506            &usb
11507        });
11508
11509        out.push_str("\n=== Input Devices (Keyboard/Pointer) ===\n");
11510        let kb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_Keyboard | ForEach-Object { \"  [KB] $($_.Name) ($($_.Status))\" }"])
11511            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11512        let mouse = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_PointingDevice | ForEach-Object { \"  [PTR] $($_.Name) ($($_.Status))\" }"])
11513            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11514        out.push_str(&kb);
11515        out.push_str(&mouse);
11516
11517        out.push_str("\n=== Connected Monitors (WMI) ===\n");
11518        let mon = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams | ForEach-Object { \"  Display ($($_.Active ? 'Active' : 'Inactive'))\" }"])
11519            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11520        out.push_str(if mon.is_empty() {
11521            "  No active monitors identified via WMI.\n"
11522        } else {
11523            &mon
11524        });
11525    }
11526
11527    #[cfg(not(target_os = "windows"))]
11528    {
11529        out.push_str("=== Connected USB Devices (lsusb) ===\n");
11530        let lsusb = Command::new("lsusb")
11531            .output()
11532            .ok()
11533            .and_then(|o| String::from_utf8(o.stdout).ok())
11534            .unwrap_or_default();
11535        out.push_str(
11536            &lsusb
11537                .lines()
11538                .take(max_entries)
11539                .collect::<Vec<_>>()
11540                .join("\n"),
11541        );
11542    }
11543
11544    Ok(out.trim_end().to_string())
11545}
11546
11547fn inspect_sessions(max_entries: usize) -> Result<String, String> {
11548    let mut out = String::from("Host inspection: sessions\n\n");
11549
11550    #[cfg(target_os = "windows")]
11551    {
11552        out.push_str("=== Active Logon Sessions (WMI Snapshot) ===\n");
11553        let script = r#"Get-CimInstance Win32_LogonSession | ForEach-Object {
11554    "$($_.LogonId)|$($_.StartTime)|$($_.LogonType)|$($_.AuthenticationPackage)"
11555}"#;
11556        if let Ok(o) = Command::new("powershell")
11557            .args(["-NoProfile", "-Command", script])
11558            .output()
11559        {
11560            let text = String::from_utf8_lossy(&o.stdout);
11561            let lines: Vec<&str> = text.lines().collect();
11562            if lines.is_empty() {
11563                out.push_str("  No active logon sessions enumerated via WMI.\n");
11564            } else {
11565                for line in lines
11566                    .iter()
11567                    .take(max_entries)
11568                    .filter(|l| !l.trim().is_empty())
11569                {
11570                    let parts: Vec<&str> = line.trim().split('|').collect();
11571                    if parts.len() == 4 {
11572                        let logon_type = match parts[2] {
11573                            "2" => "Interactive",
11574                            "3" => "Network",
11575                            "4" => "Batch",
11576                            "5" => "Service",
11577                            "7" => "Unlock",
11578                            "8" => "NetworkCleartext",
11579                            "9" => "NewCredentials",
11580                            "10" => "RemoteInteractive",
11581                            "11" => "CachedInteractive",
11582                            _ => "Other",
11583                        };
11584                        out.push_str(&format!(
11585                            "- ID: {} | Type: {} | Started: {} | Auth: {}\n",
11586                            parts[0], logon_type, parts[1], parts[3]
11587                        ));
11588                    }
11589                }
11590            }
11591        } else {
11592            out.push_str("  Active logon session data unavailable from WMI.\n");
11593        }
11594    }
11595
11596    #[cfg(not(target_os = "windows"))]
11597    {
11598        out.push_str("=== Logged-in Users (who) ===\n");
11599        let who = Command::new("who")
11600            .output()
11601            .ok()
11602            .and_then(|o| String::from_utf8(o.stdout).ok())
11603            .unwrap_or_default();
11604        out.push_str(&who.lines().take(max_entries).collect::<Vec<_>>().join("\n"));
11605    }
11606
11607    Ok(out.trim_end().to_string())
11608}
11609
11610async fn inspect_disk_benchmark(path: PathBuf) -> Result<String, String> {
11611    let mut out = String::from("Host inspection: disk_benchmark\n\n");
11612    let mut final_path = path;
11613
11614    if !final_path.exists() {
11615        if let Ok(current_exe) = std::env::current_exe() {
11616            out.push_str(&format!(
11617                "Note: Requested target '{}' not found. Falling back to current binary for silicon-aware intensity report.\n",
11618                final_path.display()
11619            ));
11620            final_path = current_exe;
11621        } else {
11622            return Err(format!("Target not found: {}", final_path.display()));
11623        }
11624    }
11625
11626    let target = if final_path.is_dir() {
11627        // Find a representative file to read
11628        let mut target_file = final_path.join("Cargo.toml");
11629        if !target_file.exists() {
11630            target_file = final_path.join("README.md");
11631        }
11632        if !target_file.exists() {
11633            return Err("Target path is a directory but no representative file (Cargo.toml/README.md) found for benchmarking.".to_string());
11634        }
11635        target_file
11636    } else {
11637        final_path
11638    };
11639
11640    out.push_str(&format!("Target: {}\n", target.display()));
11641    out.push_str("Running diagnostic stress test (5s read-thrash + kernel counter trace)...\n\n");
11642
11643    #[cfg(target_os = "windows")]
11644    {
11645        let script = format!(
11646            r#"
11647$target = "{}"
11648if (-not (Test-Path $target)) {{ "ERROR:Target not found"; exit }}
11649
11650$diskQueue = @()
11651$readStats = @()
11652$startTime = Get-Date
11653$duration = 5
11654
11655# Background reader job
11656$job = Start-Job -ScriptBlock {{
11657    param($t, $d)
11658    $stop = (Get-Date).AddSeconds($d)
11659    while ((Get-Date) -lt $stop) {{
11660        try {{ [System.IO.File]::ReadAllBytes($t) | Out-Null }} catch {{ }}
11661    }}
11662}} -ArgumentList $target, $duration
11663
11664# Metrics collector loop
11665$stopTime = (Get-Date).AddSeconds($duration)
11666while ((Get-Date) -lt $stopTime) {{
11667    $q = Get-Counter '\PhysicalDisk(_Total)\Avg. Disk Queue Length' -ErrorAction SilentlyContinue
11668    if ($q) {{ $diskQueue += $q.CounterSamples[0].CookedValue }}
11669    
11670    $r = Get-Counter '\PhysicalDisk(_Total)\Disk Reads/sec' -ErrorAction SilentlyContinue
11671    if ($r) {{ $readStats += $r.CounterSamples[0].CookedValue }}
11672    
11673    Start-Sleep -Milliseconds 250
11674}}
11675
11676Stop-Job $job
11677Receive-Job $job | Out-Null
11678Remove-Job $job
11679
11680$avgQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Average).Average }} else {{ 0 }}
11681$maxQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Maximum).Maximum }} else {{ 0 }}
11682$avgR = if ($readStats) {{ ($readStats | Measure-Object -Average).Average }} else {{ 0 }}
11683
11684"AVG_Q:$([math]::Round($avgQ, 4))|MAX_Q:$([math]::Round($maxQ, 4))|AVG_R:$([math]::Round($avgR, 2))"
11685"#,
11686            target.display()
11687        );
11688
11689        let output = Command::new("powershell")
11690            .args(["-NoProfile", "-Command", &script])
11691            .output()
11692            .map_err(|e| format!("Benchmark failed: {e}"))?;
11693
11694        let raw = String::from_utf8_lossy(&output.stdout);
11695        let text = raw.trim();
11696
11697        if text.starts_with("ERROR") {
11698            return Err(text.to_string());
11699        }
11700
11701        let mut lines = text.lines();
11702        if let Some(metrics_line) = lines.next() {
11703            let parts: Vec<&str> = metrics_line.split('|').collect();
11704            let mut avg_q = "unknown".to_string();
11705            let mut max_q = "unknown".to_string();
11706            let mut avg_r = "unknown".to_string();
11707
11708            for p in parts {
11709                if let Some((k, v)) = p.split_once(':') {
11710                    match k {
11711                        "AVG_Q" => avg_q = v.to_string(),
11712                        "MAX_Q" => max_q = v.to_string(),
11713                        "AVG_R" => avg_r = v.to_string(),
11714                        _ => {}
11715                    }
11716                }
11717            }
11718
11719            out.push_str("=== WORKSTATION INTENSITY REPORT ===\n");
11720            out.push_str(&format!("- Active Disk Queue (Avg): {}\n", avg_q));
11721            out.push_str(&format!("- Active Disk Queue (Max): {}\n", max_q));
11722            out.push_str(&format!("- Disk Throughput (Avg):  {} reads/sec\n", avg_r));
11723            out.push_str("\nVerdict: ");
11724            let q_num = avg_q.parse::<f64>().unwrap_or(0.0);
11725            if q_num > 1.0 {
11726                out.push_str(
11727                    "HIGH INTENSITY — the disk stack is saturated. Hardware bottleneck confirmed.",
11728                );
11729            } else if q_num > 0.1 {
11730                out.push_str("MODERATE LOAD — significant I/O pressure detected.");
11731            } else {
11732                out.push_str("LIGHT LOAD — the hardware is handling this volume comfortably.");
11733            }
11734        }
11735    }
11736
11737    #[cfg(not(target_os = "windows"))]
11738    {
11739        out.push_str("Note: Native silicon benchmarking is currently optimized for Windows performance counters.\n");
11740        out.push_str("Generic disk load simulated.\n");
11741    }
11742
11743    Ok(out)
11744}
11745
11746fn inspect_permissions(path: PathBuf, _max_entries: usize) -> Result<String, String> {
11747    let mut out = String::from("Host inspection: permissions\n\n");
11748    out.push_str(&format!(
11749        "Auditing access control for: {}\n\n",
11750        path.display()
11751    ));
11752
11753    #[cfg(target_os = "windows")]
11754    {
11755        let script = format!(
11756            "Get-Acl -Path '{}' | Select-Object Owner, AccessToString | ForEach-Object {{ \"OWNER:$($_.Owner)\"; \"RULES:$($_.AccessToString)\" }}",
11757            path.display()
11758        );
11759        let output = Command::new("powershell")
11760            .args(["-NoProfile", "-Command", &script])
11761            .output()
11762            .map_err(|e| format!("ACL check failed: {e}"))?;
11763
11764        let text = String::from_utf8_lossy(&output.stdout);
11765        if text.trim().is_empty() {
11766            out.push_str("No ACL information returned. Ensure the path exists and you have permission to query it.\n");
11767        } else {
11768            out.push_str("=== Windows NTFS Permissions ===\n");
11769            out.push_str(&text);
11770        }
11771    }
11772
11773    #[cfg(not(target_os = "windows"))]
11774    {
11775        let output = Command::new("ls")
11776            .args(["-ld", &path.to_string_lossy()])
11777            .output()
11778            .map_err(|e| format!("ls check failed: {e}"))?;
11779        out.push_str("=== Unix File Permissions ===\n");
11780        out.push_str(&String::from_utf8_lossy(&output.stdout));
11781    }
11782
11783    Ok(out.trim_end().to_string())
11784}
11785
11786fn inspect_login_history(max_entries: usize) -> Result<String, String> {
11787    let mut out = String::from("Host inspection: login_history\n\n");
11788
11789    #[cfg(target_os = "windows")]
11790    {
11791        out.push_str("Checking recent Logon events (Event ID 4624) from the Security Log...\n");
11792        out.push_str("Note: This typically requires Administrator elevation.\n\n");
11793
11794        let n = max_entries.clamp(1, 50);
11795        let script = format!(
11796            r#"try {{
11797    $events = Get-WinEvent -FilterHashtable @{{LogName='Security'; ID=4624}} -MaxEvents {n} -ErrorAction Stop
11798    $events | ForEach-Object {{
11799        $time = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm')
11800        # Extract target user name from the XML/Properties if possible
11801        $user = $_.Properties[5].Value
11802        $type = $_.Properties[8].Value
11803        "[$time] User: $user | Type: $type"
11804    }}
11805}} catch {{ "ERROR:" + $_.Exception.Message }}"#
11806        );
11807
11808        let output = Command::new("powershell")
11809            .args(["-NoProfile", "-Command", &script])
11810            .output()
11811            .map_err(|e| format!("Login history query failed: {e}"))?;
11812
11813        let text = String::from_utf8_lossy(&output.stdout);
11814        if text.starts_with("ERROR:") {
11815            out.push_str(&format!("Unable to query Security Log: {}\n", text));
11816        } else if text.trim().is_empty() {
11817            out.push_str("No recent logon events found or access denied.\n");
11818        } else {
11819            out.push_str("=== Recent Logons (Event ID 4624) ===\n");
11820            out.push_str(&text);
11821        }
11822    }
11823
11824    #[cfg(not(target_os = "windows"))]
11825    {
11826        let output = Command::new("last")
11827            .args(["-n", &max_entries.to_string()])
11828            .output()
11829            .map_err(|e| format!("last command failed: {e}"))?;
11830        out.push_str("=== Unix Login History (last) ===\n");
11831        out.push_str(&String::from_utf8_lossy(&output.stdout));
11832    }
11833
11834    Ok(out.trim_end().to_string())
11835}
11836
11837fn inspect_share_access(path: PathBuf) -> Result<String, String> {
11838    let mut out = String::from("Host inspection: share_access\n\n");
11839    out.push_str(&format!("Testing accessibility of: {}\n\n", path.display()));
11840
11841    #[cfg(target_os = "windows")]
11842    {
11843        let script = format!(
11844            r#"
11845$p = '{}'
11846$res = @{{ Reachable = $false; Readable = $false; Error = "" }}
11847if (Test-Connection -ComputerName ($p.Split('\')[2]) -Count 1 -Quiet -ErrorAction SilentlyContinue) {{
11848    $res.Reachable = $true
11849    try {{
11850        $null = Get-ChildItem -Path $p -ErrorAction Stop
11851        $res.Readable = $true
11852    }} catch {{
11853        $res.Error = $_.Exception.Message
11854    }}
11855}} else {{
11856    $res.Error = "Server unreachable (Ping failed)"
11857}}
11858"REACHABLE:$($res.Reachable)|READABLE:$($res.Readable)|ERROR:$($res.Error)""#,
11859            path.display()
11860        );
11861
11862        let output = Command::new("powershell")
11863            .args(["-NoProfile", "-Command", &script])
11864            .output()
11865            .map_err(|e| format!("Share test failed: {e}"))?;
11866
11867        let text = String::from_utf8_lossy(&output.stdout);
11868        out.push_str("=== Share Triage Results ===\n");
11869        out.push_str(&text);
11870    }
11871
11872    #[cfg(not(target_os = "windows"))]
11873    {
11874        out.push_str("Share access testing is primarily optimized for Windows UNC paths.\n");
11875    }
11876
11877    Ok(out.trim_end().to_string())
11878}
11879
11880fn inspect_dns_fix_plan(issue: &str) -> Result<String, String> {
11881    let mut out = String::from("Host inspection: fix_plan (DNS Resolution)\n\n");
11882    out.push_str(&format!("Issue: {}\n\n", issue));
11883    out.push_str("Proposed Remediation Steps:\n");
11884    out.push_str("1. **Flush DNS Cache**: Clear local resolver cache.\n");
11885    out.push_str("   `ipconfig /flushdns` (Windows) or `sudo resolvectl flush-caches` (Linux)\n");
11886    out.push_str("2. **Validate Hosts File**: Check for static overrides.\n");
11887    out.push_str("   `Get-Content C:\\Windows\\System32\\drivers\\etc\\hosts` (Windows)\n");
11888    out.push_str("3. **Test Name Resolution**: Use nslookup to query a specific server.\n");
11889    out.push_str("   `nslookup google.com 8.8.8.8` (Tests if external DNS works)\n");
11890    out.push_str("4. **Check Adapter DNS**: Ensure local settings match expected nameservers.\n");
11891    out.push_str(
11892        "   `Get-NetIPConfiguration | Select-Object InterfaceAlias, DNSServer` (Windows)\n",
11893    );
11894
11895    Ok(out)
11896}
11897
11898fn inspect_registry_audit() -> Result<String, String> {
11899    let mut out = String::from("Host inspection: registry_audit\n\n");
11900    out.push_str("Auditing advanced persistence points and shell integrity overrides...\n\n");
11901
11902    #[cfg(target_os = "windows")]
11903    {
11904        let script = r#"
11905$findings = @()
11906
11907# 1. Image File Execution Options (Debugger Hijacking)
11908$ifeo = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options"
11909if (Test-Path $ifeo) {
11910    Get-ChildItem $ifeo | ForEach-Object {
11911        $p = Get-ItemProperty $_.PSPath
11912        if ($p.debugger) { $findings += "[IFEO Hijack] $($_.PSChildName) -> Debugger defined: $($p.debugger)" }
11913    }
11914}
11915
11916# 2. Winlogon Shell Integrity
11917$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
11918$shell = (Get-ItemProperty $winlogon -Name Shell -ErrorAction SilentlyContinue).Shell
11919if ($shell -and $shell -ne "explorer.exe") {
11920    $findings += "[Winlogon Overlook] Non-standard shell defined: $shell"
11921}
11922
11923# 3. Session Manager BootExecute
11924$sm = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager"
11925$boot = (Get-ItemProperty $sm -Name BootExecute -ErrorAction SilentlyContinue).BootExecute
11926if ($boot -and $boot -notcontains "autocheck autochk *") {
11927    $findings += "[Boot Integrity] Non-standard BootExecute defined: $($boot -join ', ')"
11928}
11929
11930if ($findings.Count -eq 0) {
11931    "PASS: No common registry hijacking or shell overrides detected."
11932} else {
11933    $findings -join "`n"
11934}
11935"#;
11936        let output = Command::new("powershell")
11937            .args(["-NoProfile", "-Command", &script])
11938            .output()
11939            .map_err(|e| format!("Registry audit failed: {e}"))?;
11940
11941        let text = String::from_utf8_lossy(&output.stdout);
11942        out.push_str("=== Persistence & Integrity Check ===\n");
11943        out.push_str(&text);
11944    }
11945
11946    #[cfg(not(target_os = "windows"))]
11947    {
11948        out.push_str("Registry auditing is specific to Windows environments.\n");
11949    }
11950
11951    Ok(out.trim_end().to_string())
11952}
11953
11954fn inspect_thermal() -> Result<String, String> {
11955    let mut out = String::from("Host inspection: thermal\n\n");
11956    out.push_str("Checking CPU thermal state and active throttling indicators...\n\n");
11957
11958    #[cfg(target_os = "windows")]
11959    {
11960        let script = r#"
11961$thermal = Get-CimInstance -ClassName Win32_PerfRawData_Counters_ThermalZoneInformation -ErrorAction SilentlyContinue
11962if ($thermal) {
11963    $thermal | ForEach-Object {
11964        $temp = [math]::Round(($_.Temperature - 273.15), 1)
11965        "Zone: $($_.Name) | Temp: $temp °C | Throttling: $($_.HighPrecisionTemperature -eq 0 ? 'NO' : 'ACTIVE')"
11966    }
11967} else {
11968    "Thermal counters not directly available via WMI. Checking for system throttling indicators..."
11969    $throttling = Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty LoadPercentage
11970    "Current CPU Load: $throttling%"
11971}
11972"#;
11973        let output = Command::new("powershell")
11974            .args(["-NoProfile", "-Command", script])
11975            .output()
11976            .map_err(|e| format!("Thermal check failed: {e}"))?;
11977        out.push_str("=== Windows Thermal State ===\n");
11978        out.push_str(&String::from_utf8_lossy(&output.stdout));
11979    }
11980
11981    #[cfg(not(target_os = "windows"))]
11982    {
11983        out.push_str(
11984            "Thermal inspection is currently optimized for Windows performance counters.\n",
11985        );
11986    }
11987
11988    Ok(out.trim_end().to_string())
11989}
11990
11991fn inspect_activation() -> Result<String, String> {
11992    let mut out = String::from("Host inspection: activation\n\n");
11993    out.push_str("Auditing Windows activation and license state...\n\n");
11994
11995    #[cfg(target_os = "windows")]
11996    {
11997        let script = r#"
11998$xpr = cscript //nologo C:\Windows\System32\slmgr.vbs /xpr
11999$dli = cscript //nologo C:\Windows\System32\slmgr.vbs /dli
12000"Status: $($xpr.Trim())"
12001"Details: $($dli -join ' ' | Select-String -Pattern 'License Status|Name' -AllMatches | ForEach-Object { $_.ToString().Trim() })"
12002"#;
12003        let output = Command::new("powershell")
12004            .args(["-NoProfile", "-Command", script])
12005            .output()
12006            .map_err(|e| format!("Activation check failed: {e}"))?;
12007        out.push_str("=== Windows License Report ===\n");
12008        out.push_str(&String::from_utf8_lossy(&output.stdout));
12009    }
12010
12011    #[cfg(not(target_os = "windows"))]
12012    {
12013        out.push_str("Windows activation check is specific to the Windows platform.\n");
12014    }
12015
12016    Ok(out.trim_end().to_string())
12017}
12018
12019fn inspect_patch_history(max_entries: usize) -> Result<String, String> {
12020    let mut out = String::from("Host inspection: patch_history\n\n");
12021    out.push_str(&format!(
12022        "Listing the last {} installed Windows updates (KBs)...\n\n",
12023        max_entries
12024    ));
12025
12026    #[cfg(target_os = "windows")]
12027    {
12028        let n = max_entries.clamp(1, 50);
12029        let script = format!(
12030            "Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First {} | ForEach-Object {{ \"[$($_.InstalledOn.ToString('yyyy-MM-dd'))] $($_.HotFixID) - $($_.Description)\" }}",
12031            n
12032        );
12033        let output = Command::new("powershell")
12034            .args(["-NoProfile", "-Command", &script])
12035            .output()
12036            .map_err(|e| format!("Patch history query failed: {e}"))?;
12037        out.push_str("=== Recent HotFixes (KBs) ===\n");
12038        out.push_str(&String::from_utf8_lossy(&output.stdout));
12039    }
12040
12041    #[cfg(not(target_os = "windows"))]
12042    {
12043        out.push_str("Patch history is currently focused on Windows HotFixes.\n");
12044    }
12045
12046    Ok(out.trim_end().to_string())
12047}
12048
12049// ── ad_user ──────────────────────────────────────────────────────────────────
12050
12051fn inspect_ad_user(identity: &str) -> Result<String, String> {
12052    let mut out = String::from("Host inspection: ad_user\n\n");
12053    let ident = identity.trim();
12054    if ident.is_empty() {
12055        out.push_str("Status: No identity specified. Performing self-discovery...\n");
12056        #[cfg(target_os = "windows")]
12057        {
12058            let script = r#"
12059$u = [System.Security.Principal.WindowsIdentity]::GetCurrent()
12060"USER: " + $u.Name
12061"SID: " + $u.User.Value
12062"GROUPS: " + (($u.Groups | ForEach-Object { try { $_.Translate([System.Security.Principal.NTAccount]).Value } catch { $_.Value } }) -join ', ')
12063"ELEVATED: " + (New-Object System.Security.Principal.WindowsPrincipal($u)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
12064"#;
12065            let output = Command::new("powershell")
12066                .args(["-NoProfile", "-Command", script])
12067                .output()
12068                .ok();
12069            if let Some(o) = output {
12070                out.push_str(&String::from_utf8_lossy(&o.stdout));
12071            }
12072        }
12073        return Ok(out);
12074    }
12075
12076    #[cfg(target_os = "windows")]
12077    {
12078        let script = format!(
12079            r#"
12080try {{
12081    $u = Get-ADUser -Identity "{ident}" -Properties MemberOf, LastLogonDate, Enabled, PasswordExpired -ErrorAction Stop
12082    "NAME: " + $u.Name
12083    "SID: " + $u.SID
12084    "ENABLED: " + $u.Enabled
12085    "EXPIRED: " + $u.PasswordExpired
12086    "LOGON: " + $u.LastLogonDate
12087    "GROUPS: " + ($u.MemberOf -replace "CN=([^,]+),.*", "$1" -join ", ")
12088}} catch {{
12089    # Fallback to net user if AD module is missing or fails
12090    $net = net user "{ident}" /domain 2>&1
12091    if ($LASTEXITCODE -eq 0) {{
12092        $net | Select-String "User name", "Full Name", "Account active", "Password expires", "Last logon", "Local Group Memberships", "Global Group memberships" | ForEach-Object {{ $_.ToString().Trim() }}
12093    }} else {{
12094        "ERROR: " + $_.Exception.Message
12095    }}
12096}}"#
12097        );
12098
12099        let output = Command::new("powershell")
12100            .args(["-NoProfile", "-Command", &script])
12101            .output()
12102            .ok();
12103
12104        if let Some(o) = output {
12105            let stdout = String::from_utf8_lossy(&o.stdout);
12106            if stdout.contains("ERROR:") && stdout.contains("Get-ADUser") {
12107                out.push_str("Active Directory PowerShell module not found. Showing basic domain user info:\n\n");
12108            }
12109            out.push_str(&stdout);
12110        }
12111    }
12112
12113    #[cfg(not(target_os = "windows"))]
12114    {
12115        let _ = ident;
12116        out.push_str("(AD User lookup only available on Windows nodes)\n");
12117    }
12118
12119    Ok(out.trim_end().to_string())
12120}
12121
12122// ── dns_lookup ───────────────────────────────────────────────────────────────
12123
12124fn inspect_dns_lookup(name: &str, record_type: &str) -> Result<String, String> {
12125    let mut out = String::from("Host inspection: dns_lookup\n\n");
12126    let target = name.trim();
12127    if target.is_empty() {
12128        return Err("Missing required target name for dns_lookup.".to_string());
12129    }
12130
12131    #[cfg(target_os = "windows")]
12132    {
12133        let script = format!("Resolve-DnsName -Name '{target}' -Type {record_type} -ErrorAction SilentlyContinue | Select-Object Name, Type, TTL, Section, NameHost, Strings, IPAddress, Address | Format-List");
12134        let output = Command::new("powershell")
12135            .args(["-NoProfile", "-Command", &script])
12136            .output()
12137            .ok();
12138        if let Some(o) = output {
12139            let stdout = String::from_utf8_lossy(&o.stdout);
12140            if stdout.trim().is_empty() {
12141                out.push_str(&format!("No {record_type} records found for {target}.\n"));
12142            } else {
12143                out.push_str(&stdout);
12144            }
12145        }
12146    }
12147
12148    #[cfg(not(target_os = "windows"))]
12149    {
12150        let output = Command::new("dig")
12151            .args([target, record_type, "+short"])
12152            .output()
12153            .ok();
12154        if let Some(o) = output {
12155            out.push_str(&String::from_utf8_lossy(&o.stdout));
12156        }
12157    }
12158
12159    Ok(out.trim_end().to_string())
12160}
12161
12162// ── hyperv ───────────────────────────────────────────────────────────────────
12163
12164#[cfg(target_os = "windows")]
12165fn ps_exec(script: &str) -> String {
12166    Command::new("powershell")
12167        .args(["-NoProfile", "-NonInteractive", "-Command", script])
12168        .output()
12169        .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
12170        .unwrap_or_default()
12171}
12172
12173fn inspect_mdm_enrollment() -> Result<String, String> {
12174    #[cfg(target_os = "windows")]
12175    {
12176        let mut out = String::from("Host inspection: mdm_enrollment\n\n");
12177
12178        // ── dsregcmd /status — primary enrollment signal ──────────────────────
12179        out.push_str("=== Device join and MDM state (dsregcmd) ===\n");
12180        let ps_dsreg = r#"
12181$raw = dsregcmd /status 2>$null
12182$fields = @('AzureAdJoined','EnterpriseJoined','DomainJoined','MdmEnrolled',
12183            'WamDefaultSet','AzureAdPrt','TenantName','TenantId','MdmUrl','MdmTouUrl')
12184foreach ($line in $raw) {
12185    $t = $line.Trim()
12186    foreach ($f in $fields) {
12187        if ($t -like "$f :*") {
12188            $val = ($t -split ':',2)[1].Trim()
12189            "$f`: $val"
12190        }
12191    }
12192}
12193"#;
12194        match run_powershell(ps_dsreg) {
12195            Ok(o) if !o.trim().is_empty() => {
12196                for line in o.lines() {
12197                    let l = line.trim();
12198                    if !l.is_empty() {
12199                        out.push_str(&format!("- {l}\n"));
12200                    }
12201                }
12202            }
12203            Ok(_) => out.push_str(
12204                "- dsregcmd returned no enrollment fields (device may not be AAD-joined)\n",
12205            ),
12206            Err(e) => out.push_str(&format!("- dsregcmd error: {e}\n")),
12207        }
12208
12209        // ── Registry enrollment accounts ──────────────────────────────────────
12210        out.push_str("\n=== Enrollment accounts (registry) ===\n");
12211        let ps_enroll = r#"
12212$base = 'HKLM:\SOFTWARE\Microsoft\Enrollments'
12213if (Test-Path $base) {
12214    $accounts = Get-ChildItem $base -ErrorAction SilentlyContinue
12215    if ($accounts) {
12216        foreach ($acct in $accounts) {
12217            $p = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
12218            $upn    = if ($p.UPN)                { $p.UPN }                else { '(none)' }
12219            $server = if ($p.EnrollmentServerUrl){ $p.EnrollmentServerUrl }else { '(none)' }
12220            $type   = switch ($p.EnrollmentType) {
12221                6  { 'MDM' }
12222                13 { 'MAM' }
12223                default { "Type=$($p.EnrollmentType)" }
12224            }
12225            $state  = switch ($p.EnrollmentState) {
12226                1  { 'Enrolled' }
12227                2  { 'InProgress' }
12228                6  { 'Unenrolled' }
12229                default { "State=$($p.EnrollmentState)" }
12230            }
12231            "Account: $upn | $type | $state | $server"
12232        }
12233    } else { "No enrollment accounts found under $base" }
12234} else { "Enrollment registry key not found — device is not MDM-enrolled" }
12235"#;
12236        match run_powershell(ps_enroll) {
12237            Ok(o) => {
12238                for line in o.lines() {
12239                    let l = line.trim();
12240                    if !l.is_empty() {
12241                        out.push_str(&format!("- {l}\n"));
12242                    }
12243                }
12244            }
12245            Err(e) => out.push_str(&format!("- Registry read error: {e}\n")),
12246        }
12247
12248        // ── MDM service health ────────────────────────────────────────────────
12249        out.push_str("\n=== MDM services ===\n");
12250        let ps_svc = r#"
12251$names = @('IntuneManagementExtension','dmwappushservice','Microsoft.Management.Services.IntuneWindowsAgent')
12252foreach ($n in $names) {
12253    $s = Get-Service -Name $n -ErrorAction SilentlyContinue
12254    if ($s) { "$($s.Name): $($s.Status) (StartType: $($s.StartType))" }
12255}
12256"#;
12257        match run_powershell(ps_svc) {
12258            Ok(o) if !o.trim().is_empty() => {
12259                for line in o.lines() {
12260                    let l = line.trim();
12261                    if !l.is_empty() {
12262                        out.push_str(&format!("- {l}\n"));
12263                    }
12264                }
12265            }
12266            Ok(_) => out.push_str("- No Intune management services found (unmanaged device or extension not installed)\n"),
12267            Err(e) => out.push_str(&format!("- Service query error: {e}\n")),
12268        }
12269
12270        // ── Recent MDM / Intune events ────────────────────────────────────────
12271        out.push_str("\n=== Recent MDM events (last 24h) ===\n");
12272        let ps_evt = r#"
12273$logs = @('Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin',
12274          'Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot')
12275$cutoff = (Get-Date).AddHours(-24)
12276$found = $false
12277foreach ($log in $logs) {
12278    $evts = Get-WinEvent -LogName $log -MaxEvents 20 -ErrorAction SilentlyContinue |
12279            Where-Object { $_.TimeCreated -gt $cutoff -and $_.Level -le 3 }
12280    foreach ($e in $evts) {
12281        $found = $true
12282        $ts = $e.TimeCreated.ToString('HH:mm')
12283        $lvl = if ($e.Level -eq 2) { 'ERR' } else { 'WARN' }
12284        "[$lvl $ts] ID=$($e.Id) — $($e.Message.Split("`n")[0].Trim())"
12285    }
12286}
12287if (-not $found) { "No MDM warning/error events in the last 24 hours" }
12288"#;
12289        match run_powershell(ps_evt) {
12290            Ok(o) => {
12291                for line in o.lines() {
12292                    let l = line.trim();
12293                    if !l.is_empty() {
12294                        out.push_str(&format!("- {l}\n"));
12295                    }
12296                }
12297            }
12298            Err(e) => out.push_str(&format!("- Event log read error: {e}\n")),
12299        }
12300
12301        // ── Findings ──────────────────────────────────────────────────────────
12302        out.push_str("\n=== Findings ===\n");
12303        let body = out.clone();
12304        let enrolled = body.contains("MdmEnrolled: YES") || body.contains("| Enrolled |");
12305        let intune_running = body.contains("IntuneManagementExtension: Running");
12306        let has_errors = body.contains("[ERR ") || body.contains("[WARN ");
12307
12308        if !enrolled {
12309            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");
12310        } else {
12311            out.push_str("- ENROLLED: Device has an active MDM enrollment.\n");
12312            if !intune_running {
12313                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");
12314            }
12315        }
12316        if has_errors {
12317            out.push_str("- MDM error/warning events detected — review the events section above for blockers.\n");
12318        }
12319        if !enrolled && !has_errors {
12320            out.push_str("- No MDM error events detected. If enrollment is required, initiate from Settings > Accounts > Access work or school > Connect.\n");
12321        }
12322
12323        Ok(out)
12324    }
12325
12326    #[cfg(not(target_os = "windows"))]
12327    {
12328        Ok("Host inspection: mdm_enrollment\n\n=== Findings ===\n- MDM/Intune enrollment inspection is Windows-only.\n".into())
12329    }
12330}
12331
12332fn inspect_hyperv() -> Result<String, String> {
12333    #[cfg(target_os = "windows")]
12334    {
12335        let mut findings: Vec<String> = Vec::new();
12336        let mut out = String::new();
12337
12338        // --- Hyper-V role / VMMS service state ---
12339        let ps_role = r#"
12340$vmms = Get-Service -Name vmms -ErrorAction SilentlyContinue
12341$feature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -ErrorAction SilentlyContinue
12342$hostInfo = Get-VMHost -ErrorAction SilentlyContinue
12343$ram = (Get-CimInstance Win32_PhysicalMemory -ErrorAction SilentlyContinue | Measure-Object -Property Capacity -Sum).Sum
12344"VMMS:{0}|FeatureState:{1}|HostName:{2}|HostRAMBytes:{3}" -f `
12345    $(if ($vmms) { "$($vmms.Status)|$($vmms.StartType)" } else { "NotFound|Unknown" }),
12346    $(if ($feature) { $feature.State } else { "Unknown" }),
12347    $(if ($hostInfo) { $hostInfo.ComputerName } else { $env:COMPUTERNAME }),
12348    $(if ($ram) { $ram } else { "0" })
12349"#;
12350        let role_out = ps_exec(ps_role);
12351        out.push_str("=== Hyper-V role state ===\n");
12352
12353        let mut vmms_running = false;
12354        let mut host_ram_bytes: u64 = 0;
12355
12356        if let Some(line) = role_out.lines().find(|l| l.contains("VMMS:")) {
12357            let kv: std::collections::HashMap<&str, &str> = line
12358                .split('|')
12359                .filter_map(|p| {
12360                    let mut it = p.splitn(2, ':');
12361                    Some((it.next()?, it.next()?))
12362                })
12363                .collect();
12364            let vmms_status = kv.get("VMMS").copied().unwrap_or("Unknown");
12365            let feature_state = kv.get("FeatureState").copied().unwrap_or("Unknown");
12366            let host_name = kv.get("HostName").copied().unwrap_or("Unknown");
12367            host_ram_bytes = kv
12368                .get("HostRAMBytes")
12369                .and_then(|v| v.parse().ok())
12370                .unwrap_or(0);
12371
12372            let hyperv_installed = feature_state.eq_ignore_ascii_case("enabled");
12373            vmms_running = vmms_status.starts_with("Running");
12374
12375            out.push_str(&format!("- Host: {host_name}\n"));
12376            out.push_str(&format!(
12377                "- Hyper-V feature: {}\n",
12378                if hyperv_installed {
12379                    "Enabled"
12380                } else {
12381                    "Not installed"
12382                }
12383            ));
12384            out.push_str(&format!("- VMMS service: {vmms_status}\n"));
12385            if host_ram_bytes > 0 {
12386                out.push_str(&format!(
12387                    "- Host physical RAM: {} GB\n",
12388                    host_ram_bytes / 1_073_741_824
12389                ));
12390            }
12391
12392            if !hyperv_installed {
12393                findings.push(
12394                    "Hyper-V is not installed on this machine. Enable the Microsoft-Hyper-V-All feature to use virtualization.".into(),
12395                );
12396            } else if !vmms_running {
12397                findings.push(
12398                    "Hyper-V is installed but the Virtual Machine Management Service (vmms) is not running — VMs cannot start until the service is active.".into(),
12399                );
12400            }
12401        } else {
12402            out.push_str("- Could not determine Hyper-V role state\n");
12403            findings.push("Hyper-V does not appear to be installed on this machine.".into());
12404        }
12405
12406        // --- Virtual machines ---
12407        out.push_str("\n=== Virtual machines ===\n");
12408        if vmms_running {
12409            let ps_vms = r#"
12410Get-VM -ErrorAction SilentlyContinue | ForEach-Object {
12411    $ram_gb = [math]::Round($_.MemoryAssigned / 1GB, 2)
12412    "VM:{0}|State:{1}|CPU:{2}%|RAM:{3}GB|Uptime:{4}|Status:{5}|Generation:{6}" -f `
12413        $_.Name, $_.State, $_.CPUUsage, $ram_gb,
12414        $(if ($_.Uptime.TotalSeconds -gt 0) { "$($_.Uptime.Hours)h$($_.Uptime.Minutes)m" } else { "Off" }),
12415        $_.Status, $_.Generation
12416}
12417"#;
12418            let vms_out = ps_exec(ps_vms);
12419            let vm_lines: Vec<&str> = vms_out.lines().filter(|l| l.starts_with("VM:")).collect();
12420
12421            if vm_lines.is_empty() {
12422                out.push_str("- No virtual machines found on this host\n");
12423            } else {
12424                let mut total_ram_bytes: u64 = 0;
12425                let mut saved_vms: Vec<String> = Vec::new();
12426                for line in &vm_lines {
12427                    let kv: std::collections::HashMap<&str, &str> = line
12428                        .split('|')
12429                        .filter_map(|p| {
12430                            let mut it = p.splitn(2, ':');
12431                            Some((it.next()?, it.next()?))
12432                        })
12433                        .collect();
12434                    let name = kv.get("VM").copied().unwrap_or("Unknown");
12435                    let state = kv.get("State").copied().unwrap_or("Unknown");
12436                    let cpu = kv.get("CPU").copied().unwrap_or("0").trim_end_matches('%');
12437                    let ram = kv.get("RAM").copied().unwrap_or("0").trim_end_matches("GB");
12438                    let uptime = kv.get("Uptime").copied().unwrap_or("Off");
12439                    let status = kv.get("Status").copied().unwrap_or("");
12440                    let gen = kv.get("Generation").copied().unwrap_or("?");
12441
12442                    if let Ok(r) = ram.parse::<f64>() {
12443                        total_ram_bytes += (r * 1_073_741_824.0) as u64;
12444                    }
12445                    if state.eq_ignore_ascii_case("Saved") {
12446                        saved_vms.push(name.to_string());
12447                    }
12448
12449                    out.push_str(&format!(
12450                        "- {name} | State: {state} | CPU: {cpu}% | RAM: {ram} GB | Uptime: {uptime} | Gen{gen}\n"
12451                    ));
12452                    if !status.is_empty() && !status.eq_ignore_ascii_case("Operating normally") {
12453                        out.push_str(&format!("  Status: {status}\n"));
12454                    }
12455                }
12456
12457                out.push_str(&format!("\n- Total VMs: {}\n", vm_lines.len()));
12458                if total_ram_bytes > 0 && host_ram_bytes > 0 {
12459                    let pct = (total_ram_bytes * 100) / host_ram_bytes;
12460                    out.push_str(&format!(
12461                        "- Total VM RAM assigned: {} GB ({pct}% of host RAM)\n",
12462                        total_ram_bytes / 1_073_741_824
12463                    ));
12464                    if pct > 90 {
12465                        findings.push(format!(
12466                            "VM RAM assignment is at {pct}% of host physical RAM — the host may be under severe memory pressure if all VMs run simultaneously."
12467                        ));
12468                    }
12469                }
12470                if !saved_vms.is_empty() {
12471                    findings.push(format!(
12472                        "VMs in Saved state (consuming disk space for checkpoint): {} — resume or delete to free space.",
12473                        saved_vms.join(", ")
12474                    ));
12475                }
12476            }
12477        } else {
12478            out.push_str("- VMMS not running — cannot enumerate VMs\n");
12479        }
12480
12481        // --- VM network switches ---
12482        out.push_str("\n=== VM network switches ===\n");
12483        if vmms_running {
12484            let ps_switches = r#"
12485Get-VMSwitch -ErrorAction SilentlyContinue | ForEach-Object {
12486    "Switch:{0}|Type:{1}|Adapter:{2}" -f `
12487        $_.Name, $_.SwitchType,
12488        $(if ($_.NetAdapterInterfaceDescription) { $_.NetAdapterInterfaceDescription } else { "N/A" })
12489}
12490"#;
12491            let sw_out = ps_exec(ps_switches);
12492            let switch_lines: Vec<&str> = sw_out
12493                .lines()
12494                .filter(|l| l.starts_with("Switch:"))
12495                .collect();
12496
12497            if switch_lines.is_empty() {
12498                out.push_str("- No VM switches configured\n");
12499            } else {
12500                for line in &switch_lines {
12501                    let kv: std::collections::HashMap<&str, &str> = line
12502                        .split('|')
12503                        .filter_map(|p| {
12504                            let mut it = p.splitn(2, ':');
12505                            Some((it.next()?, it.next()?))
12506                        })
12507                        .collect();
12508                    let name = kv.get("Switch").copied().unwrap_or("Unknown");
12509                    let sw_type = kv.get("Type").copied().unwrap_or("Unknown");
12510                    let adapter = kv.get("Adapter").copied().unwrap_or("N/A");
12511                    out.push_str(&format!("- {name} | Type: {sw_type} | NIC: {adapter}\n"));
12512                }
12513            }
12514        } else {
12515            out.push_str("- VMMS not running — cannot enumerate switches\n");
12516        }
12517
12518        // --- VM checkpoints ---
12519        out.push_str("\n=== VM checkpoints ===\n");
12520        if vmms_running {
12521            let ps_checkpoints = r#"
12522$all = Get-VMCheckpoint -VMName * -ErrorAction SilentlyContinue
12523if ($all) {
12524    $all | ForEach-Object {
12525        "Checkpoint:{0}|VM:{1}|Created:{2}|Type:{3}" -f `
12526            $_.Name, $_.VMName,
12527            $_.CreationTime.ToString("yyyy-MM-dd HH:mm"),
12528            $_.SnapshotType
12529    }
12530} else {
12531    "NONE"
12532}
12533"#;
12534            let cp_out = ps_exec(ps_checkpoints);
12535            if cp_out.trim() == "NONE" || cp_out.trim().is_empty() {
12536                out.push_str("- No checkpoints found\n");
12537            } else {
12538                let cp_lines: Vec<&str> = cp_out
12539                    .lines()
12540                    .filter(|l| l.starts_with("Checkpoint:"))
12541                    .collect();
12542                let mut per_vm: std::collections::HashMap<&str, usize> =
12543                    std::collections::HashMap::new();
12544                for line in &cp_lines {
12545                    let kv: std::collections::HashMap<&str, &str> = line
12546                        .split('|')
12547                        .filter_map(|p| {
12548                            let mut it = p.splitn(2, ':');
12549                            Some((it.next()?, it.next()?))
12550                        })
12551                        .collect();
12552                    let cp_name = kv.get("Checkpoint").copied().unwrap_or("Unknown");
12553                    let vm_name = kv.get("VM").copied().unwrap_or("Unknown");
12554                    let created = kv.get("Created").copied().unwrap_or("");
12555                    let cp_type = kv.get("Type").copied().unwrap_or("");
12556                    out.push_str(&format!(
12557                        "- [{vm_name}] {cp_name} | Created: {created} | Type: {cp_type}\n"
12558                    ));
12559                    *per_vm.entry(vm_name).or_insert(0) += 1;
12560                }
12561                for (vm, count) in &per_vm {
12562                    if *count >= 3 {
12563                        findings.push(format!(
12564                            "VM '{vm}' has {count} checkpoints — excessive checkpoints grow the VHDX chain and degrade disk performance. Delete unneeded checkpoints."
12565                        ));
12566                    }
12567                }
12568            }
12569        } else {
12570            out.push_str("- VMMS not running — cannot enumerate checkpoints\n");
12571        }
12572
12573        let mut result = String::from("Host inspection: hyperv\n\n=== Findings ===\n");
12574        if findings.is_empty() {
12575            result.push_str("- No Hyper-V health issues detected.\n");
12576        } else {
12577            for f in &findings {
12578                result.push_str(&format!("- Finding: {f}\n"));
12579            }
12580        }
12581        result.push('\n');
12582        result.push_str(&out);
12583        return Ok(result.trim_end().to_string());
12584    }
12585
12586    #[cfg(not(target_os = "windows"))]
12587    Ok(
12588        "Host inspection: hyperv\n\n=== Findings ===\n- Hyper-V inspection is Windows-only.\n"
12589            .into(),
12590    )
12591}
12592
12593// ── ip_config ────────────────────────────────────────────────────────────────
12594
12595fn inspect_ip_config() -> Result<String, String> {
12596    let mut out = String::from("Host inspection: ip_config\n\n");
12597
12598    #[cfg(target_os = "windows")]
12599    {
12600        let script = "Get-NetIPConfiguration -Detailed | ForEach-Object { \
12601            $_.InterfaceAlias + ' [' + $_.InterfaceDescription + ']' + \
12602            '\\n  Status: ' + $_.NetAdapter.Status + \
12603            '\\n  Initial IPv4: ' + ($_.IPv4Address.IPAddress -join ', ') + \
12604            '\\n  DHCP Enabled: ' + $_.NetAdapter.DhcpStatus + \
12605            '\\n  DHCP Server: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
12606            '\\n  IPv4 Default Gateway: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
12607            '\\n  DNSServer: ' + ($_.DNSServer.ServerAddresses -join ', ') + '\\n' \
12608        }";
12609        let output = Command::new("powershell")
12610            .args(["-NoProfile", "-Command", script])
12611            .output()
12612            .ok();
12613        if let Some(o) = output {
12614            out.push_str(&String::from_utf8_lossy(&o.stdout));
12615        }
12616    }
12617
12618    #[cfg(not(target_os = "windows"))]
12619    {
12620        let output = Command::new("ip").args(["addr", "show"]).output().ok();
12621        if let Some(o) = output {
12622            out.push_str(&String::from_utf8_lossy(&o.stdout));
12623        }
12624    }
12625
12626    Ok(out.trim_end().to_string())
12627}
12628
12629// ── event_query ──────────────────────────────────────────────────────────────
12630
12631fn inspect_event_query(
12632    event_id: Option<u32>,
12633    log_name: Option<&str>,
12634    source: Option<&str>,
12635    hours: u32,
12636    level: Option<&str>,
12637    max_entries: usize,
12638) -> Result<String, String> {
12639    #[cfg(target_os = "windows")]
12640    {
12641        let mut findings: Vec<String> = Vec::new();
12642
12643        // Build the PowerShell filter hash
12644        let log = log_name.unwrap_or("*");
12645        let cap = max_entries.min(50);
12646
12647        // Level mapping: Error=2, Warning=3, Information=4
12648        let level_filter = match level.map(|l| l.to_lowercase()).as_deref() {
12649            Some("error") | Some("errors") => Some(2u8),
12650            Some("warning") | Some("warnings") | Some("warn") => Some(3u8),
12651            Some("information") | Some("info") => Some(4u8),
12652            _ => None,
12653        };
12654
12655        // Build filter hashtable entries
12656        let mut filter_parts = vec![format!("StartTime = (Get-Date).AddHours(-{hours})")];
12657        if log != "*" {
12658            filter_parts.push(format!("LogName = '{log}'"));
12659        }
12660        if let Some(id) = event_id {
12661            filter_parts.push(format!("Id = {id}"));
12662        }
12663        if let Some(src) = source {
12664            filter_parts.push(format!("ProviderName = '{src}'"));
12665        }
12666        if let Some(lvl) = level_filter {
12667            filter_parts.push(format!("Level = {lvl}"));
12668        }
12669
12670        let filter_ht = filter_parts.join("; ");
12671
12672        let ps = format!(
12673            r#"
12674$filter = @{{ {filter_ht} }}
12675try {{
12676    $events = Get-WinEvent -FilterHashtable $filter -MaxEvents {cap} -ErrorAction Stop |
12677        Select-Object TimeCreated, Id, LevelDisplayName, ProviderName,
12678            @{{N='Msg';E={{ ($_.Message -split "`n")[0] }}}}
12679    if ($events) {{
12680        $events | ForEach-Object {{
12681            "TIME:{{0}}|ID:{{1}}|LEVEL:{{2}}|SOURCE:{{3}}|MSG:{{4}}" -f `
12682                $_.TimeCreated.ToString("yyyy-MM-dd HH:mm:ss"),
12683                $_.Id, $_.LevelDisplayName, $_.ProviderName,
12684                ($_.Msg -replace '\|','/')
12685        }}
12686    }} else {{
12687        "NONE"
12688    }}
12689}} catch {{
12690    "ERROR:$($_.Exception.Message)"
12691}}
12692"#
12693        );
12694
12695        let raw = ps_exec(&ps);
12696        let lines: Vec<&str> = raw.lines().collect();
12697
12698        // Build query description for header
12699        let mut query_desc = format!("last {hours}h");
12700        if let Some(id) = event_id {
12701            query_desc.push_str(&format!(", Event ID {id}"));
12702        }
12703        if let Some(src) = source {
12704            query_desc.push_str(&format!(", source '{src}'"));
12705        }
12706        if log != "*" {
12707            query_desc.push_str(&format!(", log '{log}'"));
12708        }
12709        if let Some(l) = level {
12710            query_desc.push_str(&format!(", level '{l}'"));
12711        }
12712
12713        let mut out = format!("=== Event query: {query_desc} ===\n");
12714
12715        if lines
12716            .iter()
12717            .any(|l| l.trim() == "NONE" || l.trim().is_empty())
12718        {
12719            out.push_str("- No matching events found.\n");
12720        } else if let Some(err_line) = lines.iter().find(|l| l.starts_with("ERROR:")) {
12721            let msg = err_line.trim_start_matches("ERROR:").trim();
12722            if is_event_query_no_results_message(msg) {
12723                out.push_str("- No matching events found.\n");
12724            } else {
12725                out.push_str(&format!("- Query error: {msg}\n"));
12726                findings.push(format!("Event query failed: {msg}"));
12727            }
12728        } else {
12729            let event_lines: Vec<&str> = lines
12730                .iter()
12731                .filter(|l| l.starts_with("TIME:"))
12732                .copied()
12733                .collect();
12734            if event_lines.is_empty() {
12735                out.push_str("- No matching events found.\n");
12736            } else {
12737                // Tally by level for findings
12738                let mut error_count = 0usize;
12739                let mut warning_count = 0usize;
12740
12741                for line in &event_lines {
12742                    let kv: std::collections::HashMap<&str, &str> = line
12743                        .split('|')
12744                        .filter_map(|p| {
12745                            let mut it = p.splitn(2, ':');
12746                            Some((it.next()?, it.next()?))
12747                        })
12748                        .collect();
12749                    let time = kv.get("TIME").copied().unwrap_or("?");
12750                    let id = kv.get("ID").copied().unwrap_or("?");
12751                    let lvl = kv.get("LEVEL").copied().unwrap_or("?");
12752                    let src = kv.get("SOURCE").copied().unwrap_or("?");
12753                    let msg = kv.get("MSG").copied().unwrap_or("").trim();
12754
12755                    // Truncate long messages
12756                    let msg_display = if msg.len() > 120 {
12757                        format!("{}…", &msg[..120])
12758                    } else {
12759                        msg.to_string()
12760                    };
12761
12762                    out.push_str(&format!(
12763                        "- [{time}] ID {id} | {lvl} | {src}\n  {msg_display}\n"
12764                    ));
12765
12766                    if lvl.eq_ignore_ascii_case("error") || lvl.eq_ignore_ascii_case("critical") {
12767                        error_count += 1;
12768                    } else if lvl.eq_ignore_ascii_case("warning") {
12769                        warning_count += 1;
12770                    }
12771                }
12772
12773                out.push_str(&format!(
12774                    "\n- Total shown: {} event(s)\n",
12775                    event_lines.len()
12776                ));
12777
12778                if error_count > 0 {
12779                    findings.push(format!(
12780                        "{error_count} Error/Critical event(s) found in the {query_desc} window — review the entries above for root cause."
12781                    ));
12782                }
12783                if warning_count > 5 {
12784                    findings.push(format!(
12785                        "{warning_count} Warning events found — elevated warning volume may indicate a recurring issue."
12786                    ));
12787                }
12788            }
12789        }
12790
12791        let mut result = String::from("Host inspection: event_query\n\n=== Findings ===\n");
12792        if findings.is_empty() {
12793            result.push_str("- No actionable findings from this event query.\n");
12794        } else {
12795            for f in &findings {
12796                result.push_str(&format!("- Finding: {f}\n"));
12797            }
12798        }
12799        result.push('\n');
12800        result.push_str(&out);
12801        return Ok(result.trim_end().to_string());
12802    }
12803
12804    #[cfg(not(target_os = "windows"))]
12805    {
12806        let _ = (event_id, log_name, source, hours, level, max_entries);
12807        Ok("Host inspection: event_query\n\n=== Findings ===\n- Event log query is Windows-only.\n".into())
12808    }
12809}
12810
12811// ── app_crashes ───────────────────────────────────────────────────────────────
12812
12813fn inspect_app_crashes(process_filter: Option<&str>, max_entries: usize) -> Result<String, String> {
12814    let n = max_entries.clamp(5, 50);
12815    #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
12816    let mut findings: Vec<String> = Vec::new();
12817    #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
12818    let mut sections = String::new();
12819
12820    #[cfg(target_os = "windows")]
12821    {
12822        let proc_filter_ps = match process_filter {
12823            Some(proc) => format!(
12824                "| Where-Object {{ $_.Message -match [regex]::Escape('{}') }}",
12825                proc.replace('\'', "''")
12826            ),
12827            None => String::new(),
12828        };
12829
12830        let ps = format!(
12831            r#"
12832$results = @()
12833try {{
12834    $events = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue {proc_filter_ps}
12835    if ($events) {{
12836        foreach ($e in $events) {{
12837            $msg  = $e.Message
12838            $app  = if ($msg -match 'Faulting application name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ 'Unknown' }}
12839            $ver  = if ($msg -match 'Faulting application version: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12840            $mod  = if ($msg -match 'Faulting module name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12841            $exc  = if ($msg -match 'Exception code: (0x[0-9a-fA-F]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12842            $type = if ($e.Id -eq 1002) {{ 'HANG' }} else {{ 'CRASH' }}
12843            $results += "$($e.TimeCreated.ToString('yyyy-MM-dd HH:mm'))|$type|$app|$ver|$mod|$exc"
12844        }}
12845        $results
12846    }} else {{ 'NONE' }}
12847}} catch {{ 'ERROR:' + $_.Exception.Message }}
12848"#
12849        );
12850
12851        let raw = ps_exec(&ps);
12852        let text = raw.trim();
12853
12854        // WER archive count (non-blocking best-effort)
12855        let wer_ps = r#"
12856$wer = "$env:LOCALAPPDATA\Microsoft\Windows\WER"
12857$count = 0
12858if (Test-Path $wer) {
12859    $count = (Get-ChildItem -Path $wer -Recurse -Filter '*.wer' -ErrorAction SilentlyContinue).Count
12860}
12861$count
12862"#;
12863        let wer_count: usize = ps_exec(wer_ps).trim().parse().unwrap_or(0);
12864
12865        if text == "NONE" {
12866            sections.push_str("=== Application crashes ===\n- No application crashes or hangs in recent event log.\n");
12867        } else if text.starts_with("ERROR:") {
12868            let msg = text.trim_start_matches("ERROR:").trim();
12869            sections.push_str(&format!(
12870                "=== Application crashes ===\n- Unable to query Application event log: {msg}\n"
12871            ));
12872        } else {
12873            let events: Vec<&str> = text.lines().filter(|l| l.contains('|')).collect();
12874            let crash_count = events
12875                .iter()
12876                .filter(|l| l.splitn(3, '|').nth(1) == Some("CRASH"))
12877                .count();
12878            let hang_count = events
12879                .iter()
12880                .filter(|l| l.splitn(3, '|').nth(1) == Some("HANG"))
12881                .count();
12882
12883            // Tally crashes per app
12884            let mut app_counts: std::collections::HashMap<String, usize> =
12885                std::collections::HashMap::new();
12886            for line in &events {
12887                let parts: Vec<&str> = line.splitn(6, '|').collect();
12888                if parts.len() >= 3 {
12889                    *app_counts.entry(parts[2].to_string()).or_insert(0) += 1;
12890                }
12891            }
12892
12893            if crash_count > 0 {
12894                findings.push(format!(
12895                    "{crash_count} application crash event(s) — review below for faulting app and exception code."
12896                ));
12897            }
12898            if hang_count > 0 {
12899                findings.push(format!(
12900                    "{hang_count} application hang event(s) — process stopped responding."
12901                ));
12902            }
12903            if let Some((top_app, &count)) = app_counts.iter().max_by_key(|(_, c)| *c) {
12904                if count > 1 {
12905                    findings.push(format!(
12906                        "Most-crashed application: {top_app} ({count} events) — may indicate corrupted install or incompatible module."
12907                    ));
12908                }
12909            }
12910            if wer_count > 10 {
12911                findings.push(format!(
12912                    "{wer_count} WER reports archived — elevated crash history on this machine."
12913                ));
12914            }
12915
12916            let filter_note = match process_filter {
12917                Some(p) => format!(" (filtered: {p})"),
12918                None => String::new(),
12919            };
12920            sections.push_str(&format!(
12921                "=== Application crashes and hangs{filter_note} ===\n"
12922            ));
12923
12924            for line in &events {
12925                let parts: Vec<&str> = line.splitn(6, '|').collect();
12926                if parts.len() >= 6 {
12927                    let time = parts[0];
12928                    let kind = parts[1];
12929                    let app = parts[2];
12930                    let ver = parts[3];
12931                    let module = parts[4];
12932                    let exc = parts[5];
12933                    let ver_note = if !ver.is_empty() {
12934                        format!(" v{ver}")
12935                    } else {
12936                        String::new()
12937                    };
12938                    sections.push_str(&format!("  [{time}] {kind}: {app}{ver_note}\n"));
12939                    if !module.is_empty() && module != "?" {
12940                        let exc_note = if !exc.is_empty() {
12941                            format!(" (exc {exc})")
12942                        } else {
12943                            String::new()
12944                        };
12945                        sections.push_str(&format!("    faulting module: {module}{exc_note}\n"));
12946                    } else if !exc.is_empty() {
12947                        sections.push_str(&format!("    exception: {exc}\n"));
12948                    }
12949                }
12950            }
12951            sections.push_str(&format!(
12952                "\n  Total: {crash_count} crash(es), {hang_count} hang(s)\n"
12953            ));
12954
12955            if wer_count > 0 {
12956                sections.push_str(&format!(
12957                    "\n=== Windows Error Reporting ===\n  WER archive: {wer_count} report(s) in %LOCALAPPDATA%\\Microsoft\\Windows\\WER\n"
12958                ));
12959            }
12960        }
12961    }
12962
12963    #[cfg(not(target_os = "windows"))]
12964    {
12965        let _ = (process_filter, n);
12966        sections.push_str("=== Application crashes ===\n- Windows-only (uses Application Event Log, Event IDs 1000/1002).\n");
12967    }
12968
12969    let mut result = String::from("Host inspection: app_crashes\n\n=== Findings ===\n");
12970    if findings.is_empty() {
12971        result.push_str("- No actionable findings.\n");
12972    } else {
12973        for f in &findings {
12974            result.push_str(&format!("- Finding: {f}\n"));
12975        }
12976    }
12977    result.push('\n');
12978    result.push_str(&sections);
12979    Ok(result.trim_end().to_string())
12980}
12981
12982#[cfg(target_os = "windows")]
12983fn gpu_voltage_telemetry_note() -> String {
12984    let output = Command::new("nvidia-smi")
12985        .args(["--help-query-gpu"])
12986        .output();
12987
12988    match output {
12989        Ok(o) => {
12990            let text = String::from_utf8_lossy(&o.stdout).to_ascii_lowercase();
12991            if text.contains("\"voltage\"") || text.contains("voltage.") {
12992                "Driver query surface advertises GPU voltage fields, but Hematite is not yet decoding them on this path.".to_string()
12993            } else {
12994                "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()
12995            }
12996        }
12997        Err(_) => "Unavailable: `nvidia-smi` is not present, so Hematite cannot verify whether this driver path exposes GPU voltage rails.".to_string(),
12998    }
12999}
13000
13001#[cfg(target_os = "windows")]
13002fn decode_wmi_processor_voltage(raw: u64) -> Option<String> {
13003    if raw == 0 {
13004        return None;
13005    }
13006    if raw & 0x80 != 0 {
13007        let tenths = raw & 0x7f;
13008        return Some(format!(
13009            "{:.1} V (firmware-reported WMI current voltage)",
13010            tenths as f64 / 10.0
13011        ));
13012    }
13013
13014    let legacy = match raw {
13015        1 => Some("5.0 V"),
13016        2 => Some("3.3 V"),
13017        4 => Some("2.9 V"),
13018        _ => None,
13019    }?;
13020    Some(format!(
13021        "{} (legacy WMI voltage capability flag, not live telemetry)",
13022        legacy
13023    ))
13024}
13025
13026async fn inspect_overclocker() -> Result<String, String> {
13027    let mut out = String::from("Host inspection: overclocker\n\n");
13028
13029    #[cfg(target_os = "windows")]
13030    {
13031        out.push_str(
13032            "Gathering real-time silicon telemetry (2-second high-fidelity average)...\n\n",
13033        );
13034
13035        // 1. NVIDIA Census
13036        let nvidia = Command::new("nvidia-smi")
13037            .args([
13038                "--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",
13039                "--format=csv,noheader,nounits",
13040            ])
13041            .output();
13042
13043        if let Ok(o) = nvidia {
13044            let stdout = String::from_utf8_lossy(&o.stdout);
13045            if !stdout.trim().is_empty() {
13046                out.push_str("=== GPU SENSE (NVIDIA) ===\n");
13047                let parts: Vec<&str> = stdout.trim().split(',').map(|s| s.trim()).collect();
13048                if parts.len() >= 10 {
13049                    out.push_str(&format!("- Model:      {}\n", parts[0]));
13050                    out.push_str(&format!("- Graphics:   {} MHz\n", parts[1]));
13051                    out.push_str(&format!("- Memory:     {} MHz\n", parts[2]));
13052                    out.push_str(&format!("- Fan Speed:  {}%\n", parts[3]));
13053                    out.push_str(&format!("- Power Draw: {} W\n", parts[4]));
13054                    if !parts[6].eq_ignore_ascii_case("[N/A]") {
13055                        out.push_str(&format!("- Power Avg:  {} W\n", parts[6]));
13056                    }
13057                    if !parts[7].eq_ignore_ascii_case("[N/A]") {
13058                        out.push_str(&format!("- Power Inst: {} W\n", parts[7]));
13059                    }
13060                    if !parts[8].eq_ignore_ascii_case("[N/A]") {
13061                        out.push_str(&format!("- Power Cap:  {} W requested\n", parts[8]));
13062                    }
13063                    if !parts[9].eq_ignore_ascii_case("[N/A]") {
13064                        out.push_str(&format!("- Power Enf:  {} W enforced\n", parts[9]));
13065                    }
13066                    out.push_str(&format!("- Temperature: {}°C\n", parts[5]));
13067
13068                    if parts.len() > 10 {
13069                        let throttle_hex = parts[10];
13070                        let reasons = decode_nvidia_throttle_reasons(throttle_hex);
13071                        if !reasons.is_empty() {
13072                            out.push_str(&format!("- Throttling:  YES [Reason: {}]\n", reasons));
13073                        } else {
13074                            out.push_str("- Throttling:  None (Performance State: Max)\n");
13075                        }
13076                    }
13077                }
13078                out.push_str("\n");
13079            }
13080        }
13081
13082        out.push_str("=== VOLTAGE TELEMETRY ===\n");
13083        out.push_str(&format!(
13084            "- GPU Voltage:  {}\n\n",
13085            gpu_voltage_telemetry_note()
13086        ));
13087
13088        // 1b. Session Trends (RAM-only historians)
13089        let gpu_state = &crate::ui::gpu_monitor::GLOBAL_GPU_STATE;
13090        let history = gpu_state.history.lock().unwrap();
13091        if history.len() >= 2 {
13092            out.push_str("=== SILICON TRENDS (Session) ===\n");
13093            let first = history.front().unwrap();
13094            let last = history.back().unwrap();
13095
13096            let temp_diff = last.temperature as i32 - first.temperature as i32;
13097            let clock_diff = last.core_clock as i32 - first.core_clock as i32;
13098
13099            let temp_trend = if temp_diff > 1 {
13100                "Rising"
13101            } else if temp_diff < -1 {
13102                "Falling"
13103            } else {
13104                "Stable"
13105            };
13106            let clock_trend = if clock_diff > 10 {
13107                "Increasing"
13108            } else if clock_diff < -10 {
13109                "Decreasing"
13110            } else {
13111                "Stable"
13112            };
13113
13114            out.push_str(&format!(
13115                "- Temperature: {} ({}°C anomaly)\n",
13116                temp_trend, temp_diff
13117            ));
13118            out.push_str(&format!(
13119                "- Core Clock:  {} ({} MHz delta)\n",
13120                clock_trend, clock_diff
13121            ));
13122            out.push_str("\n");
13123        }
13124
13125        // 2. CPU Time-Series (2 samples)
13126        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))\" }";
13127        let cpu_stats = Command::new("powershell")
13128            .args(["-NoProfile", "-Command", ps_cmd])
13129            .output();
13130
13131        if let Ok(o) = cpu_stats {
13132            let stdout = String::from_utf8_lossy(&o.stdout);
13133            if !stdout.trim().is_empty() {
13134                out.push_str("=== SILICON CORE (CPU) ===\n");
13135                for line in stdout.lines() {
13136                    if let Some((path, val)) = line.split_once(':') {
13137                        let path_lower = path.to_lowercase();
13138                        if path_lower.contains("processor frequency") {
13139                            out.push_str(&format!("- Current Freq:  {} MHz (2s Avg)\n", val));
13140                        } else if path_lower.contains("% of maximum frequency") {
13141                            out.push_str(&format!("- Throttling:     {}% of Max Capacity\n", val));
13142                            let throttle_num = val.parse::<f64>().unwrap_or(100.0);
13143                            if throttle_num < 95.0 {
13144                                out.push_str(
13145                                    "  [WARNING] Active downclocking or power-saving detected.\n",
13146                                );
13147                            }
13148                        }
13149                    }
13150                }
13151            }
13152        }
13153
13154        // 2b. CPU Thermal Fallback
13155        let thermal = Command::new("powershell")
13156            .args([
13157                "-NoProfile",
13158                "-Command",
13159                "Get-CimInstance -Namespace root\\wmi -ClassName MSAcpi_ThermalZoneTemperature | Select-Object @{N='Temp';E={($_.CurrentTemperature - 2732) / 10}} | ConvertTo-Json",
13160            ])
13161            .output();
13162        if let Ok(o) = thermal {
13163            let stdout = String::from_utf8_lossy(&o.stdout);
13164            if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
13165                let temp = if v.is_array() {
13166                    v[0].get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
13167                } else {
13168                    v.get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
13169                };
13170                if temp > 1.0 {
13171                    out.push_str(&format!("- CPU Package:   {}°C (ACPI Zone)\n", temp));
13172                }
13173            }
13174        }
13175
13176        // 3. WMI Static Fallback/Context
13177        let wmi = Command::new("powershell")
13178            .args([
13179                "-NoProfile",
13180                "-Command",
13181                "Get-CimInstance Win32_Processor | Select-Object Name, MaxClockSpeed, CurrentVoltage | ConvertTo-Json",
13182            ])
13183            .output();
13184
13185        if let Ok(o) = wmi {
13186            let stdout = String::from_utf8_lossy(&o.stdout);
13187            if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
13188                out.push_str("\n=== HARDWARE DNA ===\n");
13189                out.push_str(&format!(
13190                    "- Rated Max:     {} MHz\n",
13191                    v.get("MaxClockSpeed").and_then(|x| x.as_u64()).unwrap_or(0)
13192                ));
13193                match v.get("CurrentVoltage").and_then(|x| x.as_u64()) {
13194                    Some(raw) => {
13195                        if let Some(decoded) = decode_wmi_processor_voltage(raw) {
13196                            out.push_str(&format!("- CPU Voltage:   {}\n", decoded));
13197                        } else {
13198                            out.push_str(
13199                                "- CPU Voltage:   Unavailable or non-telemetry WMI value on this firmware path\n",
13200                            );
13201                        }
13202                    }
13203                    None => out.push_str("- CPU Voltage:   Unavailable on this WMI path\n"),
13204                }
13205            }
13206        }
13207    }
13208
13209    #[cfg(not(target_os = "windows"))]
13210    {
13211        out.push_str("Overclocker telemetry is currently optimized for Windows performance counters and NVIDIA drivers.\n");
13212    }
13213
13214    Ok(out.trim_end().to_string())
13215}
13216
13217/// Decodes the NVIDIA Clocks Throttle Reasons HEX bitmask.
13218#[cfg(target_os = "windows")]
13219fn decode_nvidia_throttle_reasons(hex: &str) -> String {
13220    let hex = hex.trim().trim_start_matches("0x");
13221    let val = match u64::from_str_radix(hex, 16) {
13222        Ok(v) => v,
13223        Err(_) => return String::new(),
13224    };
13225
13226    if val == 0 {
13227        return String::new();
13228    }
13229
13230    let mut reasons = Vec::new();
13231    if val & 0x01 != 0 {
13232        reasons.push("GPU Idle");
13233    }
13234    if val & 0x02 != 0 {
13235        reasons.push("Applications Clocks Setting");
13236    }
13237    if val & 0x04 != 0 {
13238        reasons.push("SW Power Cap (PL1/PL2)");
13239    }
13240    if val & 0x08 != 0 {
13241        reasons.push("HW Slowdown (Thermal/Power)");
13242    }
13243    if val & 0x10 != 0 {
13244        reasons.push("Sync Boost");
13245    }
13246    if val & 0x20 != 0 {
13247        reasons.push("SW Thermal Slowdown");
13248    }
13249    if val & 0x40 != 0 {
13250        reasons.push("HW Thermal Slowdown");
13251    }
13252    if val & 0x80 != 0 {
13253        reasons.push("HW Power Brake Slowdown");
13254    }
13255    if val & 0x100 != 0 {
13256        reasons.push("Display Clock Setting");
13257    }
13258
13259    reasons.join(", ")
13260}
13261
13262// ── PowerShell helper (used by camera / sign_in / search_index) ───────────────
13263
13264#[cfg(windows)]
13265fn run_powershell(script: &str) -> Result<String, String> {
13266    use std::process::Command;
13267    let out = Command::new("powershell")
13268        .args(["-NoProfile", "-NonInteractive", "-Command", script])
13269        .output()
13270        .map_err(|e| format!("powershell launch failed: {e}"))?;
13271    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
13272}
13273
13274// ── inspect_camera ────────────────────────────────────────────────────────────
13275
13276#[cfg(windows)]
13277fn inspect_camera(max_entries: usize) -> Result<String, String> {
13278    let mut out = String::from("=== Camera devices ===\n");
13279
13280    // PnP camera devices
13281    let ps_devices = r#"
13282Get-PnpDevice -Class Camera -ErrorAction SilentlyContinue | Select-Object -First 20 |
13283ForEach-Object {
13284    $status = if ($_.Status -eq 'OK') { 'OK' } else { $_.Status }
13285    "$($_.FriendlyName) | Status: $status | InstanceId: $($_.InstanceId)"
13286}
13287"#;
13288    match run_powershell(ps_devices) {
13289        Ok(o) if !o.trim().is_empty() => {
13290            for line in o.lines().take(max_entries) {
13291                let l = line.trim();
13292                if !l.is_empty() {
13293                    out.push_str(&format!("- {l}\n"));
13294                }
13295            }
13296        }
13297        _ => out.push_str("- No camera devices found via PnP\n"),
13298    }
13299
13300    // Windows privacy / capability gate
13301    out.push_str("\n=== Windows camera privacy ===\n");
13302    let ps_privacy = r#"
13303$camKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\webcam'
13304$global = (Get-ItemProperty -Path $camKey -Name Value -ErrorAction SilentlyContinue).Value
13305"Global: $global"
13306$apps = Get-ChildItem $camKey -ErrorAction SilentlyContinue |
13307    Where-Object { $_.PSChildName -ne 'NonPackaged' } |
13308    ForEach-Object {
13309        $v = (Get-ItemProperty $_.PSPath -Name Value -ErrorAction SilentlyContinue).Value
13310        if ($v) { "  $($_.PSChildName): $v" }
13311    }
13312$apps
13313"#;
13314    match run_powershell(ps_privacy) {
13315        Ok(o) if !o.trim().is_empty() => {
13316            for line in o.lines().take(max_entries) {
13317                let l = line.trim_end();
13318                if !l.is_empty() {
13319                    out.push_str(&format!("{l}\n"));
13320                }
13321            }
13322        }
13323        _ => out.push_str("- Could not read camera privacy registry\n"),
13324    }
13325
13326    // Windows Hello camera (IR / face auth)
13327    out.push_str("\n=== Biometric / Hello camera ===\n");
13328    let ps_bio = r#"
13329Get-PnpDevice -Class Biometric -ErrorAction SilentlyContinue | Select-Object -First 10 |
13330ForEach-Object { "$($_.FriendlyName) | Status: $($_.Status)" }
13331"#;
13332    match run_powershell(ps_bio) {
13333        Ok(o) if !o.trim().is_empty() => {
13334            for line in o.lines().take(max_entries) {
13335                let l = line.trim();
13336                if !l.is_empty() {
13337                    out.push_str(&format!("- {l}\n"));
13338                }
13339            }
13340        }
13341        _ => out.push_str("- No biometric devices found\n"),
13342    }
13343
13344    // Findings
13345    let mut findings: Vec<String> = Vec::new();
13346    if out.contains("Status: Error") || out.contains("Status: Unknown") {
13347        findings.push("One or more camera devices report a non-OK status — check Device Manager for driver errors.".into());
13348    }
13349    if out.contains("Global: Deny") {
13350        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());
13351    }
13352
13353    let mut result = String::from("Host inspection: camera\n\n=== Findings ===\n");
13354    if findings.is_empty() {
13355        result.push_str("- No obvious camera or privacy gate issue detected.\n");
13356        result.push_str("  If an app still can't see the camera, check its individual permission in Settings > Privacy > Camera.\n");
13357    } else {
13358        for f in &findings {
13359            result.push_str(&format!("- Finding: {f}\n"));
13360        }
13361    }
13362    result.push('\n');
13363    result.push_str(&out);
13364    Ok(result)
13365}
13366
13367#[cfg(not(windows))]
13368fn inspect_camera(_max_entries: usize) -> Result<String, String> {
13369    Ok("Host inspection: camera\nCamera inspection is Windows-only.".into())
13370}
13371
13372// ── inspect_sign_in ───────────────────────────────────────────────────────────
13373
13374#[cfg(windows)]
13375fn inspect_sign_in(max_entries: usize) -> Result<String, String> {
13376    let mut out = String::from("=== Windows Hello and sign-in state ===\n");
13377
13378    // Windows Hello PIN and face/fingerprint readiness
13379    let ps_hello = r#"
13380$helloKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI'
13381$pinConfigured = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Provisioning\FingerPrint' -ErrorAction SilentlyContinue
13382$faceConfigured = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\WbioSrvc' -Name Start -ErrorAction SilentlyContinue).Start
13383"PIN-style logon path: $helloKey"
13384"WbioSrvc start type: $faceConfigured"
13385"FingerPrint key present: $pinConfigured"
13386"#;
13387    match run_powershell(ps_hello) {
13388        Ok(o) => {
13389            for line in o.lines().take(max_entries) {
13390                let l = line.trim();
13391                if !l.is_empty() {
13392                    out.push_str(&format!("- {l}\n"));
13393                }
13394            }
13395        }
13396        Err(e) => out.push_str(&format!("- Hello query error: {e}\n")),
13397    }
13398
13399    // Biometric service state
13400    out.push_str("\n=== Biometric service ===\n");
13401    let ps_bio_svc = r#"
13402$svc = Get-Service WbioSrvc -ErrorAction SilentlyContinue
13403if ($svc) { "WbioSrvc | Status: $($svc.Status) | StartType: $($svc.StartType)" }
13404else { "WbioSrvc not found" }
13405"#;
13406    match run_powershell(ps_bio_svc) {
13407        Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
13408        Err(_) => out.push_str("- Could not query biometric service\n"),
13409    }
13410
13411    // Recent logon failure events
13412    out.push_str("\n=== Recent sign-in failures (last 24h) ===\n");
13413    let ps_events = r#"
13414$cutoff = (Get-Date).AddHours(-24)
13415Get-WinEvent -LogName Security -FilterXPath "*[System[EventID=4625 and TimeCreated[timediff(@SystemTime) <= 86400000]]]" -MaxEvents 10 -ErrorAction SilentlyContinue |
13416ForEach-Object {
13417    $xml = [xml]$_.ToXml()
13418    $reason = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'FailureReason' }).'#text'
13419    $account = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text'
13420    "$($_.TimeCreated.ToString('HH:mm')) | Account: $account | Reason: $reason"
13421} | Select-Object -First 10
13422"#;
13423    match run_powershell(ps_events) {
13424        Ok(o) if !o.trim().is_empty() => {
13425            let count = o.lines().filter(|l| !l.trim().is_empty()).count();
13426            out.push_str(&format!("- {count} recent logon failure(s) detected:\n"));
13427            for line in o.lines().take(max_entries) {
13428                let l = line.trim();
13429                if !l.is_empty() {
13430                    out.push_str(&format!("  {l}\n"));
13431                }
13432            }
13433        }
13434        _ => out.push_str("- No sign-in failures in the last 24h (or insufficient privileges to read Security log)\n"),
13435    }
13436
13437    // Credential providers
13438    out.push_str("\n=== Active credential providers ===\n");
13439    let ps_cp = r#"
13440Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers' -ErrorAction SilentlyContinue |
13441ForEach-Object {
13442    $name = (Get-ItemProperty $_.PSPath -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
13443    if ($name) { $name }
13444} | Select-Object -First 15
13445"#;
13446    match run_powershell(ps_cp) {
13447        Ok(o) if !o.trim().is_empty() => {
13448            for line in o.lines().take(max_entries) {
13449                let l = line.trim();
13450                if !l.is_empty() {
13451                    out.push_str(&format!("- {l}\n"));
13452                }
13453            }
13454        }
13455        _ => out.push_str("- Could not enumerate credential providers\n"),
13456    }
13457
13458    let mut findings: Vec<String> = Vec::new();
13459    if out.contains("WbioSrvc | Status: Stopped") {
13460        findings.push("Windows Biometric Service is stopped — Windows Hello face/fingerprint will not work until it is running.".into());
13461    }
13462    if out.contains("recent logon failure") && !out.contains("0 recent") {
13463        findings.push("Recent sign-in failures detected — check the Security event log for account lockout or credential issues.".into());
13464    }
13465
13466    let mut result = String::from("Host inspection: sign_in\n\n=== Findings ===\n");
13467    if findings.is_empty() {
13468        result.push_str("- No obvious sign-in or Windows Hello service failure detected.\n");
13469        result.push_str("  If Hello is prompting for PIN or won't recognize you, try Settings > Accounts > Sign-in options > Repair.\n");
13470    } else {
13471        for f in &findings {
13472            result.push_str(&format!("- Finding: {f}\n"));
13473        }
13474    }
13475    result.push('\n');
13476    result.push_str(&out);
13477    Ok(result)
13478}
13479
13480#[cfg(not(windows))]
13481fn inspect_sign_in(_max_entries: usize) -> Result<String, String> {
13482    Ok("Host inspection: sign_in\nSign-in / Windows Hello inspection is Windows-only.".into())
13483}
13484
13485// ── inspect_installer_health ──────────────────────────────────────────────────
13486
13487#[cfg(windows)]
13488fn inspect_installer_health(max_entries: usize) -> Result<String, String> {
13489    let mut out = String::from("=== Installer engines ===\n");
13490
13491    let ps_engines = r#"
13492$services = 'msiserver','AppXSvc','ClipSVC','InstallService'
13493foreach ($name in $services) {
13494    $svc = Get-Service -Name $name -ErrorAction SilentlyContinue
13495    if ($svc) {
13496        $cim = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
13497        $startType = if ($cim) { $cim.StartMode } else { 'Unknown' }
13498        "$name | Status: $($svc.Status) | StartType: $startType"
13499    } else {
13500        "$name | Not present"
13501    }
13502}
13503if (Test-Path "$env:WINDIR\System32\msiexec.exe") {
13504    "msiexec.exe | Present: Yes"
13505} else {
13506    "msiexec.exe | Present: No"
13507}
13508"#;
13509    match run_powershell(ps_engines) {
13510        Ok(o) if !o.trim().is_empty() => {
13511            for line in o.lines().take(max_entries + 6) {
13512                let l = line.trim();
13513                if !l.is_empty() {
13514                    out.push_str(&format!("- {l}\n"));
13515                }
13516            }
13517        }
13518        _ => out.push_str("- Could not inspect installer engine services\n"),
13519    }
13520
13521    out.push_str("\n=== winget and App Installer ===\n");
13522    let ps_winget = r#"
13523$cmd = Get-Command winget -ErrorAction SilentlyContinue
13524if ($cmd) {
13525    try {
13526        $v = & winget --version 2>$null
13527        if ($LASTEXITCODE -eq 0 -and $v) { "winget | Version: $v" } else { "winget | Present but version query failed" }
13528    } catch { "winget | Present but invocation failed" }
13529} else {
13530    "winget | Missing"
13531}
13532$appInstaller = Get-AppxPackage Microsoft.DesktopAppInstaller -ErrorAction SilentlyContinue
13533if ($appInstaller) {
13534    "DesktopAppInstaller | Version: $($appInstaller.Version) | Status: Present"
13535} else {
13536    "DesktopAppInstaller | Status: Missing"
13537}
13538"#;
13539    match run_powershell(ps_winget) {
13540        Ok(o) if !o.trim().is_empty() => {
13541            for line in o.lines().take(max_entries) {
13542                let l = line.trim();
13543                if !l.is_empty() {
13544                    out.push_str(&format!("- {l}\n"));
13545                }
13546            }
13547        }
13548        _ => out.push_str("- Could not inspect winget/App Installer state\n"),
13549    }
13550
13551    out.push_str("\n=== Microsoft Store packages ===\n");
13552    let ps_store = r#"
13553$store = Get-AppxPackage Microsoft.WindowsStore -ErrorAction SilentlyContinue
13554if ($store) {
13555    "Microsoft.WindowsStore | Version: $($store.Version) | Status: Present"
13556} else {
13557    "Microsoft.WindowsStore | Status: Missing"
13558}
13559"#;
13560    match run_powershell(ps_store) {
13561        Ok(o) if !o.trim().is_empty() => {
13562            for line in o.lines().take(max_entries) {
13563                let l = line.trim();
13564                if !l.is_empty() {
13565                    out.push_str(&format!("- {l}\n"));
13566                }
13567            }
13568        }
13569        _ => out.push_str("- Could not inspect Microsoft Store package state\n"),
13570    }
13571
13572    out.push_str("\n=== Reboot and transaction blockers ===\n");
13573    let ps_blockers = r#"
13574$pending = $false
13575if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
13576    "RebootPending: CBS"
13577    $pending = $true
13578}
13579if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
13580    "RebootPending: WindowsUpdate"
13581    $pending = $true
13582}
13583$rename = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations
13584if ($rename) {
13585    "PendingFileRenameOperations: Yes"
13586    $pending = $true
13587}
13588if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\InProgress') {
13589    "InstallerInProgress: Yes"
13590    $pending = $true
13591}
13592if (-not $pending) { "No pending reboot or installer-in-progress flag detected" }
13593"#;
13594    match run_powershell(ps_blockers) {
13595        Ok(o) if !o.trim().is_empty() => {
13596            for line in o.lines().take(max_entries) {
13597                let l = line.trim();
13598                if !l.is_empty() {
13599                    out.push_str(&format!("- {l}\n"));
13600                }
13601            }
13602        }
13603        _ => out.push_str("- Could not inspect reboot or transaction blockers\n"),
13604    }
13605
13606    out.push_str("\n=== Recent installer failures (7d) ===\n");
13607    let ps_failures = r#"
13608$cutoff = (Get-Date).AddDays(-7)
13609$msi = Get-WinEvent -FilterHashtable @{ LogName='Application'; ProviderName='MsiInstaller'; StartTime=$cutoff; Level=2 } -MaxEvents 6 -ErrorAction SilentlyContinue |
13610    ForEach-Object { "MSI | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
13611$appx = Get-WinEvent -LogName 'Microsoft-Windows-AppXDeploymentServer/Operational' -ErrorAction SilentlyContinue -MaxEvents 30 |
13612    Where-Object { $_.LevelDisplayName -eq 'Error' -and $_.TimeCreated -ge $cutoff } |
13613    Select-Object -First 6 |
13614    ForEach-Object { "AppX | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
13615$all = @($msi) + @($appx)
13616if ($all.Count -eq 0) {
13617    "No recent MSI/AppX installer errors detected"
13618} else {
13619    $all | Select-Object -First 8
13620}
13621"#;
13622    match run_powershell(ps_failures) {
13623        Ok(o) if !o.trim().is_empty() => {
13624            for line in o.lines().take(max_entries + 2) {
13625                let l = line.trim();
13626                if !l.is_empty() {
13627                    out.push_str(&format!("- {l}\n"));
13628                }
13629            }
13630        }
13631        _ => out.push_str("- Could not inspect recent installer failure events\n"),
13632    }
13633
13634    let mut findings: Vec<String> = Vec::new();
13635    if out.contains("msiserver | Status: Stopped | StartType: Disabled") {
13636        findings.push("Windows Installer service (msiserver) is disabled - MSI installs cannot start until it is re-enabled.".into());
13637    }
13638    if out.contains("msiexec.exe | Present: No") {
13639        findings.push("msiexec.exe is missing from System32 - MSI installs will fail until Windows Installer is repaired.".into());
13640    }
13641    if out.contains("winget | Missing") {
13642        findings.push(
13643            "winget is missing - App Installer may not be installed or registered for this user."
13644                .into(),
13645        );
13646    }
13647    if out.contains("DesktopAppInstaller | Status: Missing") {
13648        findings.push("Microsoft Desktop App Installer is missing - winget and some app-installer flows will be unavailable.".into());
13649    }
13650    if out.contains("Microsoft.WindowsStore | Status: Missing") {
13651        findings.push(
13652            "Microsoft Store package is missing - Store-sourced installs and repairs may not work."
13653                .into(),
13654        );
13655    }
13656    if out.contains("RebootPending:") || out.contains("PendingFileRenameOperations: Yes") {
13657        findings.push("A pending reboot is present - installer transactions may stay blocked until the machine restarts.".into());
13658    }
13659    if out.contains("InstallerInProgress: Yes") {
13660        findings.push("Windows reports an installer transaction already in progress - concurrent installs may fail until it clears.".into());
13661    }
13662    if out.contains("MSI | ") || out.contains("AppX | ") {
13663        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());
13664    }
13665
13666    let mut result = String::from("Host inspection: installer_health\n\n=== Findings ===\n");
13667    if findings.is_empty() {
13668        result.push_str("- No obvious installer-platform blocker detected.\n");
13669    } else {
13670        for finding in &findings {
13671            result.push_str(&format!("- Finding: {finding}\n"));
13672        }
13673    }
13674    result.push('\n');
13675    result.push_str(&out);
13676    Ok(result)
13677}
13678
13679#[cfg(not(windows))]
13680fn inspect_installer_health(_max_entries: usize) -> Result<String, String> {
13681    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())
13682}
13683
13684// ── inspect_search_index ──────────────────────────────────────────────────────
13685
13686#[cfg(windows)]
13687fn inspect_onedrive(max_entries: usize) -> Result<String, String> {
13688    let mut out = String::from("=== OneDrive client ===\n");
13689
13690    let ps_client = r#"
13691$candidatePaths = @(
13692    (Join-Path $env:LOCALAPPDATA 'Microsoft\OneDrive\OneDrive.exe'),
13693    (Join-Path $env:ProgramFiles 'Microsoft OneDrive\OneDrive.exe'),
13694    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft OneDrive\OneDrive.exe')
13695) | Where-Object { $_ -and (Test-Path $_) }
13696$proc = Get-Process OneDrive -ErrorAction SilentlyContinue | Select-Object -First 1
13697$exe = $candidatePaths | Select-Object -First 1
13698if (-not $exe -and $proc) {
13699    try { $exe = $proc.Path } catch {}
13700}
13701if ($exe) {
13702    "Installed: Yes"
13703    "Executable: $exe"
13704    try { "Version: $((Get-Item $exe).VersionInfo.FileVersion)" } catch {}
13705} else {
13706    "Installed: Unknown"
13707}
13708if ($proc) {
13709    "Process: Running | PID: $($proc.Id)"
13710} else {
13711    "Process: Not running"
13712}
13713"#;
13714    match run_powershell(ps_client) {
13715        Ok(o) if !o.trim().is_empty() => {
13716            for line in o.lines().take(max_entries) {
13717                let l = line.trim();
13718                if !l.is_empty() {
13719                    out.push_str(&format!("- {l}\n"));
13720                }
13721            }
13722        }
13723        _ => out.push_str("- Could not inspect OneDrive client state\n"),
13724    }
13725
13726    out.push_str("\n=== OneDrive accounts ===\n");
13727    let ps_accounts = r#"
13728function MaskEmail([string]$Email) {
13729    if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
13730    $parts = $Email.Split('@', 2)
13731    $local = $parts[0]
13732    $domain = $parts[1]
13733    if ($local.Length -le 1) { return "*@$domain" }
13734    return ($local.Substring(0,1) + "***@" + $domain)
13735}
13736$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
13737if (Test-Path $base) {
13738    Get-ChildItem $base -ErrorAction SilentlyContinue |
13739        Sort-Object PSChildName |
13740        Select-Object -First 12 |
13741        ForEach-Object {
13742            $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13743            $kind = if ($_.PSChildName -eq 'Personal') { 'Personal' } else { 'Business' }
13744            $mail = MaskEmail ([string]$p.UserEmail)
13745            $root = if ([string]::IsNullOrWhiteSpace([string]$p.UserFolder)) { 'Unknown' } else { [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder) }
13746            $exists = if ($root -eq 'Unknown') { 'Unknown' } elseif (Test-Path $root) { 'Yes' } else { 'No' }
13747            "$kind | Email: $mail | SyncRoot: $root | Exists: $exists"
13748        }
13749} else {
13750    "No OneDrive accounts configured"
13751}
13752"#;
13753    match run_powershell(ps_accounts) {
13754        Ok(o) if !o.trim().is_empty() => {
13755            for line in o.lines().take(max_entries) {
13756                let l = line.trim();
13757                if !l.is_empty() {
13758                    out.push_str(&format!("- {l}\n"));
13759                }
13760            }
13761        }
13762        _ => out.push_str("- Could not read OneDrive account registry state\n"),
13763    }
13764
13765    out.push_str("\n=== OneDrive policy overrides ===\n");
13766    let ps_policy = r#"
13767$paths = @(
13768    'HKLM:\SOFTWARE\Policies\Microsoft\OneDrive',
13769    'HKCU:\SOFTWARE\Policies\Microsoft\OneDrive'
13770)
13771$names = @(
13772    'DisableFileSyncNGSC',
13773    'DisableLibrariesDefaultSaveToOneDrive',
13774    'KFMSilentOptIn',
13775    'KFMBlockOptIn',
13776    'SilentAccountConfig'
13777)
13778$found = $false
13779foreach ($path in $paths) {
13780    if (Test-Path $path) {
13781        $p = Get-ItemProperty $path -ErrorAction SilentlyContinue
13782        foreach ($name in $names) {
13783            $value = $p.$name
13784            if ($null -ne $value -and [string]$value -ne '') {
13785                "$path | $name=$value"
13786                $found = $true
13787            }
13788        }
13789    }
13790}
13791if (-not $found) { "No OneDrive policy overrides detected" }
13792"#;
13793    match run_powershell(ps_policy) {
13794        Ok(o) if !o.trim().is_empty() => {
13795            for line in o.lines().take(max_entries) {
13796                let l = line.trim();
13797                if !l.is_empty() {
13798                    out.push_str(&format!("- {l}\n"));
13799                }
13800            }
13801        }
13802        _ => out.push_str("- Could not read OneDrive policy state\n"),
13803    }
13804
13805    out.push_str("\n=== Known Folder Backup ===\n");
13806    let ps_kfm = r#"
13807$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
13808$roots = @()
13809if (Test-Path $base) {
13810    Get-ChildItem $base -ErrorAction SilentlyContinue | ForEach-Object {
13811        $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13812        if ($p.UserFolder) {
13813            $roots += [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder)
13814        }
13815    }
13816}
13817$roots = $roots | Select-Object -Unique
13818$shell = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders'
13819if (Test-Path $shell) {
13820    $props = Get-ItemProperty $shell -ErrorAction SilentlyContinue
13821    $folders = @(
13822        @{ Name='Desktop'; Value=$props.Desktop },
13823        @{ Name='Documents'; Value=$props.Personal },
13824        @{ Name='Pictures'; Value=$props.'My Pictures' }
13825    )
13826    foreach ($folder in $folders) {
13827        $path = [Environment]::ExpandEnvironmentVariables([string]$folder.Value)
13828        if ([string]::IsNullOrWhiteSpace($path)) { $path = 'Unknown' }
13829        $protected = $false
13830        foreach ($root in $roots) {
13831            if (-not [string]::IsNullOrWhiteSpace([string]$root) -and $path.ToLower().StartsWith($root.ToLower())) {
13832                $protected = $true
13833                break
13834            }
13835        }
13836        "$($folder.Name) | Path: $path | In OneDrive: $(if ($protected) { 'Yes' } else { 'No' })"
13837    }
13838} else {
13839    "Explorer shell folders unavailable"
13840}
13841"#;
13842    match run_powershell(ps_kfm) {
13843        Ok(o) if !o.trim().is_empty() => {
13844            for line in o.lines().take(max_entries) {
13845                let l = line.trim();
13846                if !l.is_empty() {
13847                    out.push_str(&format!("- {l}\n"));
13848                }
13849            }
13850        }
13851        _ => out.push_str("- Could not inspect Known Folder Backup state\n"),
13852    }
13853
13854    let mut findings: Vec<String> = Vec::new();
13855    if out.contains("Installed: Unknown") && !out.contains("Process: Running") {
13856        findings.push("OneDrive client installation could not be confirmed from standard paths in this session.".into());
13857    }
13858    if out.contains("No OneDrive accounts configured") {
13859        findings.push(
13860            "No OneDrive accounts are configured - sync cannot start until the user signs in."
13861                .into(),
13862        );
13863    }
13864    if out.contains("Process: Not running") && !out.contains("No OneDrive accounts configured") {
13865        findings.push("OneDrive accounts exist but the sync client is not running - sync may be paused until OneDrive starts.".into());
13866    }
13867    if out.contains("Exists: No") {
13868        findings.push("One or more configured OneDrive sync roots do not exist on disk - account linkage or folder redirection may be broken.".into());
13869    }
13870    if out.contains("DisableFileSyncNGSC=1") {
13871        findings
13872            .push("A OneDrive policy is disabling the sync client (DisableFileSyncNGSC=1).".into());
13873    }
13874    if out.contains("KFMBlockOptIn=1") {
13875        findings
13876            .push("A policy is blocking Known Folder Backup enrollment (KFMBlockOptIn=1).".into());
13877    }
13878    if out.contains("SyncRoot: C:\\") {
13879        let mut missing_kfm: Vec<&str> = Vec::new();
13880        for folder in ["Desktop", "Documents", "Pictures"] {
13881            if out.lines().any(|line| {
13882                line.contains(&format!("{folder} | Path:")) && line.contains("| In OneDrive: No")
13883            }) {
13884                missing_kfm.push(folder);
13885            }
13886        }
13887        if !missing_kfm.is_empty() {
13888            findings.push(format!(
13889                "Known Folder Backup is not protecting {} - those folders are outside the OneDrive sync root.",
13890                missing_kfm.join(", ")
13891            ));
13892        }
13893    }
13894
13895    let mut result = String::from("Host inspection: onedrive\n\n=== Findings ===\n");
13896    if findings.is_empty() {
13897        result.push_str("- No obvious OneDrive client, account, or policy blocker detected.\n");
13898    } else {
13899        for finding in &findings {
13900            result.push_str(&format!("- Finding: {finding}\n"));
13901        }
13902    }
13903    result.push('\n');
13904    result.push_str(&out);
13905    Ok(result)
13906}
13907
13908#[cfg(not(windows))]
13909fn inspect_onedrive(_max_entries: usize) -> Result<String, String> {
13910    Ok("Host inspection: onedrive\n\n=== Findings ===\n- OneDrive inspection is currently Windows-first. macOS/Linux support can be added later.\n".into())
13911}
13912
13913#[cfg(windows)]
13914fn inspect_browser_health(max_entries: usize) -> Result<String, String> {
13915    let mut out = String::from("=== Browser inventory ===\n");
13916
13917    let ps_inventory = r#"
13918$browsers = @(
13919    @{ Name='Edge'; Paths=@(
13920        (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'),
13921        (Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe')
13922    ); Profile=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data') },
13923    @{ Name='Chrome'; Paths=@(
13924        (Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe'),
13925        (Join-Path ${env:ProgramFiles(x86)} 'Google\Chrome\Application\chrome.exe'),
13926        (Join-Path $env:LOCALAPPDATA 'Google\Chrome\Application\chrome.exe')
13927    ); Profile=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data') },
13928    @{ Name='Firefox'; Paths=@(
13929        (Join-Path $env:ProgramFiles 'Mozilla Firefox\firefox.exe'),
13930        (Join-Path ${env:ProgramFiles(x86)} 'Mozilla Firefox\firefox.exe')
13931    ); Profile=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles') }
13932)
13933foreach ($browser in $browsers) {
13934    $exe = $browser.Paths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
13935    if ($exe) {
13936        $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
13937        $profileExists = if (Test-Path $browser.Profile) { 'Yes' } else { 'No' }
13938        "$($browser.Name) | Installed: Yes | Version: $version | Executable: $exe | ProfileRoot: $($browser.Profile) | ProfileExists: $profileExists"
13939    } else {
13940        "$($browser.Name) | Installed: No"
13941    }
13942}
13943$httpProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
13944$httpsProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
13945$startMenuInternet = (Get-ItemProperty 'HKLM:\SOFTWARE\Clients\StartMenuInternet' -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
13946"DefaultHTTP: $(if ($httpProgId) { $httpProgId } else { 'Unknown' })"
13947"DefaultHTTPS: $(if ($httpsProgId) { $httpsProgId } else { 'Unknown' })"
13948"StartMenuInternet: $(if ($startMenuInternet) { $startMenuInternet } else { 'Unknown' })"
13949"#;
13950    match run_powershell(ps_inventory) {
13951        Ok(o) if !o.trim().is_empty() => {
13952            for line in o.lines().take(max_entries + 6) {
13953                let l = line.trim();
13954                if !l.is_empty() {
13955                    out.push_str(&format!("- {l}\n"));
13956                }
13957            }
13958        }
13959        _ => out.push_str("- Could not inspect installed browser inventory\n"),
13960    }
13961
13962    out.push_str("\n=== Runtime state ===\n");
13963    let ps_runtime = r#"
13964$targets = 'msedge','chrome','firefox','msedgewebview2'
13965foreach ($name in $targets) {
13966    $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
13967    if ($procs) {
13968        $count = @($procs).Count
13969        $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
13970        "$name | Processes: $count | WorkingSetMB: $wsMb"
13971    } else {
13972        "$name | Processes: 0 | WorkingSetMB: 0"
13973    }
13974}
13975"#;
13976    match run_powershell(ps_runtime) {
13977        Ok(o) if !o.trim().is_empty() => {
13978            for line in o.lines().take(max_entries + 4) {
13979                let l = line.trim();
13980                if !l.is_empty() {
13981                    out.push_str(&format!("- {l}\n"));
13982                }
13983            }
13984        }
13985        _ => out.push_str("- Could not inspect browser runtime state\n"),
13986    }
13987
13988    out.push_str("\n=== WebView2 runtime ===\n");
13989    let ps_webview = r#"
13990$paths = @(
13991    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
13992    (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
13993) | Where-Object { $_ -and (Test-Path $_) }
13994$runtimeDir = $paths | ForEach-Object {
13995    Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
13996        Where-Object { $_.Name -match '^\d+\.' } |
13997        Sort-Object Name -Descending |
13998        Select-Object -First 1
13999} | Select-Object -First 1
14000if ($runtimeDir) {
14001    $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14002    $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14003    "Installed: Yes"
14004    "Version: $version"
14005    "Executable: $exe"
14006} else {
14007    "Installed: No"
14008}
14009$proc = Get-Process msedgewebview2 -ErrorAction SilentlyContinue
14010"ProcessCount: $(if ($proc) { @($proc).Count } else { 0 })"
14011"#;
14012    match run_powershell(ps_webview) {
14013        Ok(o) if !o.trim().is_empty() => {
14014            for line in o.lines().take(max_entries) {
14015                let l = line.trim();
14016                if !l.is_empty() {
14017                    out.push_str(&format!("- {l}\n"));
14018                }
14019            }
14020        }
14021        _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14022    }
14023
14024    out.push_str("\n=== Policy and proxy surface ===\n");
14025    let ps_policy = r#"
14026$proxy = Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
14027$proxyEnabled = if ($null -ne $proxy.ProxyEnable) { $proxy.ProxyEnable } else { 'Unknown' }
14028$proxyServer = if ($proxy.ProxyServer) { $proxy.ProxyServer } else { 'Direct' }
14029$autoConfig = if ($proxy.AutoConfigURL) { $proxy.AutoConfigURL } else { 'None' }
14030$autoDetect = if ($null -ne $proxy.AutoDetect) { $proxy.AutoDetect } else { 'Unknown' }
14031"UserProxyEnabled: $proxyEnabled"
14032"UserProxyServer: $proxyServer"
14033"UserAutoConfigURL: $autoConfig"
14034"UserAutoDetect: $autoDetect"
14035$winhttp = (netsh winhttp show proxy 2>$null) -join ' '
14036if ($winhttp) {
14037    $normalized = ($winhttp -replace '\s+', ' ').Trim()
14038    $isDirect = $normalized -match 'Direct access \(no proxy server\)\.?$'
14039    "WinHTTPMode: $(if ($isDirect) { 'Direct' } else { 'Proxy' })"
14040    "WinHTTP: $normalized"
14041}
14042$policyTargets = @(
14043    @{ Name='Edge'; Path='HKLM:\SOFTWARE\Policies\Microsoft\Edge'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') },
14044    @{ Name='Chrome'; Path='HKLM:\SOFTWARE\Policies\Google\Chrome'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') }
14045)
14046foreach ($policy in $policyTargets) {
14047    if (Test-Path $policy.Path) {
14048        $item = Get-ItemProperty $policy.Path -ErrorAction SilentlyContinue
14049        foreach ($key in $policy.Keys) {
14050            $value = $item.$key
14051            if ($null -ne $value -and [string]$value -ne '') {
14052                if ($value -is [array]) {
14053                    "$($policy.Name)Policy | $key=$([string]::Join('; ', $value))"
14054                } else {
14055                    "$($policy.Name)Policy | $key=$value"
14056                }
14057            }
14058        }
14059    }
14060}
14061"#;
14062    match run_powershell(ps_policy) {
14063        Ok(o) if !o.trim().is_empty() => {
14064            for line in o.lines().take(max_entries + 8) {
14065                let l = line.trim();
14066                if !l.is_empty() {
14067                    out.push_str(&format!("- {l}\n"));
14068                }
14069            }
14070        }
14071        _ => out.push_str("- Could not inspect browser policy or proxy state\n"),
14072    }
14073
14074    out.push_str("\n=== Profile and cache pressure ===\n");
14075    let ps_profiles = r#"
14076$profiles = @(
14077    @{ Name='Edge'; Root=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data\Default\Extensions') },
14078    @{ Name='Chrome'; Root=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data\Default\Extensions') },
14079    @{ Name='Firefox'; Root=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles'); ExtensionRoot=$null }
14080)
14081foreach ($profile in $profiles) {
14082    if (Test-Path $profile.Root) {
14083        if ($profile.Name -eq 'Firefox') {
14084            $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue
14085        } else {
14086            $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue |
14087                Where-Object {
14088                    $_.Name -eq 'Default' -or
14089                    $_.Name -eq 'Guest Profile' -or
14090                    $_.Name -eq 'System Profile' -or
14091                    $_.Name -like 'Profile *'
14092                }
14093        }
14094        $profileCount = @($dirs).Count
14095        $sizeBytes = (Get-ChildItem $profile.Root -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
14096        if (-not $sizeBytes) { $sizeBytes = 0 }
14097        $sizeGb = [Math]::Round(($sizeBytes / 1GB), 2)
14098        $extCount = 'Unknown'
14099        if ($profile.ExtensionRoot -and (Test-Path $profile.ExtensionRoot)) {
14100            $extCount = @((Get-ChildItem $profile.ExtensionRoot -Directory -ErrorAction SilentlyContinue)).Count
14101        }
14102        "$($profile.Name) | ProfileRoot: $($profile.Root) | Profiles: $profileCount | SizeGB: $sizeGb | Extensions: $extCount"
14103    } else {
14104        "$($profile.Name) | ProfileRoot: Missing"
14105    }
14106}
14107"#;
14108    match run_powershell(ps_profiles) {
14109        Ok(o) if !o.trim().is_empty() => {
14110            for line in o.lines().take(max_entries + 4) {
14111                let l = line.trim();
14112                if !l.is_empty() {
14113                    out.push_str(&format!("- {l}\n"));
14114                }
14115            }
14116        }
14117        _ => out.push_str("- Could not inspect browser profile pressure\n"),
14118    }
14119
14120    out.push_str("\n=== Recent browser failures (7d) ===\n");
14121    let ps_failures = r#"
14122$cutoff = (Get-Date).AddDays(-7)
14123$targets = 'chrome.exe','msedge.exe','firefox.exe','msedgewebview2.exe'
14124$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 250 -ErrorAction SilentlyContinue |
14125    Where-Object {
14126        $msg = [string]$_.Message
14127        ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
14128        ($targets | Where-Object { $msg.ToLower().Contains($_.ToLower()) })
14129    } |
14130    Select-Object -First 6
14131if ($events) {
14132    foreach ($event in $events) {
14133        $msg = ($event.Message -replace '\s+', ' ')
14134        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14135        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14136    }
14137} else {
14138    "No recent browser crash or WER events detected"
14139}
14140"#;
14141    match run_powershell(ps_failures) {
14142        Ok(o) if !o.trim().is_empty() => {
14143            for line in o.lines().take(max_entries + 2) {
14144                let l = line.trim();
14145                if !l.is_empty() {
14146                    out.push_str(&format!("- {l}\n"));
14147                }
14148            }
14149        }
14150        _ => out.push_str("- Could not inspect recent browser failure events\n"),
14151    }
14152
14153    let mut findings: Vec<String> = Vec::new();
14154    if out.contains("Edge | Installed: No")
14155        && out.contains("Chrome | Installed: No")
14156        && out.contains("Firefox | Installed: No")
14157    {
14158        findings.push(
14159            "No supported browser install was detected from the standard Edge/Chrome/Firefox paths."
14160                .into(),
14161        );
14162    }
14163    if out.contains("DefaultHTTP: Unknown") || out.contains("DefaultHTTPS: Unknown") {
14164        findings.push(
14165            "Default browser or protocol associations could not be read cleanly - links may open inconsistently."
14166                .into(),
14167        );
14168    }
14169    if out.contains("UserProxyEnabled: 1") || out.contains("WinHTTPMode: Proxy") {
14170        findings.push(
14171            "Proxy settings are active for this user or machine - browser sign-in and web-app failures may be proxy or PAC related."
14172                .into(),
14173        );
14174    }
14175    if out.contains("EdgePolicy | Proxy")
14176        || out.contains("ChromePolicy | Proxy")
14177        || out.contains("ExtensionInstallForcelist=")
14178    {
14179        findings.push(
14180            "Browser policy overrides are present - forced proxy or extension policy may be influencing web-app behavior."
14181                .into(),
14182        );
14183    }
14184    for browser in ["msedge", "chrome", "firefox"] {
14185        let process_marker = format!("{browser} | Processes: ");
14186        if let Some(line) = out.lines().find(|line| line.contains(&process_marker)) {
14187            let count = line
14188                .split("| Processes: ")
14189                .nth(1)
14190                .and_then(|rest| rest.split(" |").next())
14191                .and_then(|value| value.trim().parse::<usize>().ok())
14192                .unwrap_or(0);
14193            let ws_mb = line
14194                .split("| WorkingSetMB: ")
14195                .nth(1)
14196                .and_then(|value| value.trim().parse::<f64>().ok())
14197                .unwrap_or(0.0);
14198            if count >= 25 {
14199                findings.push(format!(
14200                    "{browser} is running {count} processes - extension or tab pressure may be dragging browser responsiveness."
14201                ));
14202            } else if ws_mb >= 2500.0 {
14203                findings.push(format!(
14204                    "{browser} is consuming {ws_mb:.1} MB of working set - browser memory pressure may be driving slowness or tab crashes."
14205                ));
14206            }
14207        }
14208    }
14209    if out.contains("=== WebView2 runtime ===\n- Installed: No")
14210        || (out.contains("=== WebView2 runtime ===")
14211            && out.contains("- Installed: No")
14212            && out.contains("- ProcessCount: 0"))
14213    {
14214        findings.push(
14215            "WebView2 runtime is missing - modern Windows apps that embed Edge web content may fail or render badly."
14216                .into(),
14217        );
14218    }
14219    for browser in ["Edge", "Chrome", "Firefox"] {
14220        let prefix = format!("{browser} | ProfileRoot:");
14221        if let Some(line) = out.lines().find(|line| line.contains(&prefix)) {
14222            let size_gb = line
14223                .split("| SizeGB: ")
14224                .nth(1)
14225                .and_then(|rest| rest.split(" |").next())
14226                .and_then(|value| value.trim().parse::<f64>().ok())
14227                .unwrap_or(0.0);
14228            let ext_count = line
14229                .split("| Extensions: ")
14230                .nth(1)
14231                .and_then(|value| value.trim().parse::<usize>().ok())
14232                .unwrap_or(0);
14233            if size_gb >= 2.5 {
14234                findings.push(format!(
14235                    "{browser} profile data is {size_gb:.2} GB - cache or profile bloat may be hurting startup and web-app responsiveness."
14236                ));
14237            }
14238            if ext_count >= 20 {
14239                findings.push(format!(
14240                    "{browser} has {ext_count} extensions in the default profile - extension overload can slow page loads and trigger conflicts."
14241                ));
14242            }
14243        }
14244    }
14245    if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14246        findings.push(
14247            "Recent browser crash evidence was found in the Application log - review the failure lines below for the browser or helper process that is faulting."
14248                .into(),
14249        );
14250    }
14251
14252    let mut result = String::from("Host inspection: browser_health\n\n=== Findings ===\n");
14253    if findings.is_empty() {
14254        result.push_str("- No obvious browser, proxy, or WebView2 health blocker detected.\n");
14255    } else {
14256        for finding in &findings {
14257            result.push_str(&format!("- Finding: {finding}\n"));
14258        }
14259    }
14260    result.push('\n');
14261    result.push_str(&out);
14262    Ok(result)
14263}
14264
14265#[cfg(not(windows))]
14266fn inspect_browser_health(_max_entries: usize) -> Result<String, String> {
14267    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())
14268}
14269
14270#[cfg(windows)]
14271fn inspect_outlook(max_entries: usize) -> Result<String, String> {
14272    let mut out = String::from("=== Outlook install inventory ===\n");
14273
14274    let ps_install = r#"
14275$installPaths = @(
14276    (Join-Path $env:ProgramFiles 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
14277    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
14278    (Join-Path $env:ProgramFiles 'Microsoft Office\Office16\OUTLOOK.EXE'),
14279    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office16\OUTLOOK.EXE'),
14280    (Join-Path $env:ProgramFiles 'Microsoft Office\Office15\OUTLOOK.EXE'),
14281    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office15\OUTLOOK.EXE')
14282)
14283$exe = $installPaths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14284if ($exe) {
14285    $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
14286    $productName = try { (Get-Item $exe).VersionInfo.ProductName } catch { 'Unknown' }
14287    "Installed: Yes"
14288    "Executable: $exe"
14289    "Version: $version"
14290    "Product: $productName"
14291} else {
14292    "Installed: No"
14293}
14294$newOutlook = Get-AppxPackage -Name 'Microsoft.OutlookForWindows' -ErrorAction SilentlyContinue
14295if ($newOutlook) {
14296    "NewOutlook: Installed | Version: $($newOutlook.Version)"
14297} else {
14298    "NewOutlook: Not installed"
14299}
14300"#;
14301    match run_powershell(ps_install) {
14302        Ok(o) if !o.trim().is_empty() => {
14303            for line in o.lines().take(max_entries + 4) {
14304                let l = line.trim();
14305                if !l.is_empty() {
14306                    out.push_str(&format!("- {l}\n"));
14307                }
14308            }
14309        }
14310        _ => out.push_str("- Could not inspect Outlook install paths\n"),
14311    }
14312
14313    out.push_str("\n=== Runtime state ===\n");
14314    let ps_runtime = r#"
14315$proc = Get-Process OUTLOOK -ErrorAction SilentlyContinue
14316if ($proc) {
14317    $count = @($proc).Count
14318    $wsMb = [Math]::Round((($proc | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14319    $cpuPct = try { [Math]::Round(($proc | Measure-Object CPU -Sum).Sum, 1) } catch { 0 }
14320    "Running: Yes | ProcessCount: $count | WorkingSetMB: $wsMb | CPUSeconds: $cpuPct"
14321} else {
14322    "Running: No"
14323}
14324"#;
14325    match run_powershell(ps_runtime) {
14326        Ok(o) if !o.trim().is_empty() => {
14327            for line in o.lines().take(4) {
14328                let l = line.trim();
14329                if !l.is_empty() {
14330                    out.push_str(&format!("- {l}\n"));
14331                }
14332            }
14333        }
14334        _ => out.push_str("- Could not inspect Outlook runtime state\n"),
14335    }
14336
14337    out.push_str("\n=== Mail profiles ===\n");
14338    let ps_profiles = r#"
14339$profileKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Profiles'
14340if (-not (Test-Path $profileKey)) {
14341    $profileKey = 'HKCU:\Software\Microsoft\Office\15.0\Outlook\Profiles'
14342}
14343if (Test-Path $profileKey) {
14344    $profiles = Get-ChildItem $profileKey -ErrorAction SilentlyContinue
14345    $count = @($profiles).Count
14346    "ProfileCount: $count"
14347    foreach ($p in $profiles | Select-Object -First 10) {
14348        "Profile: $($p.PSChildName)"
14349    }
14350} else {
14351    "ProfileCount: 0"
14352    "No Outlook profiles found in registry"
14353}
14354"#;
14355    match run_powershell(ps_profiles) {
14356        Ok(o) if !o.trim().is_empty() => {
14357            for line in o.lines().take(max_entries + 2) {
14358                let l = line.trim();
14359                if !l.is_empty() {
14360                    out.push_str(&format!("- {l}\n"));
14361                }
14362            }
14363        }
14364        _ => out.push_str("- Could not inspect Outlook mail profiles\n"),
14365    }
14366
14367    out.push_str("\n=== OST and PST data files ===\n");
14368    let ps_datafiles = r#"
14369$searchRoots = @(
14370    (Join-Path $env:LOCALAPPDATA 'Microsoft\Outlook'),
14371    (Join-Path $env:USERPROFILE 'Documents'),
14372    (Join-Path $env:USERPROFILE 'OneDrive\Documents')
14373) | Where-Object { $_ -and (Test-Path $_) }
14374$files = foreach ($root in $searchRoots) {
14375    Get-ChildItem $root -Include '*.ost','*.pst' -Recurse -ErrorAction SilentlyContinue -Force |
14376        Select-Object FullName,
14377            @{N='SizeMB';E={[Math]::Round($_.Length/1MB,1)}},
14378            @{N='Type';E={$_.Extension.TrimStart('.').ToUpper()}},
14379            LastWriteTime
14380}
14381if ($files) {
14382    foreach ($f in ($files | Sort-Object SizeMB -Descending | Select-Object -First 12)) {
14383        "$($f.Type) | $($f.FullName) | SizeMB: $($f.SizeMB) | LastWrite: $($f.LastWriteTime.ToString('yyyy-MM-dd'))"
14384    }
14385} else {
14386    "No OST or PST files found in standard locations"
14387}
14388"#;
14389    match run_powershell(ps_datafiles) {
14390        Ok(o) if !o.trim().is_empty() => {
14391            for line in o.lines().take(max_entries + 4) {
14392                let l = line.trim();
14393                if !l.is_empty() {
14394                    out.push_str(&format!("- {l}\n"));
14395                }
14396            }
14397        }
14398        _ => out.push_str("- Could not inspect OST/PST data files\n"),
14399    }
14400
14401    out.push_str("\n=== Add-in pressure ===\n");
14402    let ps_addins = r#"
14403$addinPaths = @(
14404    'HKLM:\SOFTWARE\Microsoft\Office\Outlook\Addins',
14405    'HKCU:\SOFTWARE\Microsoft\Office\Outlook\Addins',
14406    'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Office\Outlook\Addins'
14407)
14408$addins = foreach ($path in $addinPaths) {
14409    if (Test-Path $path) {
14410        Get-ChildItem $path -ErrorAction SilentlyContinue | ForEach-Object {
14411            $item = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
14412            $loadBehavior = $item.LoadBehavior
14413            $desc = if ($item.Description) { $item.Description } else { $_.PSChildName }
14414            [PSCustomObject]@{ Name=$desc; LoadBehavior=$loadBehavior; Key=$_.PSChildName }
14415        }
14416    }
14417}
14418$enabledCount = ($addins | Where-Object { $_.LoadBehavior -band 1 }).Count
14419$disabledCount = ($addins | Where-Object { $_.LoadBehavior -eq 0 }).Count
14420"TotalAddins: $(@($addins).Count) | Active: $enabledCount | Disabled: $disabledCount"
14421foreach ($a in ($addins | Sort-Object LoadBehavior -Descending | Select-Object -First 15)) {
14422    $state = switch ($a.LoadBehavior) {
14423        0 { 'Disabled' }
14424        2 { 'LoadOnStart(inactive)' }
14425        3 { 'ActiveOnStart' }
14426        8 { 'DemandLoad' }
14427        9 { 'ActiveDemand' }
14428        16 { 'ConnectedFirst' }
14429        default { "LoadBehavior=$($a.LoadBehavior)" }
14430    }
14431    "$($a.Name) | $state"
14432}
14433$crashedKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DoNotDisableAddinList'
14434$disabledByResiliency = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DisabledItems'
14435if (Test-Path $disabledByResiliency) {
14436    $dis = Get-ItemProperty $disabledByResiliency -ErrorAction SilentlyContinue
14437    $count = ($dis.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' }).Count
14438    if ($count -gt 0) { "ResiliencyDisabledItems: $count (add-ins crashed and were auto-disabled)" }
14439}
14440"#;
14441    match run_powershell(ps_addins) {
14442        Ok(o) if !o.trim().is_empty() => {
14443            for line in o.lines().take(max_entries + 8) {
14444                let l = line.trim();
14445                if !l.is_empty() {
14446                    out.push_str(&format!("- {l}\n"));
14447                }
14448            }
14449        }
14450        _ => out.push_str("- Could not inspect Outlook add-ins\n"),
14451    }
14452
14453    out.push_str("\n=== Authentication and cache friction ===\n");
14454    let ps_auth = r#"
14455$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14456$tokenCount = if (Test-Path $tokenCache) {
14457    @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
14458} else { 0 }
14459"TokenBrokerCacheFiles: $tokenCount"
14460$credentialManager = cmdkey /list 2>&1 | Select-String 'MicrosoftOffice|ADALCache|microsoftoffice|MsoOpenIdConnect'
14461$credsCount = @($credentialManager).Count
14462"OfficeCredentialsInVault: $credsCount"
14463$samlKey = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14464if (Test-Path $samlKey) {
14465    $id = Get-ItemProperty $samlKey -ErrorAction SilentlyContinue
14466    $connected = if ($id.ConnectedAccountWamOverride) { $id.ConnectedAccountWamOverride } else { 'Unknown' }
14467    $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
14468    "WAMOverride: $connected"
14469    "SignedInUserId: $signedIn"
14470}
14471$outlookReg = 'HKCU:\Software\Microsoft\Office\16.0\Outlook'
14472if (Test-Path $outlookReg) {
14473    $olk = Get-ItemProperty $outlookReg -ErrorAction SilentlyContinue
14474    if ($olk.DisableMAPI) { "DisableMAPI: $($olk.DisableMAPI)" }
14475}
14476"#;
14477    match run_powershell(ps_auth) {
14478        Ok(o) if !o.trim().is_empty() => {
14479            for line in o.lines().take(max_entries + 4) {
14480                let l = line.trim();
14481                if !l.is_empty() {
14482                    out.push_str(&format!("- {l}\n"));
14483                }
14484            }
14485        }
14486        _ => out.push_str("- Could not inspect Outlook auth state\n"),
14487    }
14488
14489    out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
14490    let ps_events = r#"
14491$cutoff = (Get-Date).AddDays(-7)
14492$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14493    Where-Object {
14494        $msg = [string]$_.Message
14495        ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting' -or $_.ProviderName -eq 'Outlook') -and
14496        ($msg.ToLower().Contains('outlook') -or $msg.ToLower().Contains('mso.dll') -or $msg.ToLower().Contains('outllib.dll') -or $msg.ToLower().Contains('olmapi32.dll'))
14497    } |
14498    Select-Object -First 8
14499if ($events) {
14500    foreach ($event in $events) {
14501        $msg = ($event.Message -replace '\s+', ' ')
14502        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14503        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14504    }
14505} else {
14506    "No recent Outlook crash or error events detected in Application log"
14507}
14508"#;
14509    match run_powershell(ps_events) {
14510        Ok(o) if !o.trim().is_empty() => {
14511            for line in o.lines().take(max_entries + 4) {
14512                let l = line.trim();
14513                if !l.is_empty() {
14514                    out.push_str(&format!("- {l}\n"));
14515                }
14516            }
14517        }
14518        _ => out.push_str("- Could not inspect Outlook event log evidence\n"),
14519    }
14520
14521    let mut findings: Vec<String> = Vec::new();
14522
14523    if out.contains("- Installed: No") && out.contains("- NewOutlook: Not installed") {
14524        findings.push(
14525            "Outlook is not installed — neither classic Office nor the new Outlook for Windows was found."
14526                .into(),
14527        );
14528    }
14529
14530    if let Some(line) = out.lines().find(|l| l.contains("WorkingSetMB:")) {
14531        let ws_mb = line
14532            .split("WorkingSetMB: ")
14533            .nth(1)
14534            .and_then(|r| r.split(" |").next())
14535            .and_then(|v| v.trim().parse::<f64>().ok())
14536            .unwrap_or(0.0);
14537        if ws_mb >= 1500.0 {
14538            findings.push(format!(
14539                "Outlook is consuming {ws_mb:.0} MB of RAM — add-in pressure, large OST files, or a corrupt profile may be driving memory growth."
14540            ));
14541        }
14542    }
14543
14544    let large_ost: Vec<String> = out
14545        .lines()
14546        .filter(|l| l.contains("SizeMB:") && l.contains("OST"))
14547        .filter_map(|l| {
14548            let mb = l
14549                .split("SizeMB: ")
14550                .nth(1)
14551                .and_then(|r| r.split(" |").next())
14552                .and_then(|v| v.trim().parse::<f64>().ok())
14553                .unwrap_or(0.0);
14554            if mb >= 10_000.0 {
14555                Some(format!("{mb:.0} MB OST file detected"))
14556            } else {
14557                None
14558            }
14559        })
14560        .collect();
14561    for msg in large_ost {
14562        findings.push(format!(
14563            "{msg} — large OST files can cause Outlook slowness, send/receive delays, and search index rebuild time."
14564        ));
14565    }
14566
14567    if let Some(line) = out.lines().find(|l| l.contains("TotalAddins:")) {
14568        let active_count = line
14569            .split("Active: ")
14570            .nth(1)
14571            .and_then(|r| r.split(" |").next())
14572            .and_then(|v| v.trim().parse::<usize>().ok())
14573            .unwrap_or(0);
14574        if active_count >= 8 {
14575            findings.push(format!(
14576                "{active_count} active Outlook add-ins detected — add-in overload is a common cause of slow Outlook startup, freezes, and crashes."
14577            ));
14578        }
14579    }
14580
14581    if out.contains("ResiliencyDisabledItems:") {
14582        findings.push(
14583            "Outlook's crash resiliency has auto-disabled one or more add-ins — look at the ResiliencyDisabledItems count and remove the offending add-in."
14584                .into(),
14585        );
14586    }
14587
14588    if out.contains("- ProfileCount: 0") || out.contains("- No Outlook profiles found") {
14589        findings.push(
14590            "No Outlook mail profiles were found in the registry — Outlook may not have been set up, or the profile may be corrupt."
14591                .into(),
14592        );
14593    }
14594
14595    if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14596        findings.push(
14597            "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)."
14598                .into(),
14599        );
14600    }
14601
14602    let mut result = String::from("Host inspection: outlook\n\n=== Findings ===\n");
14603    if findings.is_empty() {
14604        result.push_str("- No obvious Outlook health blocker detected.\n");
14605    } else {
14606        for finding in &findings {
14607            result.push_str(&format!("- Finding: {finding}\n"));
14608        }
14609    }
14610    result.push('\n');
14611    result.push_str(&out);
14612    Ok(result)
14613}
14614
14615#[cfg(not(windows))]
14616fn inspect_outlook(_max_entries: usize) -> Result<String, String> {
14617    Ok("Host inspection: outlook\n\n=== Findings ===\n- Outlook health inspection is Windows-only.\n".into())
14618}
14619
14620#[cfg(windows)]
14621fn inspect_teams(max_entries: usize) -> Result<String, String> {
14622    let mut out = String::from("=== Teams install inventory ===\n");
14623
14624    let ps_install = r#"
14625# Classic Teams (Teams 1.0)
14626$classicExe = @(
14627    (Join-Path $env:LOCALAPPDATA 'Microsoft\Teams\current\Teams.exe'),
14628    (Join-Path $env:ProgramFiles 'Microsoft\Teams\current\Teams.exe')
14629) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14630
14631if ($classicExe) {
14632    $ver = try { (Get-Item $classicExe).VersionInfo.FileVersion } catch { 'Unknown' }
14633    "ClassicTeams: Installed | Version: $ver | Path: $classicExe"
14634} else {
14635    "ClassicTeams: Not installed"
14636}
14637
14638# New Teams (Teams 2.0 / ms-teams.exe)
14639$newTeamsExe = @(
14640    (Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\ms-teams.exe'),
14641    (Join-Path $env:ProgramFiles 'WindowsApps\MSTeams_*\ms-teams.exe')
14642) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14643
14644$newTeamsPkg = Get-AppxPackage -Name 'MSTeams' -ErrorAction SilentlyContinue
14645if ($newTeamsPkg) {
14646    "NewTeams: Installed | Version: $($newTeamsPkg.Version) | PackageName: $($newTeamsPkg.PackageFullName)"
14647} elseif ($newTeamsExe) {
14648    $ver = try { (Get-Item $newTeamsExe).VersionInfo.FileVersion } catch { 'Unknown' }
14649    "NewTeams: Installed | Version: $ver | Path: $newTeamsExe"
14650} else {
14651    "NewTeams: Not installed"
14652}
14653
14654# Teams Machine-Wide Installer (MSI/per-machine)
14655$mwi = Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue |
14656    Where-Object { $_.DisplayName -like 'Teams Machine-Wide Installer*' } |
14657    Select-Object -First 1
14658if ($mwi) {
14659    "MachineWideInstaller: Installed | Version: $($mwi.DisplayVersion)"
14660} else {
14661    "MachineWideInstaller: Not found"
14662}
14663"#;
14664    match run_powershell(ps_install) {
14665        Ok(o) if !o.trim().is_empty() => {
14666            for line in o.lines().take(max_entries + 4) {
14667                let l = line.trim();
14668                if !l.is_empty() {
14669                    out.push_str(&format!("- {l}\n"));
14670                }
14671            }
14672        }
14673        _ => out.push_str("- Could not inspect Teams install paths\n"),
14674    }
14675
14676    out.push_str("\n=== Runtime state ===\n");
14677    let ps_runtime = r#"
14678$targets = @('Teams','ms-teams')
14679foreach ($name in $targets) {
14680    $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
14681    if ($procs) {
14682        $count = @($procs).Count
14683        $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14684        "$name | Running: Yes | Processes: $count | WorkingSetMB: $wsMb"
14685    } else {
14686        "$name | Running: No"
14687    }
14688}
14689"#;
14690    match run_powershell(ps_runtime) {
14691        Ok(o) if !o.trim().is_empty() => {
14692            for line in o.lines().take(6) {
14693                let l = line.trim();
14694                if !l.is_empty() {
14695                    out.push_str(&format!("- {l}\n"));
14696                }
14697            }
14698        }
14699        _ => out.push_str("- Could not inspect Teams runtime state\n"),
14700    }
14701
14702    out.push_str("\n=== Cache directory sizing ===\n");
14703    let ps_cache = r#"
14704$cachePaths = @(
14705    @{ Name='ClassicTeamsCache'; Path=(Join-Path $env:APPDATA 'Microsoft\Teams') },
14706    @{ Name='ClassicTeamsSquirrel'; Path=(Join-Path $env:LOCALAPPDATA 'Microsoft\Teams') },
14707    @{ Name='NewTeamsCache'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams') },
14708    @{ Name='NewTeamsAppData'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe') }
14709)
14710foreach ($entry in $cachePaths) {
14711    if (Test-Path $entry.Path) {
14712        $sizeBytes = (Get-ChildItem $entry.Path -Recurse -File -ErrorAction SilentlyContinue -Force | Measure-Object Length -Sum).Sum
14713        if (-not $sizeBytes) { $sizeBytes = 0 }
14714        $sizeMb = [Math]::Round($sizeBytes / 1MB, 1)
14715        "$($entry.Name) | Path: $($entry.Path) | SizeMB: $sizeMb"
14716    } else {
14717        "$($entry.Name) | Path: $($entry.Path) | Not found"
14718    }
14719}
14720"#;
14721    match run_powershell(ps_cache) {
14722        Ok(o) if !o.trim().is_empty() => {
14723            for line in o.lines().take(max_entries + 4) {
14724                let l = line.trim();
14725                if !l.is_empty() {
14726                    out.push_str(&format!("- {l}\n"));
14727                }
14728            }
14729        }
14730        _ => out.push_str("- Could not inspect Teams cache directories\n"),
14731    }
14732
14733    out.push_str("\n=== WebView2 runtime ===\n");
14734    let ps_webview = r#"
14735$paths = @(
14736    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
14737    (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
14738) | Where-Object { $_ -and (Test-Path $_) }
14739$runtimeDir = $paths | ForEach-Object {
14740    Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
14741        Where-Object { $_.Name -match '^\d+\.' } |
14742        Sort-Object Name -Descending |
14743        Select-Object -First 1
14744} | Select-Object -First 1
14745if ($runtimeDir) {
14746    $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14747    $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14748    "Installed: Yes | Version: $version"
14749} else {
14750    "Installed: No -- New Teams and some Office features require WebView2"
14751}
14752"#;
14753    match run_powershell(ps_webview) {
14754        Ok(o) if !o.trim().is_empty() => {
14755            for line in o.lines().take(4) {
14756                let l = line.trim();
14757                if !l.is_empty() {
14758                    out.push_str(&format!("- {l}\n"));
14759                }
14760            }
14761        }
14762        _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14763    }
14764
14765    out.push_str("\n=== Account and sign-in state ===\n");
14766    let ps_auth = r#"
14767# Classic Teams account registry
14768$classicAcct = 'HKCU:\Software\Microsoft\Office\Teams'
14769if (Test-Path $classicAcct) {
14770    $item = Get-ItemProperty $classicAcct -ErrorAction SilentlyContinue
14771    $email = if ($item.HomeUserUpn) { $item.HomeUserUpn } elseif ($item.LoggedInEmail) { $item.LoggedInEmail } else { 'Unknown' }
14772    "ClassicTeamsAccount: $email"
14773} else {
14774    "ClassicTeamsAccount: Not configured"
14775}
14776# WAM / token broker state for Teams
14777$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14778$tokenCount = if (Test-Path $tokenCache) {
14779    @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
14780} else { 0 }
14781"TokenBrokerCacheFiles: $tokenCount"
14782# Office identity
14783$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14784if (Test-Path $officeId) {
14785    $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
14786    $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
14787    "OfficeSignedInUserId: $signedIn"
14788}
14789# Check if Teams is in startup
14790$startupKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
14791$teamsRun = (Get-ItemProperty $startupKey -ErrorAction SilentlyContinue) | Select-Object -ExpandProperty 'com.squirrel.Teams.Teams' -ErrorAction SilentlyContinue
14792"TeamsInStartup: $(if ($teamsRun) { 'Yes (Classic)' } else { 'Not in user run key' })"
14793"#;
14794    match run_powershell(ps_auth) {
14795        Ok(o) if !o.trim().is_empty() => {
14796            for line in o.lines().take(max_entries + 4) {
14797                let l = line.trim();
14798                if !l.is_empty() {
14799                    out.push_str(&format!("- {l}\n"));
14800                }
14801            }
14802        }
14803        _ => out.push_str("- Could not inspect Teams account state\n"),
14804    }
14805
14806    out.push_str("\n=== Audio and video device binding ===\n");
14807    let ps_devices = r#"
14808# Teams stores device prefs in the settings file
14809$settingsPaths = @(
14810    (Join-Path $env:APPDATA 'Microsoft\Teams\desktop-config.json'),
14811    (Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams\app_settings.json')
14812)
14813$found = $false
14814foreach ($sp in $settingsPaths) {
14815    if (Test-Path $sp) {
14816        $found = $true
14817        $raw = try { Get-Content $sp -Raw -ErrorAction SilentlyContinue } catch { $null }
14818        if ($raw) {
14819            $json = try { $raw | ConvertFrom-Json -ErrorAction SilentlyContinue } catch { $null }
14820            if ($json) {
14821                $mic = if ($json.currentAudioDevice) { $json.currentAudioDevice } elseif ($json.audioDevice) { $json.audioDevice } else { 'Default' }
14822                $spk = if ($json.currentSpeakerDevice) { $json.currentSpeakerDevice } elseif ($json.speakerDevice) { $json.speakerDevice } else { 'Default' }
14823                $cam = if ($json.currentVideoDevice) { $json.currentVideoDevice } elseif ($json.videoDevice) { $json.videoDevice } else { 'Default' }
14824                "ConfigFile: $sp"
14825                "Microphone: $mic"
14826                "Speaker: $spk"
14827                "Camera: $cam"
14828            } else {
14829                "ConfigFile: $sp (not parseable as JSON)"
14830            }
14831        } else {
14832            "ConfigFile: $sp (empty)"
14833        }
14834        break
14835    }
14836}
14837if (-not $found) {
14838    "NoTeamsConfigFile: Teams device prefs not found -- Teams may not have been launched yet or uses system defaults"
14839}
14840"#;
14841    match run_powershell(ps_devices) {
14842        Ok(o) if !o.trim().is_empty() => {
14843            for line in o.lines().take(max_entries + 4) {
14844                let l = line.trim();
14845                if !l.is_empty() {
14846                    out.push_str(&format!("- {l}\n"));
14847                }
14848            }
14849        }
14850        _ => out.push_str("- Could not inspect Teams device binding\n"),
14851    }
14852
14853    out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
14854    let ps_events = r#"
14855$cutoff = (Get-Date).AddDays(-7)
14856$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14857    Where-Object {
14858        $msg = [string]$_.Message
14859        ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
14860        ($msg.ToLower().Contains('teams') -or $msg.ToLower().Contains('ms-teams') -or $msg.ToLower().Contains('msteams'))
14861    } |
14862    Select-Object -First 8
14863if ($events) {
14864    foreach ($event in $events) {
14865        $msg = ($event.Message -replace '\s+', ' ')
14866        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14867        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14868    }
14869} else {
14870    "No recent Teams crash or error events detected in Application log"
14871}
14872"#;
14873    match run_powershell(ps_events) {
14874        Ok(o) if !o.trim().is_empty() => {
14875            for line in o.lines().take(max_entries + 4) {
14876                let l = line.trim();
14877                if !l.is_empty() {
14878                    out.push_str(&format!("- {l}\n"));
14879                }
14880            }
14881        }
14882        _ => out.push_str("- Could not inspect Teams event log evidence\n"),
14883    }
14884
14885    let mut findings: Vec<String> = Vec::new();
14886
14887    let classic_installed = out.contains("- ClassicTeams: Installed");
14888    let new_installed = out.contains("- NewTeams: Installed");
14889    if !classic_installed && !new_installed {
14890        findings.push("Neither classic Teams nor new Teams is installed on this machine.".into());
14891    }
14892
14893    for name in ["Teams", "ms-teams"] {
14894        let marker = format!("{name} | Running: Yes | Processes:");
14895        if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
14896            let ws_mb = line
14897                .split("WorkingSetMB: ")
14898                .nth(1)
14899                .and_then(|v| v.trim().parse::<f64>().ok())
14900                .unwrap_or(0.0);
14901            if ws_mb >= 1000.0 {
14902                findings.push(format!(
14903                    "{name} is consuming {ws_mb:.0} MB of RAM — cache bloat or a large number of channels/meetings loaded may be driving memory growth."
14904                ));
14905            }
14906        }
14907    }
14908
14909    for (label, threshold_mb) in [
14910        ("ClassicTeamsCache", 500.0_f64),
14911        ("ClassicTeamsSquirrel", 2000.0),
14912        ("NewTeamsCache", 500.0),
14913        ("NewTeamsAppData", 3000.0),
14914    ] {
14915        let marker = format!("{label} |");
14916        if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
14917            let mb = line
14918                .split("SizeMB: ")
14919                .nth(1)
14920                .and_then(|v| v.trim().parse::<f64>().ok())
14921                .unwrap_or(0.0);
14922            if mb >= threshold_mb {
14923                findings.push(format!(
14924                    "{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."
14925                ));
14926            }
14927        }
14928    }
14929
14930    if out.contains("- Installed: No -- New Teams") {
14931        findings.push(
14932            "WebView2 runtime is missing — new Teams requires WebView2 for rendering. Install it from Microsoft's WebView2 page or via winget install Microsoft.EdgeWebView2Runtime."
14933                .into(),
14934        );
14935    }
14936
14937    if out.contains("- ClassicTeamsAccount: Not configured")
14938        && out.contains("- OfficeSignedInUserId: None")
14939    {
14940        findings.push(
14941            "No Teams account is configured and Office sign-in is absent — Teams will fail to load meetings or channels until the user signs in."
14942                .into(),
14943        );
14944    }
14945
14946    if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14947        findings.push(
14948            "Recent Teams crash evidence found in the Application event log — check the event lines below for the faulting module."
14949                .into(),
14950        );
14951    }
14952
14953    let mut result = String::from("Host inspection: teams\n\n=== Findings ===\n");
14954    if findings.is_empty() {
14955        result.push_str("- No obvious Teams health blocker detected.\n");
14956    } else {
14957        for finding in &findings {
14958            result.push_str(&format!("- Finding: {finding}\n"));
14959        }
14960    }
14961    result.push('\n');
14962    result.push_str(&out);
14963    Ok(result)
14964}
14965
14966#[cfg(not(windows))]
14967fn inspect_teams(_max_entries: usize) -> Result<String, String> {
14968    Ok(
14969        "Host inspection: teams\n\n=== Findings ===\n- Teams health inspection is Windows-only.\n"
14970            .into(),
14971    )
14972}
14973
14974#[cfg(windows)]
14975fn inspect_identity_auth(max_entries: usize) -> Result<String, String> {
14976    let mut out = String::from("=== Identity broker services ===\n");
14977
14978    let ps_services = r#"
14979$serviceNames = 'TokenBroker','wlidsvc','OneAuth'
14980foreach ($name in $serviceNames) {
14981    $svc = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
14982    if ($svc) {
14983        "$($svc.Name) | Status: $($svc.State) | StartMode: $($svc.StartMode)"
14984    } else {
14985        "$name | Not found"
14986    }
14987}
14988"#;
14989    match run_powershell(ps_services) {
14990        Ok(o) if !o.trim().is_empty() => {
14991            for line in o.lines().take(max_entries) {
14992                let l = line.trim();
14993                if !l.is_empty() {
14994                    out.push_str(&format!("- {l}\n"));
14995                }
14996            }
14997        }
14998        _ => out.push_str("- Could not inspect identity broker services\n"),
14999    }
15000
15001    out.push_str("\n=== Device registration ===\n");
15002    let ps_device = r#"
15003$dsreg = Get-Command dsregcmd.exe -ErrorAction SilentlyContinue
15004if ($dsreg) {
15005    try {
15006        $raw = & $dsreg.Source /status 2>$null
15007        $text = ($raw -join "`n")
15008        $keys = 'AzureAdJoined','WorkplaceJoined','DomainJoined','DeviceAuthStatus','TenantName','AzureAdPrt','WamDefaultSet'
15009        $seen = $false
15010        foreach ($key in $keys) {
15011            $match = [regex]::Match($text, '(?im)^\s*' + [regex]::Escape($key) + '\s*:\s*(.+)$')
15012            if ($match.Success) {
15013                "${key}: $($match.Groups[1].Value.Trim())"
15014                $seen = $true
15015            }
15016        }
15017        if (-not $seen) {
15018            "DeviceRegistration: dsregcmd returned no recognizable registration fields (common on personal or unmanaged devices)"
15019        }
15020    } catch {
15021        "DeviceRegistration: dsregcmd failed - $($_.Exception.Message)"
15022    }
15023} else {
15024    "DeviceRegistration: dsregcmd unavailable"
15025}
15026"#;
15027    match run_powershell(ps_device) {
15028        Ok(o) if !o.trim().is_empty() => {
15029            for line in o.lines().take(max_entries + 4) {
15030                let l = line.trim();
15031                if !l.is_empty() {
15032                    out.push_str(&format!("- {l}\n"));
15033                }
15034            }
15035        }
15036        _ => out.push_str(
15037            "- DeviceRegistration: Could not inspect device registration state in this session\n",
15038        ),
15039    }
15040
15041    out.push_str("\n=== Broker packages and caches ===\n");
15042    let ps_broker = r#"
15043$pkg = Get-AppxPackage -Name 'Microsoft.AAD.BrokerPlugin' -ErrorAction SilentlyContinue | Select-Object -First 1
15044if ($pkg) {
15045    "AADBrokerPlugin: Installed | Version: $($pkg.Version)"
15046} else {
15047    "AADBrokerPlugin: Not installed"
15048}
15049$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
15050$tokenCount = if (Test-Path $tokenCache) { @(Get-ChildItem $tokenCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15051"TokenBrokerCacheFiles: $tokenCount"
15052$identityCache = Join-Path $env:LOCALAPPDATA 'Microsoft\IdentityCache'
15053$identityCount = if (Test-Path $identityCache) { @(Get-ChildItem $identityCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15054"IdentityCacheFiles: $identityCount"
15055$oneAuth = Join-Path $env:LOCALAPPDATA 'Microsoft\OneAuth'
15056$oneAuthCount = if (Test-Path $oneAuth) { @(Get-ChildItem $oneAuth -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15057"OneAuthFiles: $oneAuthCount"
15058"#;
15059    match run_powershell(ps_broker) {
15060        Ok(o) if !o.trim().is_empty() => {
15061            for line in o.lines().take(max_entries + 4) {
15062                let l = line.trim();
15063                if !l.is_empty() {
15064                    out.push_str(&format!("- {l}\n"));
15065                }
15066            }
15067        }
15068        _ => out.push_str("- Could not inspect identity broker packages or caches\n"),
15069    }
15070
15071    out.push_str("\n=== Microsoft app account signals ===\n");
15072    let ps_accounts = r#"
15073function MaskEmail([string]$Email) {
15074    if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
15075    $parts = $Email.Split('@', 2)
15076    $local = $parts[0]
15077    $domain = $parts[1]
15078    if ($local.Length -le 1) { return "*@$domain" }
15079    return ($local.Substring(0,1) + "***@" + $domain)
15080}
15081$allAccounts = @()
15082$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
15083if (Test-Path $officeId) {
15084    $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
15085    if ($id.SignedInUserId) {
15086        $allAccounts += [string]$id.SignedInUserId
15087        "OfficeSignedInUserId: $(MaskEmail ([string]$id.SignedInUserId))"
15088    } else {
15089        "OfficeSignedInUserId: None"
15090    }
15091} else {
15092    "OfficeSignedInUserId: Not configured"
15093}
15094$teamsAcct = 'HKCU:\Software\Microsoft\Office\Teams'
15095if (Test-Path $teamsAcct) {
15096    $item = Get-ItemProperty $teamsAcct -ErrorAction SilentlyContinue
15097    $email = if ($item.HomeUserUpn) { [string]$item.HomeUserUpn } elseif ($item.LoggedInEmail) { [string]$item.LoggedInEmail } else { '' }
15098    if (-not [string]::IsNullOrWhiteSpace($email)) {
15099        $allAccounts += $email
15100        "TeamsAccount: $(MaskEmail $email)"
15101    } else {
15102        "TeamsAccount: Unknown"
15103    }
15104} else {
15105    "TeamsAccount: Not configured"
15106}
15107$oneDriveBase = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
15108$oneDriveEmails = @()
15109if (Test-Path $oneDriveBase) {
15110    $oneDriveEmails = Get-ChildItem $oneDriveBase -ErrorAction SilentlyContinue |
15111        ForEach-Object {
15112            $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
15113            if ($p.UserEmail) { [string]$p.UserEmail }
15114        } |
15115        Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
15116        Sort-Object -Unique
15117}
15118$allAccounts += $oneDriveEmails
15119"OneDriveAccountCount: $(@($oneDriveEmails).Count)"
15120if (@($oneDriveEmails).Count -gt 0) {
15121    "OneDriveAccounts: $((@($oneDriveEmails) | ForEach-Object { MaskEmail $_ }) -join ', ')"
15122}
15123$distinct = @($allAccounts | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
15124"DistinctIdentityCount: $($distinct.Count)"
15125if ($distinct.Count -gt 0) {
15126    "IdentitySet: $((@($distinct) | ForEach-Object { MaskEmail $_ }) -join ', ')"
15127}
15128"#;
15129    match run_powershell(ps_accounts) {
15130        Ok(o) if !o.trim().is_empty() => {
15131            for line in o.lines().take(max_entries + 6) {
15132                let l = line.trim();
15133                if !l.is_empty() {
15134                    out.push_str(&format!("- {l}\n"));
15135                }
15136            }
15137        }
15138        _ => out.push_str("- Could not inspect Microsoft app identity state\n"),
15139    }
15140
15141    out.push_str("\n=== WebView2 auth dependency ===\n");
15142    let ps_webview = r#"
15143$paths = @(
15144    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
15145    (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
15146) | Where-Object { $_ -and (Test-Path $_) }
15147$runtimeDir = $paths | ForEach-Object {
15148    Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
15149        Where-Object { $_.Name -match '^\d+\.' } |
15150        Sort-Object Name -Descending |
15151        Select-Object -First 1
15152} | Select-Object -First 1
15153if ($runtimeDir) {
15154    $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
15155    $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
15156    "WebView2: Installed | Version: $version"
15157} else {
15158    "WebView2: Not installed"
15159}
15160"#;
15161    match run_powershell(ps_webview) {
15162        Ok(o) if !o.trim().is_empty() => {
15163            for line in o.lines().take(4) {
15164                let l = line.trim();
15165                if !l.is_empty() {
15166                    out.push_str(&format!("- {l}\n"));
15167                }
15168            }
15169        }
15170        _ => out.push_str("- Could not inspect WebView2 runtime\n"),
15171    }
15172
15173    out.push_str("\n=== Recent auth-related events (24h) ===\n");
15174    let ps_events = r#"
15175try {
15176    $cutoff = (Get-Date).AddHours(-24)
15177    $events = @()
15178    if (Get-WinEvent -ListLog 'Microsoft-Windows-AAD/Operational' -ErrorAction SilentlyContinue) {
15179        $events += Get-WinEvent -FilterHashtable @{ LogName='Microsoft-Windows-AAD/Operational'; StartTime=$cutoff } -MaxEvents 30 -ErrorAction SilentlyContinue |
15180            Where-Object { $_.LevelDisplayName -in @('Error','Warning') } |
15181            Select-Object -First 4
15182    }
15183    $events += Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 80 -ErrorAction SilentlyContinue |
15184        Where-Object {
15185            ($_.LevelDisplayName -in @('Error','Warning')) -and (
15186                $_.ProviderName -match 'Outlook|Teams|OneDrive|Office|AAD|TokenBroker|Broker'
15187                -or $_.Message -match 'Outlook|Teams|OneDrive|sign-?in|authentication|TokenBroker|BrokerPlugin|AAD'
15188            )
15189        } |
15190        Select-Object -First 6
15191    $events = $events | Sort-Object TimeCreated -Descending | Select-Object -First 8
15192    "AuthEventCount: $(@($events).Count)"
15193    if ($events) {
15194        foreach ($e in $events) {
15195            $msg = if ([string]::IsNullOrWhiteSpace([string]$e.Message)) {
15196                'No message'
15197            } else {
15198                ($e.Message -replace '\r','' -split '\n')[0] -replace '\|','/'
15199            }
15200            "$($e.TimeCreated.ToString('MM-dd HH:mm')) | Provider: $($e.ProviderName) | Level: $($e.LevelDisplayName) | Id: $($e.Id) | $msg"
15201        }
15202    } else {
15203        "No auth-related warning/error events detected"
15204    }
15205} catch {
15206    "AuthEventStatus: Could not inspect auth-related events - $($_.Exception.Message)"
15207}
15208"#;
15209    match run_powershell(ps_events) {
15210        Ok(o) if !o.trim().is_empty() => {
15211            for line in o.lines().take(max_entries + 8) {
15212                let l = line.trim();
15213                if !l.is_empty() {
15214                    out.push_str(&format!("- {l}\n"));
15215                }
15216            }
15217        }
15218        _ => out
15219            .push_str("- AuthEventStatus: Could not inspect auth-related events in this session\n"),
15220    }
15221
15222    let parse_count = |prefix: &str| -> Option<u64> {
15223        out.lines().find_map(|line| {
15224            line.trim()
15225                .strip_prefix(prefix)
15226                .and_then(|value| value.trim().parse::<u64>().ok())
15227        })
15228    };
15229
15230    let distinct_identity_count = parse_count("- DistinctIdentityCount: ").unwrap_or(0);
15231    let auth_event_count = parse_count("- AuthEventCount: ").unwrap_or(0);
15232
15233    let mut findings: Vec<String> = Vec::new();
15234    if out.contains("TokenBroker | Status: Stopped")
15235        || out.contains("wlidsvc | Status: Stopped")
15236        || out.contains("OneAuth | Status: Stopped")
15237    {
15238        findings.push(
15239            "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."
15240                .into(),
15241        );
15242    }
15243    if out.contains("AADBrokerPlugin: Not installed") {
15244        findings.push(
15245            "Microsoft AAD Broker Plugin is missing - work/school account sign-in and token refresh can fail without the broker package."
15246                .into(),
15247        );
15248    }
15249    if out.contains("WebView2: Not installed") {
15250        findings.push(
15251            "WebView2 runtime is missing - modern Microsoft 365 sign-in surfaces may fail or render badly without it."
15252                .into(),
15253        );
15254    }
15255    if distinct_identity_count > 1 {
15256        findings.push(format!(
15257            "{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."
15258        ));
15259    }
15260    if (out.contains("AzureAdJoined: NO") || out.contains("WorkplaceJoined: NO"))
15261        && distinct_identity_count > 0
15262    {
15263        findings.push(
15264            "This machine shows Microsoft app identities but weak device-registration signals - organizational SSO, Conditional Access, or silent token refresh may be limited."
15265                .into(),
15266        );
15267    }
15268    if out.contains("DeviceRegistration: dsregcmd")
15269        || out.contains("DeviceRegistration: Could not inspect device registration state")
15270    {
15271        findings.push(
15272            "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."
15273                .into(),
15274        );
15275    }
15276    if auth_event_count > 0 {
15277        findings.push(format!(
15278            "{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."
15279        ));
15280    } else if out.contains("AuthEventStatus: Could not inspect auth-related events") {
15281        findings.push(
15282            "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."
15283                .into(),
15284        );
15285    }
15286
15287    let mut result = String::from("Host inspection: identity_auth\n\n=== Findings ===\n");
15288    if findings.is_empty() {
15289        result.push_str("- No obvious Microsoft 365 identity broker, token cache, or device-registration blocker detected.\n");
15290    } else {
15291        for finding in &findings {
15292            result.push_str(&format!("- Finding: {finding}\n"));
15293        }
15294    }
15295    result.push('\n');
15296    result.push_str(&out);
15297    Ok(result)
15298}
15299
15300#[cfg(not(windows))]
15301fn inspect_identity_auth(_max_entries: usize) -> Result<String, String> {
15302    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())
15303}
15304
15305#[cfg(windows)]
15306fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
15307    let mut out = String::from("=== File History ===\n");
15308
15309    let ps_fh = r#"
15310$svc = Get-Service fhsvc -ErrorAction SilentlyContinue
15311if ($svc) {
15312    "FileHistoryService: $($svc.Status) | StartType: $($svc.StartType)"
15313} else {
15314    "FileHistoryService: Not found"
15315}
15316# File History config in registry
15317$fhKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SPP\UserPolicy\S-1-5-21*\d5c93fba*'
15318$fhUser = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\FileHistory'
15319if (Test-Path $fhUser) {
15320    $fh = Get-ItemProperty $fhUser -ErrorAction SilentlyContinue
15321    $enabled = if ($fh.Enabled -eq 0) { 'Disabled' } elseif ($fh.Enabled -eq 1) { 'Enabled' } else { 'Unknown' }
15322    $target = if ($fh.TargetUrl) { $fh.TargetUrl } else { 'Not configured' }
15323    $lastBackup = if ($fh.ProtectedUpToTime) {
15324        try { [DateTime]::FromFileTime($fh.ProtectedUpToTime).ToString('yyyy-MM-dd HH:mm') } catch { 'Unknown' }
15325    } else { 'Never' }
15326    "Enabled: $enabled"
15327    "BackupDrive: $target"
15328    "LastBackup: $lastBackup"
15329} else {
15330    "Enabled: Not configured"
15331    "BackupDrive: Not configured"
15332    "LastBackup: Never"
15333}
15334"#;
15335    match run_powershell(ps_fh) {
15336        Ok(o) if !o.trim().is_empty() => {
15337            for line in o.lines().take(6) {
15338                let l = line.trim();
15339                if !l.is_empty() {
15340                    out.push_str(&format!("- {l}\n"));
15341                }
15342            }
15343        }
15344        _ => out.push_str("- Could not inspect File History state\n"),
15345    }
15346
15347    out.push_str("\n=== Windows Backup (wbadmin) ===\n");
15348    let ps_wbadmin = r#"
15349$svc = Get-Service wbengine -ErrorAction SilentlyContinue
15350"WindowsBackupEngine: $(if ($svc) { "$($svc.Status) | StartType: $($svc.StartType)" } else { 'Not found' })"
15351# Last backup from wbadmin
15352$raw = try { wbadmin get versions 2>&1 | Select-Object -First 30 } catch { $null }
15353if ($raw -and ($raw -join ' ') -notmatch 'no backup') {
15354    $lastDate = ($raw | Select-String 'Backup time:' | Select-Object -First 1).Line
15355    $lastTarget = ($raw | Select-String 'Backup target:' | Select-Object -First 1).Line
15356    if ($lastDate) { $lastDate.Trim() }
15357    if ($lastTarget) { $lastTarget.Trim() }
15358} else {
15359    "LastWbadminBackup: No backup versions found"
15360}
15361# Task-based backup
15362$task = Get-ScheduledTask -TaskPath '\Microsoft\Windows\WindowsBackup\' -ErrorAction SilentlyContinue
15363foreach ($t in $task) {
15364    "BackupTask: $($t.TaskName) | State: $($t.State)"
15365}
15366"#;
15367    match run_powershell(ps_wbadmin) {
15368        Ok(o) if !o.trim().is_empty() => {
15369            for line in o.lines().take(8) {
15370                let l = line.trim();
15371                if !l.is_empty() {
15372                    out.push_str(&format!("- {l}\n"));
15373                }
15374            }
15375        }
15376        _ => out.push_str("- Could not inspect Windows Backup state\n"),
15377    }
15378
15379    out.push_str("\n=== System Restore ===\n");
15380    let ps_sr = r#"
15381$drives = Get-WmiObject -Class Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction SilentlyContinue |
15382    Select-Object -ExpandProperty DeviceID
15383foreach ($drive in $drives) {
15384    $protection = try {
15385        (Get-ComputerRestorePoint -Drive "$drive\" -ErrorAction SilentlyContinue)
15386    } catch { $null }
15387    $srReg = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore"
15388    $rpConf = try {
15389        Get-ItemProperty "$srReg" -ErrorAction SilentlyContinue
15390    } catch { $null }
15391    # Check if SR is disabled for this drive
15392    $disabled = $false
15393    $vssService = Get-Service VSS -ErrorAction SilentlyContinue
15394    "Drive: $drive | VSSService: $(if ($vssService) { $vssService.Status } else { 'Not found' })"
15395}
15396# Most recent restore point
15397$points = try { Get-ComputerRestorePoint -ErrorAction SilentlyContinue } catch { $null }
15398if ($points) {
15399    $latest = $points | Sort-Object SequenceNumber -Descending | Select-Object -First 1
15400    $date = try { [Management.ManagementDateTimeConverter]::ToDateTime($latest.CreationTime).ToString('yyyy-MM-dd HH:mm') } catch { $latest.CreationTime }
15401    "MostRecentRestorePoint: $($latest.Description) | Created: $date"
15402} else {
15403    "MostRecentRestorePoint: None found"
15404}
15405$srEnabled = try {
15406    $regVal = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' -ErrorAction SilentlyContinue).RPSessionInterval
15407    if ($null -eq $regVal) { 'Enabled (default)' } elseif ($regVal -eq 0) { 'Disabled' } else { "Interval: $regVal" }
15408} catch { 'Unknown' }
15409"SystemRestoreState: $srEnabled"
15410"#;
15411    match run_powershell(ps_sr) {
15412        Ok(o) if !o.trim().is_empty() => {
15413            for line in o.lines().take(8) {
15414                let l = line.trim();
15415                if !l.is_empty() {
15416                    out.push_str(&format!("- {l}\n"));
15417                }
15418            }
15419        }
15420        _ => out.push_str("- Could not inspect System Restore state\n"),
15421    }
15422
15423    out.push_str("\n=== OneDrive backup (Known Folder Move) ===\n");
15424    let ps_kfm = r#"
15425$kfmKey = 'HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts'
15426if (Test-Path $kfmKey) {
15427    $accounts = Get-ChildItem $kfmKey -ErrorAction SilentlyContinue
15428    foreach ($acct in $accounts | Select-Object -First 3) {
15429        $props = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
15430        $email = $props.UserEmail
15431        $kfmDesktop = $props.'KFMSilentOptInDesktop'
15432        $kfmDocs = $props.'KFMSilentOptInDocuments'
15433        $kfmPics = $props.'KFMSilentOptInPictures'
15434        "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' })"
15435    }
15436} else {
15437    "OneDriveKFM: No OneDrive accounts found"
15438}
15439"#;
15440    match run_powershell(ps_kfm) {
15441        Ok(o) if !o.trim().is_empty() => {
15442            for line in o.lines().take(6) {
15443                let l = line.trim();
15444                if !l.is_empty() {
15445                    out.push_str(&format!("- {l}\n"));
15446                }
15447            }
15448        }
15449        _ => out.push_str("- Could not inspect OneDrive Known Folder Move state\n"),
15450    }
15451
15452    out.push_str("\n=== Recent backup failure events (7d) ===\n");
15453    let ps_events = r#"
15454$cutoff = (Get-Date).AddDays(-7)
15455$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
15456    Where-Object {
15457        $_.ProviderName -match 'backup|FileHistory|wbengine|Microsoft-Windows-Backup' -or
15458        ($_.Id -in @(49,50,517,521) -and $_.LogName -eq 'Application')
15459    } |
15460    Where-Object { $_.Level -le 3 } |
15461    Select-Object -First 6
15462if ($events) {
15463    foreach ($event in $events) {
15464        $msg = ($event.Message -replace '\s+', ' ')
15465        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
15466        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
15467    }
15468} else {
15469    "No recent backup failure events detected"
15470}
15471"#;
15472    match run_powershell(ps_events) {
15473        Ok(o) if !o.trim().is_empty() => {
15474            for line in o.lines().take(8) {
15475                let l = line.trim();
15476                if !l.is_empty() {
15477                    out.push_str(&format!("- {l}\n"));
15478                }
15479            }
15480        }
15481        _ => out.push_str("- Could not inspect backup failure events\n"),
15482    }
15483
15484    let mut findings: Vec<String> = Vec::new();
15485
15486    let fh_enabled = out.contains("- Enabled: Enabled");
15487    let fh_never =
15488        out.contains("- LastBackup: Never") || out.contains("- LastBackup: Not configured");
15489    let no_wbadmin = out.contains("No backup versions found");
15490    let no_restore_point = out.contains("MostRecentRestorePoint: None found");
15491
15492    if !fh_enabled && no_wbadmin {
15493        findings.push(
15494            "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(),
15495        );
15496    } else if fh_enabled && fh_never {
15497        findings.push(
15498            "File History is enabled but has never completed a backup — check that the backup drive is connected and accessible.".into(),
15499        );
15500    }
15501
15502    if no_restore_point {
15503        findings.push(
15504            "No System Restore points exist — if a driver or update goes wrong there is no local rollback point available.".into(),
15505        );
15506    }
15507
15508    if out.contains("- FileHistoryService: Stopped")
15509        || out.contains("- FileHistoryService: Not found")
15510    {
15511        findings.push(
15512            "File History service (fhsvc) is stopped or missing — File History backups cannot run until the service is started.".into(),
15513        );
15514    }
15515
15516    if out.contains("Application Error |")
15517        || out.contains("Microsoft-Windows-Backup |")
15518        || out.contains("wbengine |")
15519    {
15520        findings.push(
15521            "Recent backup failure events found in the Application log — check the event lines below for the specific error.".into(),
15522        );
15523    }
15524
15525    let mut result = String::from("Host inspection: windows_backup\n\n=== Findings ===\n");
15526    if findings.is_empty() {
15527        result.push_str("- No obvious backup health blocker detected.\n");
15528    } else {
15529        for finding in &findings {
15530            result.push_str(&format!("- Finding: {finding}\n"));
15531        }
15532    }
15533    result.push('\n');
15534    result.push_str(&out);
15535    Ok(result)
15536}
15537
15538#[cfg(not(windows))]
15539fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
15540    Ok("Host inspection: windows_backup\n\n=== Findings ===\n- Windows Backup inspection is Windows-only.\n".into())
15541}
15542
15543#[cfg(windows)]
15544fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
15545    let mut out = String::from("=== Windows Search service ===\n");
15546
15547    // Service state
15548    let ps_svc = r#"
15549$svc = Get-Service WSearch -ErrorAction SilentlyContinue
15550if ($svc) { "WSearch | Status: $($svc.Status) | StartType: $($svc.StartType)" }
15551else { "WSearch service not found" }
15552"#;
15553    match run_powershell(ps_svc) {
15554        Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
15555        Err(_) => out.push_str("- Could not query WSearch service\n"),
15556    }
15557
15558    // Indexer state via registry
15559    out.push_str("\n=== Indexer state ===\n");
15560    let ps_idx = r#"
15561$key = 'HKLM:\SOFTWARE\Microsoft\Windows Search'
15562$props = Get-ItemProperty $key -ErrorAction SilentlyContinue
15563if ($props) {
15564    "SetupCompletedSuccessfully: $($props.SetupCompletedSuccessfully)"
15565    "IsContentIndexingEnabled: $($props.IsContentIndexingEnabled)"
15566    "DataDirectory: $($props.DataDirectory)"
15567} else { "Registry key not found" }
15568"#;
15569    match run_powershell(ps_idx) {
15570        Ok(o) => {
15571            for line in o.lines() {
15572                let l = line.trim();
15573                if !l.is_empty() {
15574                    out.push_str(&format!("- {l}\n"));
15575                }
15576            }
15577        }
15578        Err(_) => out.push_str("- Could not read indexer registry\n"),
15579    }
15580
15581    // Indexed locations
15582    out.push_str("\n=== Indexed locations ===\n");
15583    let ps_locs = r#"
15584$comObj = New-Object -ComObject Microsoft.Search.Administration.CSearchManager -ErrorAction SilentlyContinue
15585if ($comObj) {
15586    $catalog = $comObj.GetCatalog('SystemIndex')
15587    $manager = $catalog.GetCrawlScopeManager()
15588    $rules = $manager.EnumerateRoots()
15589    while ($true) {
15590        try {
15591            $root = $rules.Next(1)
15592            if ($root.Count -eq 0) { break }
15593            $r = $root[0]
15594            "  $($r.RootURL) | Default: $($r.IsDefault) | Included: $($r.IsIncluded)"
15595        } catch { break }
15596    }
15597} else { "  COM admin interface not available (normal on non-admin sessions)" }
15598"#;
15599    match run_powershell(ps_locs) {
15600        Ok(o) if !o.trim().is_empty() => {
15601            for line in o.lines() {
15602                let l = line.trim_end();
15603                if !l.is_empty() {
15604                    out.push_str(&format!("{l}\n"));
15605                }
15606            }
15607        }
15608        _ => {
15609            // Fallback: read from registry
15610            let ps_reg = r#"
15611Get-ChildItem 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Search\Indexer\Sources' -ErrorAction SilentlyContinue |
15612ForEach-Object { "  $($_.PSChildName)" } | Select-Object -First 20
15613"#;
15614            match run_powershell(ps_reg) {
15615                Ok(o) if !o.trim().is_empty() => {
15616                    for line in o.lines() {
15617                        let l = line.trim_end();
15618                        if !l.is_empty() {
15619                            out.push_str(&format!("{l}\n"));
15620                        }
15621                    }
15622                }
15623                _ => out.push_str("  - Could not enumerate indexed locations\n"),
15624            }
15625        }
15626    }
15627
15628    // Recent indexing errors from event log
15629    out.push_str("\n=== Recent indexer errors (last 24h) ===\n");
15630    let ps_evts = r#"
15631Get-WinEvent -LogName 'Microsoft-Windows-Search/Operational' -MaxEvents 5 -ErrorAction SilentlyContinue |
15632Where-Object { $_.LevelDisplayName -eq 'Error' -or $_.LevelDisplayName -eq 'Warning' } |
15633ForEach-Object { "$($_.TimeCreated.ToString('HH:mm')) [$($_.LevelDisplayName)] $($_.Message.Substring(0, [Math]::Min(120, $_.Message.Length)))" }
15634"#;
15635    match run_powershell(ps_evts) {
15636        Ok(o) if !o.trim().is_empty() => {
15637            for line in o.lines() {
15638                let l = line.trim();
15639                if !l.is_empty() {
15640                    out.push_str(&format!("- {l}\n"));
15641                }
15642            }
15643        }
15644        _ => out.push_str("- No recent indexer errors found\n"),
15645    }
15646
15647    let mut findings: Vec<String> = Vec::new();
15648    if out.contains("Status: Stopped") {
15649        findings.push("Windows Search (WSearch) is stopped — search results will be slow or empty. Start the service: `Start-Service WSearch`.".into());
15650    }
15651    if out.contains("IsContentIndexingEnabled: 0")
15652        || out.contains("IsContentIndexingEnabled: False")
15653    {
15654        findings.push(
15655            "Content indexing is disabled — file content won't be searchable, only filenames."
15656                .into(),
15657        );
15658    }
15659    if out.contains("SetupCompletedSuccessfully: 0")
15660        || out.contains("SetupCompletedSuccessfully: False")
15661    {
15662        findings.push("Search indexer setup did not complete successfully — index may be corrupt. Rebuild: Settings > Search > Searching Windows > Advanced > Rebuild.".into());
15663    }
15664
15665    let mut result = String::from("Host inspection: search_index\n\n=== Findings ===\n");
15666    if findings.is_empty() {
15667        result.push_str("- Windows Search service and indexer appear healthy.\n");
15668        result.push_str("  If search still feels slow, the index may just be catching up — check indexing status in Settings > Search > Searching Windows.\n");
15669    } else {
15670        for f in &findings {
15671            result.push_str(&format!("- Finding: {f}\n"));
15672        }
15673    }
15674    result.push('\n');
15675    result.push_str(&out);
15676    Ok(result)
15677}
15678
15679#[cfg(not(windows))]
15680fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
15681    Ok("Host inspection: search_index\nSearch index inspection is Windows-only.".into())
15682}
15683
15684// ── inspect_display_config ────────────────────────────────────────────────────
15685
15686#[cfg(windows)]
15687fn inspect_display_config(max_entries: usize) -> Result<String, String> {
15688    let mut out = String::new();
15689
15690    // Active displays via CIM
15691    out.push_str("=== Active displays ===\n");
15692    let ps_displays = r#"
15693Get-CimInstance -ClassName CIM_VideoControllerResolution -ErrorAction SilentlyContinue |
15694Select-Object -First 20 |
15695ForEach-Object {
15696    "$($_.HorizontalResolution)x$($_.VerticalResolution) @ $($_.RefreshRate)Hz | Colors: $($_.NumberOfColors)"
15697}
15698"#;
15699    match run_powershell(ps_displays) {
15700        Ok(o) if !o.trim().is_empty() => {
15701            for line in o.lines().take(max_entries) {
15702                let l = line.trim();
15703                if !l.is_empty() {
15704                    out.push_str(&format!("- {l}\n"));
15705                }
15706            }
15707        }
15708        _ => out.push_str("- Could not enumerate display resolutions via CIM\n"),
15709    }
15710
15711    // GPU / video adapter
15712    out.push_str("\n=== Video adapters ===\n");
15713    let ps_gpu = r#"
15714Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | Select-Object -First 4 |
15715ForEach-Object {
15716    $res = "$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
15717    $hz  = "$($_.CurrentRefreshRate) Hz"
15718    $bits = "$($_.CurrentBitsPerPixel) bpp"
15719    "$($_.Name) | $res @ $hz | $bits | Driver: $($_.DriverVersion)"
15720}
15721"#;
15722    match run_powershell(ps_gpu) {
15723        Ok(o) if !o.trim().is_empty() => {
15724            for line in o.lines().take(max_entries) {
15725                let l = line.trim();
15726                if !l.is_empty() {
15727                    out.push_str(&format!("- {l}\n"));
15728                }
15729            }
15730        }
15731        _ => out.push_str("- Could not query video adapter info\n"),
15732    }
15733
15734    // Monitor names via Win32_DesktopMonitor
15735    out.push_str("\n=== Connected monitors ===\n");
15736    let ps_monitors = r#"
15737Get-CimInstance Win32_DesktopMonitor -ErrorAction SilentlyContinue | Select-Object -First 8 |
15738ForEach-Object { "$($_.Name) | Status: $($_.Status) | PnP: $($_.PNPDeviceID)" }
15739"#;
15740    match run_powershell(ps_monitors) {
15741        Ok(o) if !o.trim().is_empty() => {
15742            for line in o.lines().take(max_entries) {
15743                let l = line.trim();
15744                if !l.is_empty() {
15745                    out.push_str(&format!("- {l}\n"));
15746                }
15747            }
15748        }
15749        _ => out.push_str("- No monitor info available via WMI\n"),
15750    }
15751
15752    // DPI scaling
15753    out.push_str("\n=== DPI / scaling ===\n");
15754    let ps_dpi = r#"
15755Add-Type -TypeDefinition @'
15756using System; using System.Runtime.InteropServices;
15757public class DPI {
15758    [DllImport("user32")] public static extern IntPtr GetDC(IntPtr hwnd);
15759    [DllImport("gdi32")]  public static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
15760    [DllImport("user32")] public static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc);
15761}
15762'@ -ErrorAction SilentlyContinue
15763try {
15764    $hdc  = [DPI]::GetDC([IntPtr]::Zero)
15765    $dpiX = [DPI]::GetDeviceCaps($hdc, 88)
15766    $dpiY = [DPI]::GetDeviceCaps($hdc, 90)
15767    [DPI]::ReleaseDC([IntPtr]::Zero, $hdc) | Out-Null
15768    $scale = [Math]::Round($dpiX / 96.0 * 100)
15769    "DPI: ${dpiX}x${dpiY} | Scale: ${scale}%"
15770} catch { "DPI query unavailable" }
15771"#;
15772    match run_powershell(ps_dpi) {
15773        Ok(o) if !o.trim().is_empty() => {
15774            out.push_str(&format!("- {}\n", o.trim()));
15775        }
15776        _ => out.push_str("- DPI info unavailable\n"),
15777    }
15778
15779    let mut findings: Vec<String> = Vec::new();
15780    if out.contains("0x0") || out.contains("@ 0 Hz") {
15781        findings.push("One or more adapters report zero resolution or refresh rate — display may be asleep or misconfigured.".into());
15782    }
15783
15784    let mut result = String::from("Host inspection: display_config\n\n=== Findings ===\n");
15785    if findings.is_empty() {
15786        result.push_str("- Display configuration appears normal.\n");
15787    } else {
15788        for f in &findings {
15789            result.push_str(&format!("- Finding: {f}\n"));
15790        }
15791    }
15792    result.push('\n');
15793    result.push_str(&out);
15794    Ok(result)
15795}
15796
15797#[cfg(not(windows))]
15798fn inspect_display_config(_max_entries: usize) -> Result<String, String> {
15799    Ok("Host inspection: display_config\nDisplay config inspection is Windows-only.".into())
15800}
15801
15802// ── inspect_ntp ───────────────────────────────────────────────────────────────
15803
15804#[cfg(windows)]
15805fn inspect_ntp() -> Result<String, String> {
15806    let mut out = String::new();
15807
15808    // w32tm status
15809    out.push_str("=== Windows Time service ===\n");
15810    let ps_svc = r#"
15811$svc = Get-Service W32Time -ErrorAction SilentlyContinue
15812if ($svc) { "W32Time | Status: $($svc.Status) | StartType: $($svc.StartType)" }
15813else { "W32Time service not found" }
15814"#;
15815    match run_powershell(ps_svc) {
15816        Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
15817        Err(_) => out.push_str("- Could not query W32Time service\n"),
15818    }
15819
15820    // NTP source and last sync
15821    out.push_str("\n=== NTP source and sync status ===\n");
15822    let ps_sync = r#"
15823$q = w32tm /query /status 2>$null
15824if ($q) { $q } else { "w32tm query unavailable" }
15825"#;
15826    match run_powershell(ps_sync) {
15827        Ok(o) if !o.trim().is_empty() => {
15828            for line in o.lines() {
15829                let l = line.trim();
15830                if !l.is_empty() {
15831                    out.push_str(&format!("  {l}\n"));
15832                }
15833            }
15834        }
15835        _ => out.push_str("  - Could not query w32tm status\n"),
15836    }
15837
15838    // Configured NTP server
15839    out.push_str("\n=== Configured NTP servers ===\n");
15840    let ps_peers = r#"
15841w32tm /query /peers 2>$null | Select-Object -First 10
15842"#;
15843    match run_powershell(ps_peers) {
15844        Ok(o) if !o.trim().is_empty() => {
15845            for line in o.lines() {
15846                let l = line.trim();
15847                if !l.is_empty() {
15848                    out.push_str(&format!("  {l}\n"));
15849                }
15850            }
15851        }
15852        _ => {
15853            // Fallback: registry
15854            let ps_reg = r#"
15855(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Parameters' -Name NtpServer -ErrorAction SilentlyContinue).NtpServer
15856"#;
15857            match run_powershell(ps_reg) {
15858                Ok(o) if !o.trim().is_empty() => {
15859                    out.push_str(&format!("  NtpServer (registry): {}\n", o.trim()));
15860                }
15861                _ => out.push_str("  - Could not enumerate NTP peers\n"),
15862            }
15863        }
15864    }
15865
15866    let mut findings: Vec<String> = Vec::new();
15867    if out.contains("W32Time | Status: Stopped") {
15868        findings.push("Windows Time service is stopped — system clock will drift and may cause authentication or certificate failures. Start with: `Start-Service W32Time`.".into());
15869    }
15870    if out.contains("The computer did not resync") || out.contains("Error") {
15871        findings.push("w32tm reports a sync error — check NTP server reachability or run `w32tm /resync /force`.".into());
15872    }
15873
15874    let mut result = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15875    if findings.is_empty() {
15876        result.push_str("- Windows Time service and NTP sync appear healthy.\n");
15877    } else {
15878        for f in &findings {
15879            result.push_str(&format!("- Finding: {f}\n"));
15880        }
15881    }
15882    result.push('\n');
15883    result.push_str(&out);
15884    Ok(result)
15885}
15886
15887#[cfg(not(windows))]
15888fn inspect_ntp() -> Result<String, String> {
15889    // Linux/macOS: check timedatectl / chrony / ntpq
15890    let mut out = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15891
15892    let timedatectl = std::process::Command::new("timedatectl")
15893        .arg("status")
15894        .output();
15895
15896    if let Ok(o) = timedatectl {
15897        let text = String::from_utf8_lossy(&o.stdout);
15898        if text.contains("synchronized: yes") || text.contains("NTP synchronized: yes") {
15899            out.push_str("- NTP synchronized: yes\n\n=== timedatectl status ===\n");
15900        } else {
15901            out.push_str("- Finding: NTP not synchronized — run `timedatectl set-ntp true`\n\n=== timedatectl status ===\n");
15902        }
15903        for line in text.lines() {
15904            let l = line.trim();
15905            if !l.is_empty() {
15906                out.push_str(&format!("  {l}\n"));
15907            }
15908        }
15909        return Ok(out);
15910    }
15911
15912    // macOS fallback
15913    let sntp = std::process::Command::new("sntp")
15914        .args(["-d", "time.apple.com"])
15915        .output();
15916    if let Ok(o) = sntp {
15917        out.push_str("- NTP check via sntp:\n");
15918        out.push_str(&String::from_utf8_lossy(&o.stdout));
15919        return Ok(out);
15920    }
15921
15922    out.push_str("- NTP status unavailable (no timedatectl or sntp found)\n");
15923    Ok(out)
15924}
15925
15926// ── inspect_cpu_power ─────────────────────────────────────────────────────────
15927
15928#[cfg(windows)]
15929fn inspect_cpu_power() -> Result<String, String> {
15930    let mut out = String::new();
15931
15932    // Active power plan
15933    out.push_str("=== Active power plan ===\n");
15934    let ps_plan = r#"
15935$plan = powercfg /getactivescheme 2>$null
15936if ($plan) { $plan } else { "Could not query power scheme" }
15937"#;
15938    match run_powershell(ps_plan) {
15939        Ok(o) if !o.trim().is_empty() => out.push_str(&format!("- {}\n", o.trim())),
15940        _ => out.push_str("- Could not read active power plan\n"),
15941    }
15942
15943    // Processor min/max state and boost policy
15944    out.push_str("\n=== Processor performance policy ===\n");
15945    let ps_proc = r#"
15946$active = (powercfg /getactivescheme) -replace '.*GUID: ([a-f0-9-]+).*','$1'
15947$min = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMIN 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15948$max = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMAX 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15949$boost = powercfg /query $active SUB_PROCESSOR PERFBOOSTMODE 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15950if ($min)   { "Min processor state:  $(([Convert]::ToInt32(($min -split '0x')[1],16)))%" }
15951if ($max)   { "Max processor state:  $(([Convert]::ToInt32(($max -split '0x')[1],16)))%" }
15952if ($boost) {
15953    $bval = [Convert]::ToInt32(($boost -split '0x')[1],16)
15954    $bname = switch ($bval) { 0{'Disabled'} 1{'Enabled'} 2{'Aggressive'} 3{'Efficient Enabled'} 4{'Efficient Aggressive'} default{"Unknown ($bval)"} }
15955    "Turbo boost mode:     $bname"
15956}
15957"#;
15958    match run_powershell(ps_proc) {
15959        Ok(o) if !o.trim().is_empty() => {
15960            for line in o.lines() {
15961                let l = line.trim();
15962                if !l.is_empty() {
15963                    out.push_str(&format!("- {l}\n"));
15964                }
15965            }
15966        }
15967        _ => out.push_str("- Could not query processor performance settings\n"),
15968    }
15969
15970    // Current CPU frequency via WMI
15971    out.push_str("\n=== CPU frequency ===\n");
15972    let ps_freq = r#"
15973Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 4 |
15974ForEach-Object {
15975    $cur = $_.CurrentClockSpeed
15976    $max = $_.MaxClockSpeed
15977    $load = $_.LoadPercentage
15978    "$($_.Name.Trim()) | Current: ${cur} MHz | Max: ${max} MHz | Load: ${load}%"
15979}
15980"#;
15981    match run_powershell(ps_freq) {
15982        Ok(o) if !o.trim().is_empty() => {
15983            for line in o.lines() {
15984                let l = line.trim();
15985                if !l.is_empty() {
15986                    out.push_str(&format!("- {l}\n"));
15987                }
15988            }
15989        }
15990        _ => out.push_str("- Could not query CPU frequency via WMI\n"),
15991    }
15992
15993    // Throttle reason from ETW (quick check)
15994    out.push_str("\n=== Throttling indicators ===\n");
15995    let ps_throttle = r#"
15996$pwr = Get-CimInstance -Namespace root\wmi -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue
15997if ($pwr) {
15998    $pwr | Select-Object -First 4 | ForEach-Object {
15999        $c = [Math]::Round(($_.CurrentTemperature / 10.0) - 273.15, 1)
16000        "Thermal zone $($_.InstanceName): ${c}°C"
16001    }
16002} else { "Thermal zone WMI not available (normal on consumer hardware)" }
16003"#;
16004    match run_powershell(ps_throttle) {
16005        Ok(o) if !o.trim().is_empty() => {
16006            for line in o.lines() {
16007                let l = line.trim();
16008                if !l.is_empty() {
16009                    out.push_str(&format!("- {l}\n"));
16010                }
16011            }
16012        }
16013        _ => out.push_str("- Thermal zone info unavailable\n"),
16014    }
16015
16016    let mut findings: Vec<String> = Vec::new();
16017    if out.contains("Max processor state:  0%") || out.contains("Max processor state:  1%") {
16018        findings.push("Max processor state is near 0% — CPU is being hard-capped by the power plan. Check power plan settings.".into());
16019    }
16020    if out.contains("Turbo boost mode:     Disabled") {
16021        findings.push("Turbo Boost is disabled in the active power plan — CPU cannot exceed base clock speed.".into());
16022    }
16023    if out.contains("Min processor state:  100%") {
16024        findings.push("Min processor state is 100% — CPU is pinned at max clock. Good for performance, increases power/heat.".into());
16025    }
16026
16027    let mut result = String::from("Host inspection: cpu_power\n\n=== Findings ===\n");
16028    if findings.is_empty() {
16029        result.push_str("- CPU power and frequency settings appear normal.\n");
16030    } else {
16031        for f in &findings {
16032            result.push_str(&format!("- Finding: {f}\n"));
16033        }
16034    }
16035    result.push('\n');
16036    result.push_str(&out);
16037    Ok(result)
16038}
16039
16040#[cfg(windows)]
16041fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
16042    let mut out = String::new();
16043
16044    out.push_str("=== Credential vault summary ===\n");
16045    let ps_summary = r#"
16046$raw = cmdkey /list 2>&1
16047$lines = $raw -split "`n"
16048$total = ($lines | Where-Object { $_ -match "Target:" }).Count
16049"Total stored credentials: $total"
16050$windows = ($lines | Where-Object { $_ -match "Type: Windows" }).Count
16051$generic = ($lines | Where-Object { $_ -match "Type: Generic" }).Count
16052$cert    = ($lines | Where-Object { $_ -match "Type: Certificate" }).Count
16053"  Windows credentials: $windows"
16054"  Generic credentials: $generic"
16055"  Certificate-based:   $cert"
16056"#;
16057    match run_powershell(ps_summary) {
16058        Ok(o) => {
16059            for line in o.lines() {
16060                let l = line.trim();
16061                if !l.is_empty() {
16062                    out.push_str(&format!("- {l}\n"));
16063                }
16064            }
16065        }
16066        Err(e) => out.push_str(&format!("- Credential summary error: {e}\n")),
16067    }
16068
16069    out.push_str("\n=== Credential targets (up to 20) ===\n");
16070    let ps_list = r#"
16071$raw = cmdkey /list 2>&1
16072$entries = @(); $cur = @{}
16073foreach ($line in ($raw -split "`n")) {
16074    $l = $line.Trim()
16075    if     ($l -match "^Target:\s*(.+)")  { $cur = @{ Target=$Matches[1] } }
16076    elseif ($l -match "^Type:\s*(.+)"   -and $cur.Target) { $cur.Type=$Matches[1] }
16077    elseif ($l -match "^User:\s*(.+)"   -and $cur.Target) { $cur.User=$Matches[1]; $entries+=$cur; $cur=@{} }
16078}
16079$entries | Select-Object -Last 20 | ForEach-Object {
16080    "[$($_.Type)] $($_.Target)  (user: $($_.User))"
16081}
16082"#;
16083    match run_powershell(ps_list) {
16084        Ok(o) => {
16085            let lines: Vec<&str> = o
16086                .lines()
16087                .map(|l| l.trim())
16088                .filter(|l| !l.is_empty())
16089                .collect();
16090            if lines.is_empty() {
16091                out.push_str("- No credential entries found\n");
16092            } else {
16093                for l in &lines {
16094                    out.push_str(&format!("- {l}\n"));
16095                }
16096            }
16097        }
16098        Err(e) => out.push_str(&format!("- Credential list error: {e}\n")),
16099    }
16100
16101    let total_creds: usize = {
16102        let ps_count = r#"(cmdkey /list 2>&1 | Select-String "Target:").Count"#;
16103        run_powershell(ps_count)
16104            .ok()
16105            .and_then(|s| s.trim().parse().ok())
16106            .unwrap_or(0)
16107    };
16108
16109    let mut findings: Vec<String> = Vec::new();
16110    if total_creds > 30 {
16111        findings.push(format!(
16112            "{total_creds} stored credentials found — consider auditing for stale entries."
16113        ));
16114    }
16115
16116    let mut result = String::from("Host inspection: credentials\n\n=== Findings ===\n");
16117    if findings.is_empty() {
16118        result.push_str("- Credential store looks normal.\n");
16119    } else {
16120        for f in &findings {
16121            result.push_str(&format!("- Finding: {f}\n"));
16122        }
16123    }
16124    result.push('\n');
16125    result.push_str(&out);
16126    Ok(result)
16127}
16128
16129#[cfg(not(windows))]
16130fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
16131    Ok("Host inspection: credentials\n\n=== Findings ===\n- Credential Manager is Windows-only. Use `secret-tool` or `pass` on Linux.\n".into())
16132}
16133
16134#[cfg(windows)]
16135fn inspect_tpm() -> Result<String, String> {
16136    let mut out = String::new();
16137
16138    out.push_str("=== TPM state ===\n");
16139    let ps_tpm = r#"
16140function Emit-Field([string]$Name, $Value, [string]$Fallback = "Unknown") {
16141    $text = if ($null -eq $Value) { "" } else { [string]$Value }
16142    if ([string]::IsNullOrWhiteSpace($text)) { $text = $Fallback }
16143    "$Name$text"
16144}
16145$t = Get-Tpm -ErrorAction SilentlyContinue
16146if ($t) {
16147    Emit-Field "TpmPresent:          " $t.TpmPresent
16148    Emit-Field "TpmReady:            " $t.TpmReady
16149    Emit-Field "TpmEnabled:          " $t.TpmEnabled
16150    Emit-Field "TpmOwned:            " $t.TpmOwned
16151    Emit-Field "RestartPending:      " $t.RestartPending
16152    Emit-Field "ManufacturerIdTxt:   " $t.ManufacturerIdTxt
16153    Emit-Field "ManufacturerVersion: " $t.ManufacturerVersion
16154} else { "TPM module unavailable" }
16155"#;
16156    match run_powershell(ps_tpm) {
16157        Ok(o) => {
16158            for line in o.lines() {
16159                let l = line.trim();
16160                if !l.is_empty() {
16161                    out.push_str(&format!("- {l}\n"));
16162                }
16163            }
16164        }
16165        Err(e) => out.push_str(&format!("- Get-Tpm error: {e}\n")),
16166    }
16167
16168    out.push_str("\n=== TPM spec version (WMI) ===\n");
16169    let ps_spec = r#"
16170$wmi = Get-CimInstance -Namespace root\cimv2\security\microsofttpm -ClassName Win32_Tpm -ErrorAction SilentlyContinue
16171if ($wmi) {
16172    $spec = if ([string]::IsNullOrWhiteSpace([string]$wmi.SpecVersion)) { "Unknown" } else { [string]$wmi.SpecVersion }
16173    "SpecVersion:  $spec"
16174    "IsActivated:  $(if ($null -eq $wmi.IsActivated_InitialValue) { 'Unknown' } else { $wmi.IsActivated_InitialValue })"
16175    "IsEnabled:    $(if ($null -eq $wmi.IsEnabled_InitialValue) { 'Unknown' } else { $wmi.IsEnabled_InitialValue })"
16176    "IsOwned:      $(if ($null -eq $wmi.IsOwned_InitialValue) { 'Unknown' } else { $wmi.IsOwned_InitialValue })"
16177} else { "Win32_Tpm WMI class unavailable (may need elevation or no TPM)" }
16178"#;
16179    match run_powershell(ps_spec) {
16180        Ok(o) => {
16181            for line in o.lines() {
16182                let l = line.trim();
16183                if !l.is_empty() {
16184                    out.push_str(&format!("- {l}\n"));
16185                }
16186            }
16187        }
16188        Err(e) => out.push_str(&format!("- Win32_Tpm WMI error: {e}\n")),
16189    }
16190
16191    out.push_str("\n=== Secure Boot state ===\n");
16192    let ps_sb = r#"
16193try {
16194    $sb = Confirm-SecureBootUEFI -ErrorAction Stop
16195    if ($sb) { "Secure Boot: ENABLED" } else { "Secure Boot: DISABLED" }
16196} catch {
16197    $msg = $_.Exception.Message
16198    if ($msg -match "Access was denied" -or $msg -match "proper privileges") {
16199        "Secure Boot: Unknown (administrator privileges required)"
16200    } elseif ($msg -match "Cmdlet not supported on this platform") {
16201        "Secure Boot: N/A (Legacy BIOS or unsupported firmware)"
16202    } else {
16203        "Secure Boot: N/A ($msg)"
16204    }
16205}
16206"#;
16207    match run_powershell(ps_sb) {
16208        Ok(o) => {
16209            for line in o.lines() {
16210                let l = line.trim();
16211                if !l.is_empty() {
16212                    out.push_str(&format!("- {l}\n"));
16213                }
16214            }
16215        }
16216        Err(e) => out.push_str(&format!("- Secure Boot check error: {e}\n")),
16217    }
16218
16219    out.push_str("\n=== Firmware type ===\n");
16220    let ps_fw = r#"
16221$fw = (Get-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Control -Name "PEFirmwareType" -ErrorAction SilentlyContinue).PEFirmwareType
16222switch ($fw) {
16223    1 { "Firmware type: BIOS (Legacy)" }
16224    2 { "Firmware type: UEFI" }
16225    default {
16226        $bcd = bcdedit /enum firmware 2>$null
16227        if ($LASTEXITCODE -eq 0 -and $bcd) { "Firmware type: UEFI (bcdedit fallback)" }
16228        else { "Firmware type: Unknown or not set" }
16229    }
16230}
16231"#;
16232    match run_powershell(ps_fw) {
16233        Ok(o) => {
16234            for line in o.lines() {
16235                let l = line.trim();
16236                if !l.is_empty() {
16237                    out.push_str(&format!("- {l}\n"));
16238                }
16239            }
16240        }
16241        Err(e) => out.push_str(&format!("- Firmware type error: {e}\n")),
16242    }
16243
16244    let mut findings: Vec<String> = Vec::new();
16245    let mut indeterminate = false;
16246    if out.contains("TpmPresent:          False") {
16247        findings.push("No TPM detected — BitLocker hardware encryption and Windows 11 security features unavailable.".into());
16248    }
16249    if out.contains("TpmReady:            False") {
16250        findings.push(
16251            "TPM present but not ready — may need initialization in BIOS/UEFI settings.".into(),
16252        );
16253    }
16254    if out.contains("SpecVersion:  1.2") {
16255        findings.push("TPM 1.2 detected — Windows 11 requires TPM 2.0.".into());
16256    }
16257    if out.contains("Secure Boot: DISABLED") {
16258        findings.push("Secure Boot is disabled — recommended to enable in UEFI firmware for Windows 11 compliance.".into());
16259    }
16260    if out.contains("Firmware type: BIOS (Legacy)") {
16261        findings.push(
16262            "Legacy BIOS detected — Secure Boot and modern TPM require UEFI firmware.".into(),
16263        );
16264    }
16265
16266    if out.contains("TPM module unavailable")
16267        || out.contains("Win32_Tpm WMI class unavailable")
16268        || out.contains("Secure Boot: N/A")
16269        || out.contains("Secure Boot: Unknown")
16270        || out.contains("Firmware type: Unknown or not set")
16271        || out.contains("TpmPresent:          Unknown")
16272        || out.contains("TpmReady:            Unknown")
16273        || out.contains("TpmEnabled:          Unknown")
16274    {
16275        indeterminate = true;
16276    }
16277    if indeterminate {
16278        findings.push(
16279            "TPM / Secure Boot state could not be fully determined from this session - firmware mode, privileges, or Windows TPM providers may be limiting visibility."
16280                .into(),
16281        );
16282    }
16283
16284    let mut result = String::from("Host inspection: tpm\n\n=== Findings ===\n");
16285    if findings.is_empty() {
16286        result.push_str("- TPM and Secure Boot appear healthy.\n");
16287    } else {
16288        for f in &findings {
16289            result.push_str(&format!("- Finding: {f}\n"));
16290        }
16291    }
16292    result.push('\n');
16293    result.push_str(&out);
16294    Ok(result)
16295}
16296
16297#[cfg(not(windows))]
16298fn inspect_tpm() -> Result<String, String> {
16299    Ok(
16300        "Host inspection: tpm\n\n=== Findings ===\n- TPM/Secure Boot inspection is Windows-only.\n"
16301            .into(),
16302    )
16303}
16304
16305#[cfg(windows)]
16306fn inspect_latency() -> Result<String, String> {
16307    let mut out = String::new();
16308
16309    // Resolve default gateway from the routing table
16310    let ps_gw = r#"
16311$gw = (Get-NetRoute -DestinationPrefix "0.0.0.0/0" -ErrorAction SilentlyContinue |
16312       Sort-Object RouteMetric | Select-Object -First 1).NextHop
16313if ($gw) { $gw } else { "" }
16314"#;
16315    let gateway = run_powershell(ps_gw)
16316        .ok()
16317        .map(|s| s.trim().to_string())
16318        .filter(|s| !s.is_empty());
16319
16320    let targets: Vec<(&str, String)> = {
16321        let mut t = Vec::new();
16322        if let Some(ref gw) = gateway {
16323            t.push(("Default gateway", gw.clone()));
16324        }
16325        t.push(("Cloudflare DNS", "1.1.1.1".into()));
16326        t.push(("Google DNS", "8.8.8.8".into()));
16327        t
16328    };
16329
16330    let mut findings: Vec<String> = Vec::new();
16331
16332    for (label, host) in &targets {
16333        out.push_str(&format!("\n=== Ping: {label} ({host}) ===\n"));
16334        // Test-NetConnection gives RTT; -InformationLevel Quiet just returns bool, so use ping
16335        let ps_ping = format!(
16336            r#"
16337$r = Test-Connection -ComputerName "{host}" -Count 4 -ErrorAction SilentlyContinue
16338if ($r) {{
16339    $rtts = $r | ForEach-Object {{ $_.ResponseTime }}
16340    $min  = ($rtts | Measure-Object -Minimum).Minimum
16341    $max  = ($rtts | Measure-Object -Maximum).Maximum
16342    $avg  = [Math]::Round(($rtts | Measure-Object -Average).Average, 1)
16343    $loss = [Math]::Round((4 - $r.Count) / 4 * 100)
16344    "RTT min/avg/max: ${{min}}ms / ${{avg}}ms / ${{max}}ms"
16345    "Packet loss: ${{loss}}%"
16346    "Sent: 4  Received: $($r.Count)"
16347}} else {{
16348    "UNREACHABLE — 100% packet loss"
16349}}
16350"#
16351        );
16352        match run_powershell(&ps_ping) {
16353            Ok(o) => {
16354                let body = o.trim().to_string();
16355                for line in body.lines() {
16356                    let l = line.trim();
16357                    if !l.is_empty() {
16358                        out.push_str(&format!("- {l}\n"));
16359                    }
16360                }
16361                if body.contains("UNREACHABLE") {
16362                    findings.push(format!(
16363                        "{label} ({host}) is unreachable — possible routing or firewall issue."
16364                    ));
16365                } else if let Some(loss_line) = body.lines().find(|l| l.contains("Packet loss")) {
16366                    let pct: u32 = loss_line
16367                        .chars()
16368                        .filter(|c| c.is_ascii_digit())
16369                        .collect::<String>()
16370                        .parse()
16371                        .unwrap_or(0);
16372                    if pct >= 25 {
16373                        findings.push(format!("{label} ({host}): {pct}% packet loss detected — possible network instability."));
16374                    }
16375                    // High latency check
16376                    if let Some(rtt_line) = body.lines().find(|l| l.contains("RTT min/avg/max")) {
16377                        // parse avg from "RTT min/avg/max: Xms / Yms / Zms"
16378                        let parts: Vec<&str> = rtt_line.split('/').collect();
16379                        if parts.len() >= 2 {
16380                            let avg_str: String =
16381                                parts[1].chars().filter(|c| c.is_ascii_digit()).collect();
16382                            let avg: u32 = avg_str.parse().unwrap_or(0);
16383                            if avg > 150 {
16384                                findings.push(format!("{label} ({host}): high average RTT ({avg}ms) — check for congestion or routing issues."));
16385                            }
16386                        }
16387                    }
16388                }
16389            }
16390            Err(e) => out.push_str(&format!("- Ping error: {e}\n")),
16391        }
16392    }
16393
16394    let mut result = String::from("Host inspection: latency\n\n=== Findings ===\n");
16395    if findings.is_empty() {
16396        result.push_str("- Latency and reachability look normal.\n");
16397    } else {
16398        for f in &findings {
16399            result.push_str(&format!("- Finding: {f}\n"));
16400        }
16401    }
16402    result.push('\n');
16403    result.push_str(&out);
16404    Ok(result)
16405}
16406
16407#[cfg(not(windows))]
16408fn inspect_latency() -> Result<String, String> {
16409    let mut out = String::from("Host inspection: latency\n\n=== Findings ===\n");
16410    let targets = [("Cloudflare DNS", "1.1.1.1"), ("Google DNS", "8.8.8.8")];
16411    let mut findings: Vec<String> = Vec::new();
16412
16413    for (label, host) in &targets {
16414        out.push_str(&format!("\n=== Ping: {label} ({host}) ===\n"));
16415        let ping = std::process::Command::new("ping")
16416            .args(["-c", "4", "-W", "2", host])
16417            .output();
16418        match ping {
16419            Ok(o) => {
16420                let body = String::from_utf8_lossy(&o.stdout).into_owned();
16421                for line in body.lines() {
16422                    let l = line.trim();
16423                    if l.contains("ms") || l.contains("loss") || l.contains("transmitted") {
16424                        out.push_str(&format!("- {l}\n"));
16425                    }
16426                }
16427                if body.contains("100% packet loss") || body.contains("100.0% packet loss") {
16428                    findings.push(format!("{label} ({host}) is unreachable."));
16429                }
16430            }
16431            Err(e) => out.push_str(&format!("- ping error: {e}\n")),
16432        }
16433    }
16434
16435    if findings.is_empty() {
16436        out.insert_str(
16437            "Host inspection: latency\n\n=== Findings ===\n".len(),
16438            "- Latency and reachability look normal.\n",
16439        );
16440    } else {
16441        let mut prefix = String::new();
16442        for f in &findings {
16443            prefix.push_str(&format!("- Finding: {f}\n"));
16444        }
16445        out.insert_str(
16446            "Host inspection: latency\n\n=== Findings ===\n".len(),
16447            &prefix,
16448        );
16449    }
16450    Ok(out)
16451}
16452
16453#[cfg(windows)]
16454fn inspect_network_adapter() -> Result<String, String> {
16455    let mut out = String::new();
16456
16457    out.push_str("=== Network adapters ===\n");
16458    let ps_adapters = r#"
16459Get-NetAdapter | Sort-Object Status,Name | ForEach-Object {
16460    $speed = if ($_.LinkSpeed) { $_.LinkSpeed } else { "Unknown" }
16461    "$($_.Name) | Status: $($_.Status) | Speed: $speed | MAC: $($_.MacAddress) | Driver: $($_.DriverVersion)"
16462}
16463"#;
16464    match run_powershell(ps_adapters) {
16465        Ok(o) => {
16466            for line in o.lines() {
16467                let l = line.trim();
16468                if !l.is_empty() {
16469                    out.push_str(&format!("- {l}\n"));
16470                }
16471            }
16472        }
16473        Err(e) => out.push_str(&format!("- Adapter query error: {e}\n")),
16474    }
16475
16476    out.push_str("\n=== Duplex and negotiated speed ===\n");
16477    let ps_duplex = r#"
16478Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16479    $name = $_.Name
16480    $duplex = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
16481        Where-Object { $_.DisplayName -match "Duplex|Speed" } |
16482        Select-Object DisplayName, DisplayValue
16483    if ($duplex) {
16484        "--- $name ---"
16485        $duplex | ForEach-Object { "  $($_.DisplayName): $($_.DisplayValue)" }
16486    } else {
16487        "--- $name --- (no duplex/speed property exposed by driver)"
16488    }
16489}
16490"#;
16491    match run_powershell(ps_duplex) {
16492        Ok(o) => {
16493            let lines: Vec<&str> = o
16494                .lines()
16495                .map(|l| l.trim())
16496                .filter(|l| !l.is_empty())
16497                .collect();
16498            for l in &lines {
16499                out.push_str(&format!("- {l}\n"));
16500            }
16501        }
16502        Err(e) => out.push_str(&format!("- Duplex query error: {e}\n")),
16503    }
16504
16505    out.push_str("\n=== Offload and performance settings (Up adapters) ===\n");
16506    let ps_offload = r#"
16507Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16508    $name = $_.Name
16509    $props = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
16510        Where-Object { $_.DisplayName -match "Offload|RSS|Jumbo|Buffer|Flow|Interrupt|Checksum|Large Send" } |
16511        Select-Object DisplayName, DisplayValue
16512    if ($props) {
16513        "--- $name ---"
16514        $props | ForEach-Object { "  $($_.DisplayName): $($_.DisplayValue)" }
16515    }
16516}
16517"#;
16518    match run_powershell(ps_offload) {
16519        Ok(o) => {
16520            let lines: Vec<&str> = o
16521                .lines()
16522                .map(|l| l.trim())
16523                .filter(|l| !l.is_empty())
16524                .collect();
16525            if lines.is_empty() {
16526                out.push_str(
16527                    "- No offload settings exposed by driver (common on virtual/Wi-Fi adapters)\n",
16528                );
16529            } else {
16530                for l in &lines {
16531                    out.push_str(&format!("- {l}\n"));
16532                }
16533            }
16534        }
16535        Err(e) => out.push_str(&format!("- Offload query error: {e}\n")),
16536    }
16537
16538    out.push_str("\n=== Adapter error counters ===\n");
16539    let ps_errors = r#"
16540Get-NetAdapterStatistics | ForEach-Object {
16541    $errs = $_.ReceivedPacketErrors + $_.OutboundPacketErrors + $_.ReceivedDiscardedPackets + $_.OutboundDiscardedPackets
16542    if ($errs -gt 0) {
16543        "$($_.Name) | RX errors: $($_.ReceivedPacketErrors) | TX errors: $($_.OutboundPacketErrors) | RX discards: $($_.ReceivedDiscardedPackets) | TX discards: $($_.OutboundDiscardedPackets)"
16544    }
16545}
16546"#;
16547    match run_powershell(ps_errors) {
16548        Ok(o) => {
16549            let lines: Vec<&str> = o
16550                .lines()
16551                .map(|l| l.trim())
16552                .filter(|l| !l.is_empty())
16553                .collect();
16554            if lines.is_empty() {
16555                out.push_str("- No adapter errors or discards detected.\n");
16556            } else {
16557                for l in &lines {
16558                    out.push_str(&format!("- {l}\n"));
16559                }
16560            }
16561        }
16562        Err(e) => out.push_str(&format!("- Error counter query: {e}\n")),
16563    }
16564
16565    out.push_str("\n=== Wake-on-LAN and power settings ===\n");
16566    let ps_wol = r#"
16567Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16568    $wol = Get-NetAdapterPowerManagement -Name $_.Name -ErrorAction SilentlyContinue
16569    if ($wol) {
16570        "$($_.Name) | WakeOnMagicPacket: $($wol.WakeOnMagicPacket) | AllowComputerToTurnOffDevice: $($wol.AllowComputerToTurnOffDevice)"
16571    }
16572}
16573"#;
16574    match run_powershell(ps_wol) {
16575        Ok(o) => {
16576            let lines: Vec<&str> = o
16577                .lines()
16578                .map(|l| l.trim())
16579                .filter(|l| !l.is_empty())
16580                .collect();
16581            if lines.is_empty() {
16582                out.push_str("- Power management data unavailable for active adapters.\n");
16583            } else {
16584                for l in &lines {
16585                    out.push_str(&format!("- {l}\n"));
16586                }
16587            }
16588        }
16589        Err(e) => out.push_str(&format!("- WoL query error: {e}\n")),
16590    }
16591
16592    let mut findings: Vec<String> = Vec::new();
16593    // Check for error-prone adapters
16594    if out.contains("RX errors:") || out.contains("TX errors:") {
16595        findings
16596            .push("Adapter errors detected — check cabling, driver, or duplex mismatch.".into());
16597    }
16598    // Check for half-duplex (rare but still seen on older switches)
16599    if out.contains("Half") {
16600        findings.push("Half-duplex adapter detected — likely a duplex mismatch with the switch; set both sides to full-duplex.".into());
16601    }
16602
16603    let mut result = String::from("Host inspection: network_adapter\n\n=== Findings ===\n");
16604    if findings.is_empty() {
16605        result.push_str("- Network adapter configuration looks normal.\n");
16606    } else {
16607        for f in &findings {
16608            result.push_str(&format!("- Finding: {f}\n"));
16609        }
16610    }
16611    result.push('\n');
16612    result.push_str(&out);
16613    Ok(result)
16614}
16615
16616#[cfg(not(windows))]
16617fn inspect_network_adapter() -> Result<String, String> {
16618    let mut out = String::from("Host inspection: network_adapter\n\n=== Findings ===\n- Network adapter inspection running on Unix.\n\n");
16619
16620    out.push_str("=== Network adapters (ip link) ===\n");
16621    let ip_link = std::process::Command::new("ip")
16622        .args(["link", "show"])
16623        .output();
16624    if let Ok(o) = ip_link {
16625        for line in String::from_utf8_lossy(&o.stdout).lines() {
16626            let l = line.trim();
16627            if !l.is_empty() {
16628                out.push_str(&format!("- {l}\n"));
16629            }
16630        }
16631    }
16632
16633    out.push_str("\n=== Adapter statistics (ip -s link) ===\n");
16634    let ip_stats = std::process::Command::new("ip")
16635        .args(["-s", "link", "show"])
16636        .output();
16637    if let Ok(o) = ip_stats {
16638        for line in String::from_utf8_lossy(&o.stdout).lines() {
16639            let l = line.trim();
16640            if l.contains("RX") || l.contains("TX") || l.contains("errors") || l.contains("dropped")
16641            {
16642                out.push_str(&format!("- {l}\n"));
16643            }
16644        }
16645    }
16646    Ok(out)
16647}
16648
16649#[cfg(windows)]
16650fn inspect_dhcp() -> Result<String, String> {
16651    let mut out = String::new();
16652
16653    out.push_str("=== DHCP lease details (per adapter) ===\n");
16654    let ps_dhcp = r#"
16655$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
16656    Where-Object { $_.IPEnabled -eq $true }
16657foreach ($a in $adapters) {
16658    "--- $($a.Description) ---"
16659    "  DHCP Enabled:      $($a.DHCPEnabled)"
16660    if ($a.DHCPEnabled) {
16661        "  DHCP Server:       $($a.DHCPServer)"
16662        $obtained = $a.ConvertToDateTime($a.DHCPLeaseObtained) 2>$null
16663        $expires  = $a.ConvertToDateTime($a.DHCPLeaseExpires)  2>$null
16664        "  Lease Obtained:    $obtained"
16665        "  Lease Expires:     $expires"
16666    }
16667    "  IP Address:        $($a.IPAddress -join ', ')"
16668    "  Subnet Mask:       $($a.IPSubnet -join ', ')"
16669    "  Default Gateway:   $($a.DefaultIPGateway -join ', ')"
16670    "  DNS Servers:       $($a.DNSServerSearchOrder -join ', ')"
16671    "  MAC Address:       $($a.MACAddress)"
16672    ""
16673}
16674"#;
16675    match run_powershell(ps_dhcp) {
16676        Ok(o) => {
16677            for line in o.lines() {
16678                let l = line.trim_end();
16679                if !l.is_empty() {
16680                    out.push_str(&format!("{l}\n"));
16681                }
16682            }
16683        }
16684        Err(e) => out.push_str(&format!("- DHCP query error: {e}\n")),
16685    }
16686
16687    // Findings: check for expired or very-soon-expiring leases
16688    let mut findings: Vec<String> = Vec::new();
16689    let ps_expiry = r#"
16690$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object { $_.DHCPEnabled -and $_.IPEnabled }
16691foreach ($a in $adapters) {
16692    try {
16693        $exp = $a.ConvertToDateTime($a.DHCPLeaseExpires)
16694        $now = Get-Date
16695        $hrs = ($exp - $now).TotalHours
16696        if ($hrs -lt 0) { "$($a.Description): EXPIRED" }
16697        elseif ($hrs -lt 2) { "$($a.Description): expires in $([Math]::Round($hrs,1)) hours" }
16698    } catch {}
16699}
16700"#;
16701    if let Ok(o) = run_powershell(ps_expiry) {
16702        for line in o.lines() {
16703            let l = line.trim();
16704            if !l.is_empty() {
16705                if l.contains("EXPIRED") {
16706                    findings.push(format!("DHCP lease EXPIRED on adapter: {l}"));
16707                } else if l.contains("expires in") {
16708                    findings.push(format!("DHCP lease expiring soon — {l}"));
16709                }
16710            }
16711        }
16712    }
16713
16714    let mut result = String::from("Host inspection: dhcp\n\n=== Findings ===\n");
16715    if findings.is_empty() {
16716        result.push_str("- DHCP leases look healthy.\n");
16717    } else {
16718        for f in &findings {
16719            result.push_str(&format!("- Finding: {f}\n"));
16720        }
16721    }
16722    result.push('\n');
16723    result.push_str(&out);
16724    Ok(result)
16725}
16726
16727#[cfg(not(windows))]
16728fn inspect_dhcp() -> Result<String, String> {
16729    let mut out = String::from(
16730        "Host inspection: dhcp\n\n=== Findings ===\n- DHCP lease inspection running on Unix.\n\n",
16731    );
16732    out.push_str("=== DHCP leases (dhclient / NetworkManager) ===\n");
16733    for path in &["/var/lib/dhcp/dhclient.leases", "/var/lib/NetworkManager"] {
16734        if std::path::Path::new(path).exists() {
16735            let cat = std::process::Command::new("cat").arg(path).output();
16736            if let Ok(o) = cat {
16737                let text = String::from_utf8_lossy(&o.stdout);
16738                for line in text.lines().take(40) {
16739                    let l = line.trim();
16740                    if l.contains("lease")
16741                        || l.contains("expire")
16742                        || l.contains("server")
16743                        || l.contains("address")
16744                    {
16745                        out.push_str(&format!("- {l}\n"));
16746                    }
16747                }
16748            }
16749        }
16750    }
16751    // Also try ip addr for current IPs
16752    let ip = std::process::Command::new("ip")
16753        .args(["addr", "show"])
16754        .output();
16755    if let Ok(o) = ip {
16756        out.push_str("\n=== Current IP addresses (ip addr) ===\n");
16757        for line in String::from_utf8_lossy(&o.stdout).lines() {
16758            let l = line.trim();
16759            if l.starts_with("inet") || l.contains("dynamic") {
16760                out.push_str(&format!("- {l}\n"));
16761            }
16762        }
16763    }
16764    Ok(out)
16765}
16766
16767#[cfg(windows)]
16768fn inspect_mtu() -> Result<String, String> {
16769    let mut out = String::new();
16770
16771    out.push_str("=== Per-adapter MTU (IPv4) ===\n");
16772    let ps_mtu = r#"
16773Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv4" } |
16774    Sort-Object ConnectionState, InterfaceAlias |
16775    ForEach-Object {
16776        "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
16777    }
16778"#;
16779    match run_powershell(ps_mtu) {
16780        Ok(o) => {
16781            for line in o.lines() {
16782                let l = line.trim();
16783                if !l.is_empty() {
16784                    out.push_str(&format!("- {l}\n"));
16785                }
16786            }
16787        }
16788        Err(e) => out.push_str(&format!("- MTU query error: {e}\n")),
16789    }
16790
16791    out.push_str("\n=== Per-adapter MTU (IPv6) ===\n");
16792    let ps_mtu6 = r#"
16793Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv6" } |
16794    Sort-Object ConnectionState, InterfaceAlias |
16795    ForEach-Object {
16796        "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
16797    }
16798"#;
16799    match run_powershell(ps_mtu6) {
16800        Ok(o) => {
16801            for line in o.lines() {
16802                let l = line.trim();
16803                if !l.is_empty() {
16804                    out.push_str(&format!("- {l}\n"));
16805                }
16806            }
16807        }
16808        Err(e) => out.push_str(&format!("- IPv6 MTU query error: {e}\n")),
16809    }
16810
16811    out.push_str("\n=== Path MTU discovery (ping DF-bit to 8.8.8.8) ===\n");
16812    // Send a 1472-byte payload (1500 - 28 IP+ICMP headers) to test standard Ethernet MTU
16813    let ps_pmtu = r#"
16814$sizes = @(1472, 1400, 1280, 576)
16815$result = $null
16816foreach ($s in $sizes) {
16817    $r = Test-Connection -ComputerName "8.8.8.8" -Count 1 -BufferSize $s -ErrorAction SilentlyContinue
16818    if ($r) { $result = $s; break }
16819}
16820if ($result) { "Largest successful payload: $result bytes (path MTU >= $($result + 28) bytes)" }
16821else { "All test sizes failed — path MTU may be very restricted or ICMP is blocked" }
16822"#;
16823    match run_powershell(ps_pmtu) {
16824        Ok(o) => {
16825            for line in o.lines() {
16826                let l = line.trim();
16827                if !l.is_empty() {
16828                    out.push_str(&format!("- {l}\n"));
16829                }
16830            }
16831        }
16832        Err(e) => out.push_str(&format!("- Path MTU test error: {e}\n")),
16833    }
16834
16835    let mut findings: Vec<String> = Vec::new();
16836    if out.contains("MTU: 576 bytes") {
16837        findings.push("576-byte MTU detected — severely restricted path, likely a misconfigured VPN or legacy link.".into());
16838    }
16839    if out.contains("MTU: 1280 bytes") && !out.contains("IPv6") {
16840        findings.push(
16841            "1280-byte MTU on an IPv4 interface is unusually low — check VPN or PPPoE config."
16842                .into(),
16843        );
16844    }
16845    if out.contains("All test sizes failed") {
16846        findings.push("Path MTU test failed — ICMP may be blocked by firewall or all tested sizes exceed the path limit.".into());
16847    }
16848
16849    let mut result = String::from("Host inspection: mtu\n\n=== Findings ===\n");
16850    if findings.is_empty() {
16851        result.push_str("- MTU configuration looks normal.\n");
16852    } else {
16853        for f in &findings {
16854            result.push_str(&format!("- Finding: {f}\n"));
16855        }
16856    }
16857    result.push('\n');
16858    result.push_str(&out);
16859    Ok(result)
16860}
16861
16862#[cfg(not(windows))]
16863fn inspect_mtu() -> Result<String, String> {
16864    let mut out = String::from(
16865        "Host inspection: mtu\n\n=== Findings ===\n- MTU inspection running on Unix.\n\n",
16866    );
16867
16868    out.push_str("=== Per-interface MTU (ip link) ===\n");
16869    let ip = std::process::Command::new("ip")
16870        .args(["link", "show"])
16871        .output();
16872    if let Ok(o) = ip {
16873        for line in String::from_utf8_lossy(&o.stdout).lines() {
16874            let l = line.trim();
16875            if l.contains("mtu") || l.starts_with("\\d") {
16876                out.push_str(&format!("- {l}\n"));
16877            }
16878        }
16879    }
16880
16881    out.push_str("\n=== Path MTU test (ping -M do to 8.8.8.8) ===\n");
16882    let ping = std::process::Command::new("ping")
16883        .args(["-c", "1", "-M", "do", "-s", "1472", "8.8.8.8"])
16884        .output();
16885    match ping {
16886        Ok(o) => {
16887            let body = String::from_utf8_lossy(&o.stdout);
16888            for line in body.lines() {
16889                let l = line.trim();
16890                if !l.is_empty() {
16891                    out.push_str(&format!("- {l}\n"));
16892                }
16893            }
16894        }
16895        Err(e) => out.push_str(&format!("- Ping error: {e}\n")),
16896    }
16897    Ok(out)
16898}
16899
16900#[cfg(not(windows))]
16901fn inspect_cpu_power() -> Result<String, String> {
16902    let mut out = String::from("Host inspection: cpu_power\n\n=== Findings ===\n- CPU power inspection running on Unix.\n\n");
16903
16904    // Linux: cpufreq-info or /sys/devices/system/cpu
16905    out.push_str("=== CPU frequency (Linux) ===\n");
16906    let cat_scaling = std::process::Command::new("cat")
16907        .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq")
16908        .output();
16909    if let Ok(o) = cat_scaling {
16910        let khz: u64 = String::from_utf8_lossy(&o.stdout)
16911            .trim()
16912            .parse()
16913            .unwrap_or(0);
16914        if khz > 0 {
16915            out.push_str(&format!("- Current: {} MHz\n", khz / 1000));
16916        }
16917    }
16918    let cat_max = std::process::Command::new("cat")
16919        .arg("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq")
16920        .output();
16921    if let Ok(o) = cat_max {
16922        let khz: u64 = String::from_utf8_lossy(&o.stdout)
16923            .trim()
16924            .parse()
16925            .unwrap_or(0);
16926        if khz > 0 {
16927            out.push_str(&format!("- Max: {} MHz\n", khz / 1000));
16928        }
16929    }
16930    let governor = std::process::Command::new("cat")
16931        .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
16932        .output();
16933    if let Ok(o) = governor {
16934        let g = String::from_utf8_lossy(&o.stdout);
16935        let g = g.trim();
16936        if !g.is_empty() {
16937            out.push_str(&format!("- Governor: {g}\n"));
16938        }
16939    }
16940    Ok(out)
16941}
16942
16943// ── IPv6 ────────────────────────────────────────────────────────────────────
16944
16945#[cfg(windows)]
16946fn inspect_ipv6() -> Result<String, String> {
16947    let script = r#"
16948$result = [System.Text.StringBuilder]::new()
16949
16950# Per-adapter IPv6 addresses
16951$result.AppendLine("=== IPv6 addresses per adapter ===") | Out-Null
16952$adapters = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16953    Where-Object { $_.IPAddress -notmatch '^::1$' } |
16954    Sort-Object InterfaceAlias
16955foreach ($a in $adapters) {
16956    $prefix = $a.PrefixOrigin
16957    $suffix = $a.SuffixOrigin
16958    $scope  = $a.AddressState
16959    $result.AppendLine("  [$($a.InterfaceAlias)] $($a.IPAddress)/$($a.PrefixLength)  origin=$prefix/$suffix  state=$scope") | Out-Null
16960}
16961if (-not $adapters) { $result.AppendLine("  No global/link-local IPv6 addresses found.") | Out-Null }
16962
16963# Default gateway IPv6
16964$result.AppendLine("") | Out-Null
16965$result.AppendLine("=== IPv6 default gateway ===") | Out-Null
16966$gw6 = Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue
16967if ($gw6) {
16968    foreach ($g in $gw6) {
16969        $result.AppendLine("  [$($g.InterfaceAlias)] via $($g.NextHop)  metric=$($g.RouteMetric)") | Out-Null
16970    }
16971} else {
16972    $result.AppendLine("  No IPv6 default gateway configured.") | Out-Null
16973}
16974
16975# DHCPv6 lease info
16976$result.AppendLine("") | Out-Null
16977$result.AppendLine("=== DHCPv6 / prefix delegation ===") | Out-Null
16978$dhcpv6 = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16979    Where-Object { $_.PrefixOrigin -eq 'Dhcp' -or $_.SuffixOrigin -eq 'Dhcp' }
16980if ($dhcpv6) {
16981    foreach ($d in $dhcpv6) {
16982        $result.AppendLine("  [$($d.InterfaceAlias)] $($d.IPAddress) (DHCPv6-assigned)") | Out-Null
16983    }
16984} else {
16985    $result.AppendLine("  No DHCPv6-assigned addresses found (SLAAC or static in use).") | Out-Null
16986}
16987
16988# Privacy extensions
16989$result.AppendLine("") | Out-Null
16990$result.AppendLine("=== Privacy extensions (RFC 4941) ===") | Out-Null
16991try {
16992    $priv = netsh interface ipv6 show privacy
16993    $result.AppendLine(($priv -join "`n")) | Out-Null
16994} catch {
16995    $result.AppendLine("  Could not retrieve privacy extension state.") | Out-Null
16996}
16997
16998# Tunnel adapters
16999$result.AppendLine("") | Out-Null
17000$result.AppendLine("=== Tunnel adapters ===") | Out-Null
17001$tunnels = Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceDescription -match 'Teredo|6to4|ISATAP|Tunnel' }
17002if ($tunnels) {
17003    foreach ($t in $tunnels) {
17004        $result.AppendLine("  $($t.Name): $($t.InterfaceDescription)  Status=$($t.Status)") | Out-Null
17005    }
17006} else {
17007    $result.AppendLine("  No Teredo/6to4/ISATAP tunnel adapters found.") | Out-Null
17008}
17009
17010# Findings
17011$findings = [System.Collections.Generic.List[string]]::new()
17012$globalAddrs = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
17013    Where-Object { $_.IPAddress -match '^2[0-9a-f]{3}:' -or $_.IPAddress -match '^fc|^fd' }
17014if (-not $globalAddrs) { $findings.Add("No global unicast IPv6 address assigned — IPv6 internet access may be unavailable.") }
17015$noGw6 = -not (Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue)
17016if ($noGw6) { $findings.Add("No IPv6 default gateway — IPv6 routing not active.") }
17017
17018$result.AppendLine("") | Out-Null
17019$result.AppendLine("=== Findings ===") | Out-Null
17020if ($findings.Count -eq 0) {
17021    $result.AppendLine("- IPv6 configuration looks healthy.") | Out-Null
17022} else {
17023    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17024}
17025
17026Write-Output $result.ToString()
17027"#;
17028    let out = run_powershell(script)?;
17029    Ok(format!("Host inspection: ipv6\n\n{out}"))
17030}
17031
17032#[cfg(not(windows))]
17033fn inspect_ipv6() -> Result<String, String> {
17034    let mut out = String::from("Host inspection: ipv6\n\n=== IPv6 addresses (ip -6 addr) ===\n");
17035    if let Ok(o) = std::process::Command::new("ip")
17036        .args(["-6", "addr", "show"])
17037        .output()
17038    {
17039        out.push_str(&String::from_utf8_lossy(&o.stdout));
17040    }
17041    out.push_str("\n=== IPv6 routes (ip -6 route) ===\n");
17042    if let Ok(o) = std::process::Command::new("ip")
17043        .args(["-6", "route"])
17044        .output()
17045    {
17046        out.push_str(&String::from_utf8_lossy(&o.stdout));
17047    }
17048    Ok(out)
17049}
17050
17051// ── TCP Parameters ──────────────────────────────────────────────────────────
17052
17053#[cfg(windows)]
17054fn inspect_tcp_params() -> Result<String, String> {
17055    let script = r#"
17056$result = [System.Text.StringBuilder]::new()
17057
17058# Autotuning and global TCP settings
17059$result.AppendLine("=== TCP global settings (netsh) ===") | Out-Null
17060try {
17061    $global = netsh interface tcp show global
17062    foreach ($line in $global) {
17063        $l = $line.Trim()
17064        if ($l -and $l -notmatch '^---' -and $l -notmatch '^TCP Global') {
17065            $result.AppendLine("  $l") | Out-Null
17066        }
17067    }
17068} catch {
17069    $result.AppendLine("  Could not retrieve TCP global settings.") | Out-Null
17070}
17071
17072# Supplemental params via Get-NetTCPSetting
17073$result.AppendLine("") | Out-Null
17074$result.AppendLine("=== TCP settings profiles ===") | Out-Null
17075try {
17076    $tcpSettings = Get-NetTCPSetting -ErrorAction SilentlyContinue
17077    foreach ($s in $tcpSettings) {
17078        $result.AppendLine("  Profile: $($s.SettingName)") | Out-Null
17079        $result.AppendLine("    CongestionProvider:      $($s.CongestionProvider)") | Out-Null
17080        $result.AppendLine("    InitialCongestionWindow: $($s.InitialCongestionWindowMss) MSS") | Out-Null
17081        $result.AppendLine("    AutoTuningLevelLocal:    $($s.AutoTuningLevelLocal)") | Out-Null
17082        $result.AppendLine("    ScalingHeuristics:       $($s.ScalingHeuristics)") | Out-Null
17083        $result.AppendLine("    DynamicPortRangeStart:   $($s.DynamicPortRangeStartPort)") | Out-Null
17084        $result.AppendLine("    DynamicPortRangeEnd:     $($s.DynamicPortRangeStartPort + $s.DynamicPortRangeNumberOfPorts - 1)") | Out-Null
17085        $result.AppendLine("") | Out-Null
17086    }
17087} catch {
17088    $result.AppendLine("  Get-NetTCPSetting unavailable.") | Out-Null
17089}
17090
17091# Chimney offload state
17092$result.AppendLine("=== TCP Chimney offload ===") | Out-Null
17093try {
17094    $chimney = netsh interface tcp show chimney
17095    $result.AppendLine(($chimney -join "`n  ")) | Out-Null
17096} catch {
17097    $result.AppendLine("  Could not retrieve chimney state.") | Out-Null
17098}
17099
17100# ECN state
17101$result.AppendLine("") | Out-Null
17102$result.AppendLine("=== ECN capability ===") | Out-Null
17103try {
17104    $ecn = netsh interface tcp show ecncapability
17105    $result.AppendLine(($ecn -join "`n  ")) | Out-Null
17106} catch {
17107    $result.AppendLine("  Could not retrieve ECN state.") | Out-Null
17108}
17109
17110# Findings
17111$findings = [System.Collections.Generic.List[string]]::new()
17112try {
17113    $ts = Get-NetTCPSetting -SettingName 'Internet' -ErrorAction SilentlyContinue
17114    if ($ts -and $ts.AutoTuningLevelLocal -eq 'Disabled') {
17115        $findings.Add("TCP autotuning is DISABLED on the Internet profile — may limit throughput on high-latency links.")
17116    }
17117    if ($ts -and $ts.CongestionProvider -ne 'CUBIC' -and $ts.CongestionProvider -ne 'NewReno' -and $ts.CongestionProvider) {
17118        $findings.Add("Non-standard congestion provider: $($ts.CongestionProvider)")
17119    }
17120} catch {}
17121
17122$result.AppendLine("") | Out-Null
17123$result.AppendLine("=== Findings ===") | Out-Null
17124if ($findings.Count -eq 0) {
17125    $result.AppendLine("- TCP parameters look normal.") | Out-Null
17126} else {
17127    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17128}
17129
17130Write-Output $result.ToString()
17131"#;
17132    let out = run_powershell(script)?;
17133    Ok(format!("Host inspection: tcp_params\n\n{out}"))
17134}
17135
17136#[cfg(not(windows))]
17137fn inspect_tcp_params() -> Result<String, String> {
17138    let mut out = String::from("Host inspection: tcp_params\n\n=== TCP kernel parameters ===\n");
17139    for key in &[
17140        "net.ipv4.tcp_congestion_control",
17141        "net.ipv4.tcp_rmem",
17142        "net.ipv4.tcp_wmem",
17143        "net.ipv4.tcp_window_scaling",
17144        "net.ipv4.tcp_ecn",
17145        "net.ipv4.tcp_timestamps",
17146    ] {
17147        if let Ok(o) = std::process::Command::new("sysctl").arg(key).output() {
17148            out.push_str(&format!(
17149                "  {}\n",
17150                String::from_utf8_lossy(&o.stdout).trim()
17151            ));
17152        }
17153    }
17154    Ok(out)
17155}
17156
17157// ── WLAN Profiles ───────────────────────────────────────────────────────────
17158
17159#[cfg(windows)]
17160fn inspect_wlan_profiles() -> Result<String, String> {
17161    let script = r#"
17162$result = [System.Text.StringBuilder]::new()
17163
17164# List all saved profiles
17165$result.AppendLine("=== Saved wireless profiles ===") | Out-Null
17166try {
17167    $profilesRaw = netsh wlan show profiles
17168    $profiles = $profilesRaw | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
17169        $_.Matches[0].Groups[1].Value.Trim()
17170    }
17171
17172    if (-not $profiles) {
17173        $result.AppendLine("  No saved wireless profiles found.") | Out-Null
17174    } else {
17175        foreach ($p in $profiles) {
17176            $result.AppendLine("") | Out-Null
17177            $result.AppendLine("  Profile: $p") | Out-Null
17178            # Get detail for each profile
17179            $detail = netsh wlan show profile name="$p" key=clear 2>$null
17180            $auth      = ($detail | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
17181            $cipher    = ($detail | Select-String 'Cipher\s*:\s*(.+)') | Select-Object -First 1
17182            $conn      = ($detail | Select-String 'Connection mode\s*:\s*(.+)') | Select-Object -First 1
17183            $autoConn  = ($detail | Select-String 'Connect automatically\s*:\s*(.+)') | Select-Object -First 1
17184            if ($auth)     { $result.AppendLine("    Authentication:    $($auth.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17185            if ($cipher)   { $result.AppendLine("    Cipher:            $($cipher.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17186            if ($conn)     { $result.AppendLine("    Connection mode:   $($conn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17187            if ($autoConn) { $result.AppendLine("    Auto-connect:      $($autoConn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17188        }
17189    }
17190} catch {
17191    $result.AppendLine("  netsh wlan unavailable (no wireless adapter or WLAN service not running).") | Out-Null
17192}
17193
17194# Currently connected SSID
17195$result.AppendLine("") | Out-Null
17196$result.AppendLine("=== Currently connected ===") | Out-Null
17197try {
17198    $conn = netsh wlan show interfaces
17199    $ssid   = ($conn | Select-String 'SSID\s*:\s*(?!BSSID)(.+)') | Select-Object -First 1
17200    $bssid  = ($conn | Select-String 'BSSID\s*:\s*(.+)') | Select-Object -First 1
17201    $signal = ($conn | Select-String 'Signal\s*:\s*(.+)') | Select-Object -First 1
17202    $radio  = ($conn | Select-String 'Radio type\s*:\s*(.+)') | Select-Object -First 1
17203    if ($ssid)   { $result.AppendLine("  SSID:       $($ssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17204    if ($bssid)  { $result.AppendLine("  BSSID:      $($bssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17205    if ($signal) { $result.AppendLine("  Signal:     $($signal.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17206    if ($radio)  { $result.AppendLine("  Radio type: $($radio.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17207    if (-not $ssid) { $result.AppendLine("  Not connected to any wireless network.") | Out-Null }
17208} catch {
17209    $result.AppendLine("  Could not query wireless interface state.") | Out-Null
17210}
17211
17212# Findings
17213$findings = [System.Collections.Generic.List[string]]::new()
17214try {
17215    $allDetail = netsh wlan show profiles 2>$null
17216    $profileNames = $allDetail | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
17217        $_.Matches[0].Groups[1].Value.Trim()
17218    }
17219    foreach ($pn in $profileNames) {
17220        $det = netsh wlan show profile name="$pn" key=clear 2>$null
17221        $authLine = ($det | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
17222        if ($authLine) {
17223            $authVal = $authLine.Matches[0].Groups[1].Value.Trim()
17224            if ($authVal -match 'Open|WEP|None') {
17225                $findings.Add("Profile '$pn' uses weak/open authentication: $authVal")
17226            }
17227        }
17228    }
17229} catch {}
17230
17231$result.AppendLine("") | Out-Null
17232$result.AppendLine("=== Findings ===") | Out-Null
17233if ($findings.Count -eq 0) {
17234    $result.AppendLine("- All saved wireless profiles use acceptable authentication.") | Out-Null
17235} else {
17236    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17237}
17238
17239Write-Output $result.ToString()
17240"#;
17241    let out = run_powershell(script)?;
17242    Ok(format!("Host inspection: wlan_profiles\n\n{out}"))
17243}
17244
17245#[cfg(not(windows))]
17246fn inspect_wlan_profiles() -> Result<String, String> {
17247    let mut out =
17248        String::from("Host inspection: wlan_profiles\n\n=== Saved wireless profiles ===\n");
17249    // Try nmcli (NetworkManager)
17250    if let Ok(o) = std::process::Command::new("nmcli")
17251        .args(["-t", "-f", "NAME,TYPE,DEVICE", "connection", "show"])
17252        .output()
17253    {
17254        for line in String::from_utf8_lossy(&o.stdout).lines() {
17255            if line.contains("wireless") || line.contains("wifi") {
17256                out.push_str(&format!("  {line}\n"));
17257            }
17258        }
17259    } else {
17260        out.push_str("  nmcli not available.\n");
17261    }
17262    Ok(out)
17263}
17264
17265// ── IPSec ───────────────────────────────────────────────────────────────────
17266
17267#[cfg(windows)]
17268fn inspect_ipsec() -> Result<String, String> {
17269    let script = r#"
17270$result = [System.Text.StringBuilder]::new()
17271
17272# IPSec rules (firewall-integrated)
17273$result.AppendLine("=== IPSec connection security rules ===") | Out-Null
17274try {
17275    $rules = Get-NetIPsecRule -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq 'True' }
17276    if ($rules) {
17277        foreach ($r in $rules) {
17278            $result.AppendLine("  [$($r.DisplayName)]") | Out-Null
17279            $result.AppendLine("    Mode:       $($r.Mode)") | Out-Null
17280            $result.AppendLine("    Action:     $($r.Action)") | Out-Null
17281            $result.AppendLine("    InProfile:  $($r.Profile)") | Out-Null
17282        }
17283    } else {
17284        $result.AppendLine("  No enabled IPSec connection security rules found.") | Out-Null
17285    }
17286} catch {
17287    $result.AppendLine("  Get-NetIPsecRule unavailable.") | Out-Null
17288}
17289
17290# Active main-mode SAs
17291$result.AppendLine("") | Out-Null
17292$result.AppendLine("=== Active IPSec main-mode SAs ===") | Out-Null
17293try {
17294    $mmSAs = Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue
17295    if ($mmSAs) {
17296        foreach ($sa in $mmSAs) {
17297            $result.AppendLine("  Local: $($sa.LocalAddress)  <-->  Remote: $($sa.RemoteAddress)") | Out-Null
17298            $result.AppendLine("    AuthMethod: $($sa.LocalFirstId)  Cipher: $($sa.Cipher)") | Out-Null
17299        }
17300    } else {
17301        $result.AppendLine("  No active main-mode IPSec SAs.") | Out-Null
17302    }
17303} catch {
17304    $result.AppendLine("  Get-NetIPsecMainModeSA unavailable.") | Out-Null
17305}
17306
17307# Active quick-mode SAs
17308$result.AppendLine("") | Out-Null
17309$result.AppendLine("=== Active IPSec quick-mode SAs ===") | Out-Null
17310try {
17311    $qmSAs = Get-NetIPsecQuickModeSA -ErrorAction SilentlyContinue
17312    if ($qmSAs) {
17313        foreach ($sa in $qmSAs) {
17314            $result.AppendLine("  Local: $($sa.LocalAddress)  <-->  Remote: $($sa.RemoteAddress)") | Out-Null
17315            $result.AppendLine("    Encapsulation: $($sa.EncapsulationMode)  Protocol: $($sa.TransportLayerProtocol)") | Out-Null
17316        }
17317    } else {
17318        $result.AppendLine("  No active quick-mode IPSec SAs.") | Out-Null
17319    }
17320} catch {
17321    $result.AppendLine("  Get-NetIPsecQuickModeSA unavailable.") | Out-Null
17322}
17323
17324# IKE service state
17325$result.AppendLine("") | Out-Null
17326$result.AppendLine("=== IKE / IPSec Policy Agent service ===") | Out-Null
17327$ikeAgentSvc = Get-Service -Name 'PolicyAgent' -ErrorAction SilentlyContinue
17328if ($ikeAgentSvc) {
17329    $result.AppendLine("  PolicyAgent (IPSec Policy Agent): $($ikeAgentSvc.Status)") | Out-Null
17330} else {
17331    $result.AppendLine("  PolicyAgent service not found.") | Out-Null
17332}
17333
17334# Findings
17335$findings = [System.Collections.Generic.List[string]]::new()
17336$mmSACount = 0
17337try { $mmSACount = (Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue | Measure-Object).Count } catch {}
17338if ($mmSACount -gt 0) {
17339    $findings.Add("$mmSACount active IPSec main-mode SA(s) — IPSec tunnel is active.")
17340}
17341
17342$result.AppendLine("") | Out-Null
17343$result.AppendLine("=== Findings ===") | Out-Null
17344if ($findings.Count -eq 0) {
17345    $result.AppendLine("- No active IPSec SAs detected (no IPSec tunnel currently established).") | Out-Null
17346} else {
17347    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17348}
17349
17350Write-Output $result.ToString()
17351"#;
17352    let out = run_powershell(script)?;
17353    Ok(format!("Host inspection: ipsec\n\n{out}"))
17354}
17355
17356#[cfg(not(windows))]
17357fn inspect_ipsec() -> Result<String, String> {
17358    let mut out = String::from("Host inspection: ipsec\n\n=== IPSec SAs (ip xfrm state) ===\n");
17359    if let Ok(o) = std::process::Command::new("ip")
17360        .args(["xfrm", "state"])
17361        .output()
17362    {
17363        let body = String::from_utf8_lossy(&o.stdout);
17364        if body.trim().is_empty() {
17365            out.push_str("  No active IPSec SAs.\n");
17366        } else {
17367            out.push_str(&body);
17368        }
17369    }
17370    out.push_str("\n=== IPSec policies (ip xfrm policy) ===\n");
17371    if let Ok(o) = std::process::Command::new("ip")
17372        .args(["xfrm", "policy"])
17373        .output()
17374    {
17375        let body = String::from_utf8_lossy(&o.stdout);
17376        if body.trim().is_empty() {
17377            out.push_str("  No IPSec policies.\n");
17378        } else {
17379            out.push_str(&body);
17380        }
17381    }
17382    Ok(out)
17383}
17384
17385// ── NetBIOS ──────────────────────────────────────────────────────────────────
17386
17387#[cfg(windows)]
17388fn inspect_netbios() -> Result<String, String> {
17389    let script = r#"
17390$result = [System.Text.StringBuilder]::new()
17391
17392# NetBIOS node type and WINS per adapter
17393$result.AppendLine("=== NetBIOS configuration per adapter ===") | Out-Null
17394try {
17395    $adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17396        Where-Object { $_.IPEnabled -eq $true }
17397    foreach ($a in $adapters) {
17398        $nodeType = switch ($a.TcpipNetbiosOptions) {
17399            0 { "EnableNetBIOSViaDHCP" }
17400            1 { "Enabled" }
17401            2 { "Disabled" }
17402            default { "Unknown ($($a.TcpipNetbiosOptions))" }
17403        }
17404        $result.AppendLine("  [$($a.Description)]") | Out-Null
17405        $result.AppendLine("    NetBIOS over TCP/IP: $nodeType") | Out-Null
17406        if ($a.WINSPrimaryServer) {
17407            $result.AppendLine("    WINS Primary:        $($a.WINSPrimaryServer)") | Out-Null
17408        }
17409        if ($a.WINSSecondaryServer) {
17410            $result.AppendLine("    WINS Secondary:      $($a.WINSSecondaryServer)") | Out-Null
17411        }
17412    }
17413} catch {
17414    $result.AppendLine("  Could not query NetBIOS adapter config.") | Out-Null
17415}
17416
17417# nbtstat -n — registered local NetBIOS names
17418$result.AppendLine("") | Out-Null
17419$result.AppendLine("=== Registered NetBIOS names (nbtstat -n) ===") | Out-Null
17420try {
17421    $nbt = nbtstat -n 2>$null
17422    foreach ($line in $nbt) {
17423        $l = $line.Trim()
17424        if ($l -and $l -notmatch '^Node|^Host|^Registered|^-{3}') {
17425            $result.AppendLine("  $l") | Out-Null
17426        }
17427    }
17428} catch {
17429    $result.AppendLine("  nbtstat not available.") | Out-Null
17430}
17431
17432# NetBIOS session table
17433$result.AppendLine("") | Out-Null
17434$result.AppendLine("=== Active NetBIOS sessions (nbtstat -s) ===") | Out-Null
17435try {
17436    $sessions = nbtstat -s 2>$null | Where-Object { $_.Trim() -ne '' }
17437    if ($sessions) {
17438        foreach ($s in $sessions) { $result.AppendLine("  $($s.Trim())") | Out-Null }
17439    } else {
17440        $result.AppendLine("  No active NetBIOS sessions.") | Out-Null
17441    }
17442} catch {
17443    $result.AppendLine("  Could not query NetBIOS sessions.") | Out-Null
17444}
17445
17446# Findings
17447$findings = [System.Collections.Generic.List[string]]::new()
17448try {
17449    $enabled = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17450        Where-Object { $_.IPEnabled -and $_.TcpipNetbiosOptions -ne 2 }
17451    if ($enabled) {
17452        $findings.Add("NetBIOS over TCP/IP is enabled on $($enabled.Count) adapter(s) — potential attack surface if not required.")
17453    }
17454    $wins = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17455        Where-Object { $_.WINSPrimaryServer }
17456    if ($wins) {
17457        $findings.Add("WINS server configured: $($wins[0].WINSPrimaryServer) — verify this is intentional.")
17458    }
17459} catch {}
17460
17461$result.AppendLine("") | Out-Null
17462$result.AppendLine("=== Findings ===") | Out-Null
17463if ($findings.Count -eq 0) {
17464    $result.AppendLine("- NetBIOS configuration looks standard.") | Out-Null
17465} else {
17466    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17467}
17468
17469Write-Output $result.ToString()
17470"#;
17471    let out = run_powershell(script)?;
17472    Ok(format!("Host inspection: netbios\n\n{out}"))
17473}
17474
17475#[cfg(not(windows))]
17476fn inspect_netbios() -> Result<String, String> {
17477    let mut out = String::from("Host inspection: netbios\n\n=== NetBIOS (nmblookup) ===\n");
17478    if let Ok(o) = std::process::Command::new("nmblookup")
17479        .arg("-A")
17480        .arg("localhost")
17481        .output()
17482    {
17483        out.push_str(&String::from_utf8_lossy(&o.stdout));
17484    } else {
17485        out.push_str("  nmblookup not available (Samba not installed).\n");
17486    }
17487    Ok(out)
17488}
17489
17490// ── NIC Teaming ──────────────────────────────────────────────────────────────
17491
17492#[cfg(windows)]
17493fn inspect_nic_teaming() -> Result<String, String> {
17494    let script = r#"
17495$result = [System.Text.StringBuilder]::new()
17496
17497# Team inventory
17498$result.AppendLine("=== NIC teams ===") | Out-Null
17499try {
17500    $teams = Get-NetLbfoTeam -ErrorAction SilentlyContinue
17501    if ($teams) {
17502        foreach ($t in $teams) {
17503            $result.AppendLine("  Team: $($t.Name)") | Out-Null
17504            $result.AppendLine("    Mode:            $($t.TeamingMode)") | Out-Null
17505            $result.AppendLine("    LB Algorithm:    $($t.LoadBalancingAlgorithm)") | Out-Null
17506            $result.AppendLine("    Status:          $($t.Status)") | Out-Null
17507            $result.AppendLine("    Members:         $($t.Members -join ', ')") | Out-Null
17508            $result.AppendLine("    VLANs:           $($t.TransmitLinkSpeed / 1000000) Mbps TX / $($t.ReceiveLinkSpeed / 1000000) Mbps RX") | Out-Null
17509        }
17510    } else {
17511        $result.AppendLine("  No NIC teams configured on this machine.") | Out-Null
17512    }
17513} catch {
17514    $result.AppendLine("  Get-NetLbfoTeam unavailable (feature may not be installed).") | Out-Null
17515}
17516
17517# Team members detail
17518$result.AppendLine("") | Out-Null
17519$result.AppendLine("=== Team member detail ===") | Out-Null
17520try {
17521    $members = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue
17522    if ($members) {
17523        foreach ($m in $members) {
17524            $result.AppendLine("  [$($m.Team)] $($m.Name)  Role=$($m.AdministrativeMode)  Status=$($m.OperationalStatus)") | Out-Null
17525        }
17526    } else {
17527        $result.AppendLine("  No team members found.") | Out-Null
17528    }
17529} catch {
17530    $result.AppendLine("  Could not query team members.") | Out-Null
17531}
17532
17533# Findings
17534$findings = [System.Collections.Generic.List[string]]::new()
17535try {
17536    $degraded = Get-NetLbfoTeam -ErrorAction SilentlyContinue | Where-Object { $_.Status -ne 'Up' }
17537    if ($degraded) {
17538        foreach ($d in $degraded) { $findings.Add("Team '$($d.Name)' is in degraded state: $($d.Status)") }
17539    }
17540    $downMembers = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue | Where-Object { $_.OperationalStatus -ne 'Active' }
17541    if ($downMembers) {
17542        foreach ($m in $downMembers) { $findings.Add("Team member '$($m.Name)' in team '$($m.Team)' is not Active: $($m.OperationalStatus)") }
17543    }
17544} catch {}
17545
17546$result.AppendLine("") | Out-Null
17547$result.AppendLine("=== Findings ===") | Out-Null
17548if ($findings.Count -eq 0) {
17549    $result.AppendLine("- NIC teaming state looks healthy (or no teams configured).") | Out-Null
17550} else {
17551    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17552}
17553
17554Write-Output $result.ToString()
17555"#;
17556    let out = run_powershell(script)?;
17557    Ok(format!("Host inspection: nic_teaming\n\n{out}"))
17558}
17559
17560#[cfg(not(windows))]
17561fn inspect_nic_teaming() -> Result<String, String> {
17562    let mut out = String::from("Host inspection: nic_teaming\n\n=== Bond interfaces ===\n");
17563    if let Ok(o) = std::process::Command::new("cat")
17564        .arg("/proc/net/bonding/bond0")
17565        .output()
17566    {
17567        if o.status.success() {
17568            out.push_str(&String::from_utf8_lossy(&o.stdout));
17569        } else {
17570            out.push_str("  No bond0 interface found.\n");
17571        }
17572    }
17573    if let Ok(o) = std::process::Command::new("ip")
17574        .args(["link", "show", "type", "bond"])
17575        .output()
17576    {
17577        let body = String::from_utf8_lossy(&o.stdout);
17578        if !body.trim().is_empty() {
17579            out.push_str("\n=== Bond links (ip link) ===\n");
17580            out.push_str(&body);
17581        }
17582    }
17583    Ok(out)
17584}
17585
17586// ── SNMP ─────────────────────────────────────────────────────────────────────
17587
17588#[cfg(windows)]
17589fn inspect_snmp() -> Result<String, String> {
17590    let script = r#"
17591$result = [System.Text.StringBuilder]::new()
17592
17593# SNMP service state
17594$result.AppendLine("=== SNMP service state ===") | Out-Null
17595$svc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
17596if ($svc) {
17597    $result.AppendLine("  SNMP Agent service: $($svc.Status) (Startup: $($svc.StartType))") | Out-Null
17598} else {
17599    $result.AppendLine("  SNMP Agent service not installed.") | Out-Null
17600}
17601
17602$svcTrap = Get-Service -Name 'SNMPTRAP' -ErrorAction SilentlyContinue
17603if ($svcTrap) {
17604    $result.AppendLine("  SNMP Trap service:  $($svcTrap.Status) (Startup: $($svcTrap.StartType))") | Out-Null
17605}
17606
17607# Community strings (presence only — values redacted)
17608$result.AppendLine("") | Out-Null
17609$result.AppendLine("=== SNMP community strings (presence only) ===") | Out-Null
17610try {
17611    $communities = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
17612    if ($communities) {
17613        $names = $communities.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Name
17614        if ($names) {
17615            foreach ($n in $names) {
17616                $result.AppendLine("  Community: '$n'  (value redacted)") | Out-Null
17617            }
17618        } else {
17619            $result.AppendLine("  No community strings configured.") | Out-Null
17620        }
17621    } else {
17622        $result.AppendLine("  Registry key not found (SNMP may not be configured).") | Out-Null
17623    }
17624} catch {
17625    $result.AppendLine("  Could not read community strings (SNMP not configured or access denied).") | Out-Null
17626}
17627
17628# Permitted managers
17629$result.AppendLine("") | Out-Null
17630$result.AppendLine("=== Permitted SNMP managers ===") | Out-Null
17631try {
17632    $managers = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\PermittedManagers' -ErrorAction SilentlyContinue
17633    if ($managers) {
17634        $mgrs = $managers.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Value
17635        if ($mgrs) {
17636            foreach ($m in $mgrs) { $result.AppendLine("  $m") | Out-Null }
17637        } else {
17638            $result.AppendLine("  No permitted managers configured (accepts from any host).") | Out-Null
17639        }
17640    } else {
17641        $result.AppendLine("  No manager restrictions configured.") | Out-Null
17642    }
17643} catch {
17644    $result.AppendLine("  Could not read permitted managers.") | Out-Null
17645}
17646
17647# Findings
17648$findings = [System.Collections.Generic.List[string]]::new()
17649$snmpSvc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
17650if ($snmpSvc -and $snmpSvc.Status -eq 'Running') {
17651    $findings.Add("SNMP Agent is running — verify community strings and permitted managers are locked down.")
17652    try {
17653        $comms = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
17654        $publicExists = $comms.PSObject.Properties | Where-Object { $_.Name -eq 'public' }
17655        if ($publicExists) { $findings.Add("Community string 'public' is configured — this is a well-known default and a security risk.") }
17656    } catch {}
17657}
17658
17659$result.AppendLine("") | Out-Null
17660$result.AppendLine("=== Findings ===") | Out-Null
17661if ($findings.Count -eq 0) {
17662    $result.AppendLine("- SNMP agent is not running (or not installed). No exposure.") | Out-Null
17663} else {
17664    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17665}
17666
17667Write-Output $result.ToString()
17668"#;
17669    let out = run_powershell(script)?;
17670    Ok(format!("Host inspection: snmp\n\n{out}"))
17671}
17672
17673#[cfg(not(windows))]
17674fn inspect_snmp() -> Result<String, String> {
17675    let mut out = String::from("Host inspection: snmp\n\n=== SNMP daemon state ===\n");
17676    for svc in &["snmpd", "snmp"] {
17677        if let Ok(o) = std::process::Command::new("systemctl")
17678            .args(["is-active", svc])
17679            .output()
17680        {
17681            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
17682            out.push_str(&format!("  {svc}: {status}\n"));
17683        }
17684    }
17685    out.push_str("\n=== snmpd.conf community strings (presence check) ===\n");
17686    if let Ok(o) = std::process::Command::new("grep")
17687        .args(["-i", "community", "/etc/snmp/snmpd.conf"])
17688        .output()
17689    {
17690        if o.status.success() {
17691            for line in String::from_utf8_lossy(&o.stdout).lines() {
17692                out.push_str(&format!("  {line}\n"));
17693            }
17694        } else {
17695            out.push_str("  /etc/snmp/snmpd.conf not found or no community lines.\n");
17696        }
17697    }
17698    Ok(out)
17699}
17700
17701// ── Port Test ─────────────────────────────────────────────────────────────────
17702
17703#[cfg(windows)]
17704fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
17705    let target_host = host.unwrap_or("8.8.8.8");
17706    let target_port = port.unwrap_or(443);
17707
17708    let script = format!(
17709        r#"
17710$result = [System.Text.StringBuilder]::new()
17711$result.AppendLine("=== Port reachability test ===") | Out-Null
17712$result.AppendLine("  Target: {target_host}:{target_port}") | Out-Null
17713$result.AppendLine("") | Out-Null
17714
17715try {{
17716    $test = Test-NetConnection -ComputerName '{target_host}' -Port {target_port} -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
17717    if ($test) {{
17718        $status = if ($test.TcpTestSucceeded) {{ "OPEN (reachable)" }} else {{ "CLOSED or FILTERED" }}
17719        $result.AppendLine("  Result:          $status") | Out-Null
17720        $result.AppendLine("  Remote address:  $($test.RemoteAddress)") | Out-Null
17721        $result.AppendLine("  Remote port:     $($test.RemotePort)") | Out-Null
17722        if ($test.PingSucceeded) {{
17723            $result.AppendLine("  ICMP ping:       Succeeded ($($test.PingReplyDetails.RoundtripTime) ms)") | Out-Null
17724        }} else {{
17725            $result.AppendLine("  ICMP ping:       Failed (host may block ICMP)") | Out-Null
17726        }}
17727        $result.AppendLine("  Interface used:  $($test.InterfaceAlias)") | Out-Null
17728        $result.AppendLine("  Source address:  $($test.SourceAddress.IPAddress)") | Out-Null
17729
17730        $result.AppendLine("") | Out-Null
17731        $result.AppendLine("=== Findings ===") | Out-Null
17732        if ($test.TcpTestSucceeded) {{
17733            $result.AppendLine("- Port {target_port} on {target_host} is OPEN — TCP handshake succeeded.") | Out-Null
17734        }} else {{
17735            $result.AppendLine("- Port {target_port} on {target_host} is CLOSED or FILTERED — TCP handshake failed.") | Out-Null
17736            $result.AppendLine("  Check: firewall rules, route to host, or service not listening on that port.") | Out-Null
17737        }}
17738    }}
17739}} catch {{
17740    $result.AppendLine("  Test-NetConnection failed: $($_.Exception.Message)") | Out-Null
17741}}
17742
17743Write-Output $result.ToString()
17744"#
17745    );
17746    let out = run_powershell(&script)?;
17747    Ok(format!("Host inspection: port_test\n\n{out}"))
17748}
17749
17750#[cfg(not(windows))]
17751fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
17752    let target_host = host.unwrap_or("8.8.8.8");
17753    let target_port = port.unwrap_or(443);
17754    let mut out = format!("Host inspection: port_test\n\n=== Port reachability test ===\n  Target: {target_host}:{target_port}\n\n");
17755    // nc -zv with timeout
17756    let nc = std::process::Command::new("nc")
17757        .args(["-zv", "-w", "3", target_host, &target_port.to_string()])
17758        .output();
17759    match nc {
17760        Ok(o) => {
17761            let stderr = String::from_utf8_lossy(&o.stderr);
17762            let stdout = String::from_utf8_lossy(&o.stdout);
17763            let body = if !stdout.trim().is_empty() {
17764                stdout.as_ref()
17765            } else {
17766                stderr.as_ref()
17767            };
17768            out.push_str(&format!("  {}\n", body.trim()));
17769            out.push_str("\n=== Findings ===\n");
17770            if o.status.success() {
17771                out.push_str(&format!("- Port {target_port} on {target_host} is OPEN.\n"));
17772            } else {
17773                out.push_str(&format!(
17774                    "- Port {target_port} on {target_host} is CLOSED or FILTERED.\n"
17775                ));
17776            }
17777        }
17778        Err(e) => out.push_str(&format!("  nc not available: {e}\n")),
17779    }
17780    Ok(out)
17781}
17782
17783// ── Network Profile ───────────────────────────────────────────────────────────
17784
17785#[cfg(windows)]
17786fn inspect_network_profile() -> Result<String, String> {
17787    let script = r#"
17788$result = [System.Text.StringBuilder]::new()
17789
17790$result.AppendLine("=== Network location profiles ===") | Out-Null
17791try {
17792    $profiles = Get-NetConnectionProfile -ErrorAction SilentlyContinue
17793    if ($profiles) {
17794        foreach ($p in $profiles) {
17795            $result.AppendLine("  Interface: $($p.InterfaceAlias)") | Out-Null
17796            $result.AppendLine("    Network name:    $($p.Name)") | Out-Null
17797            $result.AppendLine("    Category:        $($p.NetworkCategory)") | Out-Null
17798            $result.AppendLine("    IPv4 conn:       $($p.IPv4Connectivity)") | Out-Null
17799            $result.AppendLine("    IPv6 conn:       $($p.IPv6Connectivity)") | Out-Null
17800            $result.AppendLine("") | Out-Null
17801        }
17802    } else {
17803        $result.AppendLine("  No network connection profiles found.") | Out-Null
17804    }
17805} catch {
17806    $result.AppendLine("  Could not query network profiles.") | Out-Null
17807}
17808
17809# Findings
17810$findings = [System.Collections.Generic.List[string]]::new()
17811try {
17812    $pub = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'Public' }
17813    if ($pub) {
17814        foreach ($p in $pub) {
17815            $findings.Add("Interface '$($p.InterfaceAlias)' is set to Public — firewall restrictions are maximum. Change to Private if this is a trusted network.")
17816        }
17817    }
17818    $domain = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'DomainAuthenticated' }
17819    if ($domain) {
17820        foreach ($d in $domain) {
17821            $findings.Add("Interface '$($d.InterfaceAlias)' is domain-authenticated — domain GPO firewall rules apply.")
17822        }
17823    }
17824} catch {}
17825
17826$result.AppendLine("=== Findings ===") | Out-Null
17827if ($findings.Count -eq 0) {
17828    $result.AppendLine("- Network profiles look normal.") | Out-Null
17829} else {
17830    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17831}
17832
17833Write-Output $result.ToString()
17834"#;
17835    let out = run_powershell(script)?;
17836    Ok(format!("Host inspection: network_profile\n\n{out}"))
17837}
17838
17839#[cfg(not(windows))]
17840fn inspect_network_profile() -> Result<String, String> {
17841    let mut out = String::from(
17842        "Host inspection: network_profile\n\n=== Network manager connection profiles ===\n",
17843    );
17844    if let Ok(o) = std::process::Command::new("nmcli")
17845        .args([
17846            "-t",
17847            "-f",
17848            "NAME,TYPE,STATE,DEVICE",
17849            "connection",
17850            "show",
17851            "--active",
17852        ])
17853        .output()
17854    {
17855        out.push_str(&String::from_utf8_lossy(&o.stdout));
17856    } else {
17857        out.push_str("  nmcli not available.\n");
17858    }
17859    Ok(out)
17860}
17861
17862// ── Storage Spaces ────────────────────────────────────────────────────────────
17863
17864#[cfg(windows)]
17865fn inspect_storage_spaces() -> Result<String, String> {
17866    let script = r#"
17867$result = [System.Text.StringBuilder]::new()
17868
17869# Storage Pools
17870try {
17871    $pools = Get-StoragePool -IsPrimordial $false -ErrorAction SilentlyContinue
17872    if ($pools) {
17873        $result.AppendLine("=== Storage Pools ===") | Out-Null
17874        foreach ($pool in $pools) {
17875            $health = $pool.HealthStatus
17876            $oper   = $pool.OperationalStatus
17877            $sizGB  = [math]::Round($pool.Size / 1GB, 1)
17878            $allocGB= [math]::Round($pool.AllocatedSize / 1GB, 1)
17879            $result.AppendLine("  Pool: $($pool.FriendlyName)  Size: ${sizGB}GB  Allocated: ${allocGB}GB  Health: $health  Status: $oper") | Out-Null
17880        }
17881        $result.AppendLine("") | Out-Null
17882    } else {
17883        $result.AppendLine("=== Storage Pools ===") | Out-Null
17884        $result.AppendLine("  No Storage Spaces pools configured.") | Out-Null
17885        $result.AppendLine("") | Out-Null
17886    }
17887} catch {
17888    $result.AppendLine("=== Storage Pools ===") | Out-Null
17889    $result.AppendLine("  Unable to query storage pools (may require elevation).") | Out-Null
17890    $result.AppendLine("") | Out-Null
17891}
17892
17893# Virtual Disks
17894try {
17895    $vdisks = Get-VirtualDisk -ErrorAction SilentlyContinue
17896    if ($vdisks) {
17897        $result.AppendLine("=== Virtual Disks ===") | Out-Null
17898        foreach ($vd in $vdisks) {
17899            $health  = $vd.HealthStatus
17900            $oper    = $vd.OperationalStatus
17901            $layout  = $vd.ResiliencySettingName
17902            $sizGB   = [math]::Round($vd.Size / 1GB, 1)
17903            $result.AppendLine("  VDisk: $($vd.FriendlyName)  Layout: $layout  Size: ${sizGB}GB  Health: $health  Status: $oper") | Out-Null
17904        }
17905        $result.AppendLine("") | Out-Null
17906    } else {
17907        $result.AppendLine("=== Virtual Disks ===") | Out-Null
17908        $result.AppendLine("  No Storage Spaces virtual disks configured.") | Out-Null
17909        $result.AppendLine("") | Out-Null
17910    }
17911} catch {
17912    $result.AppendLine("=== Virtual Disks ===") | Out-Null
17913    $result.AppendLine("  Unable to query virtual disks.") | Out-Null
17914    $result.AppendLine("") | Out-Null
17915}
17916
17917# Physical Disks in pools
17918try {
17919    $pdisks = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.Usage -ne 'Journal' }
17920    if ($pdisks) {
17921        $result.AppendLine("=== Physical Disks ===") | Out-Null
17922        foreach ($pd in $pdisks) {
17923            $sizGB  = [math]::Round($pd.Size / 1GB, 1)
17924            $health = $pd.HealthStatus
17925            $usage  = $pd.Usage
17926            $media  = $pd.MediaType
17927            $result.AppendLine("  $($pd.FriendlyName)  ${sizGB}GB  $media  Usage: $usage  Health: $health") | Out-Null
17928        }
17929        $result.AppendLine("") | Out-Null
17930    }
17931} catch {}
17932
17933# Findings
17934$findings = @()
17935try {
17936    $unhealthy = Get-StoragePool -IsPrimordial $false -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -ne 'Healthy' }
17937    foreach ($p in $unhealthy) { $findings += "WARN: Pool '$($p.FriendlyName)' health is $($p.HealthStatus)" }
17938    $degraded = Get-VirtualDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -ne 'Healthy' }
17939    foreach ($v in $degraded) { $findings += "WARN: VDisk '$($v.FriendlyName)' health is $($v.HealthStatus) — check for failed drives" }
17940    $failedPd = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -eq 'Unhealthy' -or $_.OperationalStatus -eq 'Lost Communication' }
17941    foreach ($d in $failedPd) { $findings += "CRITICAL: Physical disk '$($d.FriendlyName)' is $($d.HealthStatus) / $($d.OperationalStatus)" }
17942} catch {}
17943
17944if ($findings.Count -gt 0) {
17945    $result.AppendLine("=== Findings ===") | Out-Null
17946    foreach ($f in $findings) { $result.AppendLine("  $f") | Out-Null }
17947} else {
17948    $result.AppendLine("=== Findings ===") | Out-Null
17949    $result.AppendLine("  All storage pool components healthy (or no Storage Spaces configured).") | Out-Null
17950}
17951
17952Write-Output $result.ToString().TrimEnd()
17953"#;
17954    let out = run_powershell(script)?;
17955    Ok(format!("Host inspection: storage_spaces\n\n{out}"))
17956}
17957
17958#[cfg(not(windows))]
17959fn inspect_storage_spaces() -> Result<String, String> {
17960    let mut out = String::from("Host inspection: storage_spaces\n\n");
17961    // Linux: check mdadm software RAID
17962    let mdstat = std::fs::read_to_string("/proc/mdstat").unwrap_or_default();
17963    if !mdstat.is_empty() {
17964        out.push_str("=== Software RAID (/proc/mdstat) ===\n");
17965        out.push_str(&mdstat);
17966    } else {
17967        out.push_str("No mdadm software RAID detected (/proc/mdstat not found or empty).\n");
17968    }
17969    // Check LVM
17970    if let Ok(o) = Command::new("lvs")
17971        .args(["--noheadings", "-o", "lv_name,vg_name,lv_size,lv_attr"])
17972        .output()
17973    {
17974        let lvs = String::from_utf8_lossy(&o.stdout).to_string();
17975        if !lvs.trim().is_empty() {
17976            out.push_str("\n=== LVM Logical Volumes ===\n");
17977            out.push_str(&lvs);
17978        }
17979    }
17980    Ok(out)
17981}
17982
17983// ── Defender Quarantine / Threat History ─────────────────────────────────────
17984
17985#[cfg(windows)]
17986fn inspect_defender_quarantine(max_entries: usize) -> Result<String, String> {
17987    let limit = max_entries.min(50);
17988    let script = format!(
17989        r#"
17990$result = [System.Text.StringBuilder]::new()
17991
17992# Current threat detections (active + quarantined)
17993try {{
17994    $threats = Get-MpThreatDetection -ErrorAction SilentlyContinue | Sort-Object InitialDetectionTime -Descending | Select-Object -First {limit}
17995    if ($threats) {{
17996        $result.AppendLine("=== Recent Threat Detections (last {limit}) ===") | Out-Null
17997        foreach ($t in $threats) {{
17998            $name    = (Get-MpThreat -ThreatID $t.ThreatID -ErrorAction SilentlyContinue).ThreatName
17999            if (-not $name) {{ $name = "ID:$($t.ThreatID)" }}
18000            $time    = $t.InitialDetectionTime.ToString("yyyy-MM-dd HH:mm")
18001            $action  = $t.ActionSuccess
18002            $status  = $t.CurrentThreatExecutionStatusID
18003            $result.AppendLine("  [$time] $name  ActionSuccess:$action  Status:$status") | Out-Null
18004        }}
18005        $result.AppendLine("") | Out-Null
18006    }} else {{
18007        $result.AppendLine("=== Recent Threat Detections ===") | Out-Null
18008        $result.AppendLine("  No threat detections on record — Defender history is clean.") | Out-Null
18009        $result.AppendLine("") | Out-Null
18010    }}
18011}} catch {{
18012    $result.AppendLine("=== Recent Threat Detections ===") | Out-Null
18013    $result.AppendLine("  Unable to query threat detections: $_") | Out-Null
18014    $result.AppendLine("") | Out-Null
18015}}
18016
18017# Quarantine items
18018try {{
18019    $quarantine = Get-MpThreat -ErrorAction SilentlyContinue | Where-Object {{ $_.IsActive -eq $false }} | Select-Object -First {limit}
18020    if ($quarantine) {{
18021        $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18022        foreach ($q in $quarantine) {{
18023            $result.AppendLine("  $($q.ThreatName)  Severity:$($q.SeverityID)  Category:$($q.CategoryID)  Active:$($q.IsActive)") | Out-Null
18024        }}
18025        $result.AppendLine("") | Out-Null
18026    }} else {{
18027        $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18028        $result.AppendLine("  No quarantined threats found.") | Out-Null
18029        $result.AppendLine("") | Out-Null
18030    }}
18031}} catch {{
18032    $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18033    $result.AppendLine("  Unable to query quarantine list: $_") | Out-Null
18034    $result.AppendLine("") | Out-Null
18035}}
18036
18037# Defender scan stats
18038try {{
18039    $status = Get-MpComputerStatus -ErrorAction SilentlyContinue
18040    if ($status) {{
18041        $lastScan   = $status.QuickScanStartTime
18042        $lastFull   = $status.FullScanStartTime
18043        $sigDate    = $status.AntivirusSignatureLastUpdated
18044        $result.AppendLine("=== Defender Scan Summary ===") | Out-Null
18045        $result.AppendLine("  Last quick scan : $lastScan") | Out-Null
18046        $result.AppendLine("  Last full scan  : $lastFull") | Out-Null
18047        $result.AppendLine("  Signature date  : $sigDate") | Out-Null
18048    }}
18049}} catch {{}}
18050
18051Write-Output $result.ToString().TrimEnd()
18052"#,
18053        limit = limit
18054    );
18055    let out = run_powershell(&script)?;
18056    Ok(format!("Host inspection: defender_quarantine\n\n{out}"))
18057}
18058
18059// ── inspect_domain_health ─────────────────────────────────────────────────────
18060
18061#[cfg(windows)]
18062fn inspect_domain_health() -> Result<String, String> {
18063    let script = r#"
18064$result = [System.Text.StringBuilder]::new()
18065
18066# Domain membership
18067try {
18068    $cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
18069    $joined = $cs.PartOfDomain
18070    $domain = $cs.Domain
18071    $result.AppendLine("=== Domain Membership ===") | Out-Null
18072    $result.AppendLine("  Join Status : $(if ($joined) { 'DOMAIN JOINED' } else { 'WORKGROUP' })") | Out-Null
18073    if ($joined) { $result.AppendLine("  Domain      : $domain") | Out-Null }
18074    $result.AppendLine("  Computer    : $($cs.Name)") | Out-Null
18075} catch {
18076    $result.AppendLine("  Domain membership check failed: $_") | Out-Null
18077}
18078
18079# dsregcmd device registration state
18080try {
18081    $dsreg = dsregcmd /status 2>&1 | Where-Object { $_ -match '(AzureAdJoined|DomainJoined|WorkplaceJoined|TenantName|DeviceId|MdmUrl)' }
18082    if ($dsreg) {
18083        $result.AppendLine("") | Out-Null
18084        $result.AppendLine("=== Device Registration (dsregcmd) ===") | Out-Null
18085        foreach ($line in $dsreg) { $result.AppendLine("  $($line.Trim())") | Out-Null }
18086    }
18087} catch {}
18088
18089# DC discovery via nltest
18090$result.AppendLine("") | Out-Null
18091$result.AppendLine("=== Domain Controller Discovery ===") | Out-Null
18092try {
18093    $nl = nltest /dsgetdc:. 2>&1
18094    $dc_name = $null
18095    foreach ($line in $nl) {
18096        if ($line -match '(DC:|Address:|Dom Guid|DomainName)') {
18097            $result.AppendLine("  $($line.Trim())") | Out-Null
18098        }
18099        if ($line -match 'DC: \\\\(.+)') { $dc_name = $Matches[1].Trim() }
18100    }
18101    if ($dc_name) {
18102        $result.AppendLine("") | Out-Null
18103        $result.AppendLine("=== DC Port Connectivity (DC: $dc_name) ===") | Out-Null
18104        foreach ($entry in @(@{p=389;n='LDAP'},@{p=636;n='LDAPS'},@{p=88;n='Kerberos'},@{p=3268;n='GC-LDAP'})) {
18105            try {
18106                $tcp = New-Object System.Net.Sockets.TcpClient
18107                $conn = $tcp.BeginConnect($dc_name, $entry.p, $null, $null)
18108                $ok = $conn.AsyncWaitHandle.WaitOne(1200, $false)
18109                $tcp.Close()
18110                $status = if ($ok) { 'OPEN' } else { 'TIMEOUT' }
18111            } catch { $status = 'FAILED' }
18112            $result.AppendLine("  Port $($entry.p) ($($entry.n)): $status") | Out-Null
18113        }
18114    }
18115} catch {
18116    $result.AppendLine("  nltest unavailable (not domain-joined or missing RSAT): $_") | Out-Null
18117}
18118
18119# Last GPO machine refresh time
18120try {
18121    $gpoKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Group Policy\State\Machine\Extension-List\{00000000-0000-0000-0000-000000000000}'
18122    if (Test-Path $gpoKey) {
18123        $gpo = Get-ItemProperty $gpoKey -ErrorAction SilentlyContinue
18124        $result.AppendLine("") | Out-Null
18125        $result.AppendLine("=== Group Policy Last Refresh ===") | Out-Null
18126        $result.AppendLine("  Machine GPO last applied: $($gpo.EndTime)") | Out-Null
18127    }
18128} catch {}
18129
18130Write-Output $result.ToString().TrimEnd()
18131"#;
18132    let out = run_powershell(script)?;
18133    Ok(format!("Host inspection: domain_health\n\n{out}"))
18134}
18135
18136#[cfg(not(windows))]
18137fn inspect_domain_health() -> Result<String, String> {
18138    let mut out = String::from("Host inspection: domain_health\n\n");
18139    for cmd_args in &[vec!["realm", "list"], vec!["sssd", "--version"]] {
18140        if let Ok(o) = Command::new(cmd_args[0]).args(&cmd_args[1..]).output() {
18141            let s = String::from_utf8_lossy(&o.stdout);
18142            if !s.trim().is_empty() {
18143                out.push_str(&format!("$ {}\n{}\n", cmd_args.join(" "), s.trim_end()));
18144            }
18145        }
18146    }
18147    if out.trim_end().ends_with("domain_health") {
18148        out.push_str("Not domain-joined or realm/sssd not installed.\n");
18149    }
18150    Ok(out)
18151}
18152
18153// ── inspect_service_dependencies ─────────────────────────────────────────────
18154
18155#[cfg(windows)]
18156fn inspect_service_dependencies(max_entries: usize) -> Result<String, String> {
18157    let limit = max_entries.min(60);
18158    let script = format!(
18159        r#"
18160$result = [System.Text.StringBuilder]::new()
18161$result.AppendLine("=== Service Dependency Graph ===") | Out-Null
18162$result.AppendLine("Format: [Status] ServiceName — requires: ... | needed by: ...") | Out-Null
18163$result.AppendLine("") | Out-Null
18164$svc = Get-Service | Where-Object {{ $_.DependentServices.Count -gt 0 -or $_.RequiredServices.Count -gt 0 }} | Sort-Object Name | Select-Object -First {limit}
18165foreach ($s in $svc) {{
18166    $req  = if ($s.RequiredServices.Count  -gt 0) {{ "requires: $($s.RequiredServices.Name  -join ', ')" }} else {{ "" }}
18167    $dep  = if ($s.DependentServices.Count -gt 0) {{ "needed by: $($s.DependentServices.Name -join ', ')" }} else {{ "" }}
18168    $parts = @($req, $dep) | Where-Object {{ $_ }}
18169    if ($parts) {{
18170        $result.AppendLine("  [$($s.Status)] $($s.Name) — $($parts -join ' | ')") | Out-Null
18171    }}
18172}}
18173Write-Output $result.ToString().TrimEnd()
18174"#,
18175        limit = limit
18176    );
18177    let out = run_powershell(&script)?;
18178    Ok(format!("Host inspection: service_dependencies\n\n{out}"))
18179}
18180
18181#[cfg(not(windows))]
18182fn inspect_service_dependencies(_max_entries: usize) -> Result<String, String> {
18183    let out = Command::new("systemctl")
18184        .args(["list-dependencies", "--no-pager", "--plain"])
18185        .output()
18186        .ok()
18187        .and_then(|o| String::from_utf8(o.stdout).ok())
18188        .unwrap_or_else(|| "systemctl not available.\n".to_string());
18189    Ok(format!(
18190        "Host inspection: service_dependencies\n\n{}",
18191        out.trim_end()
18192    ))
18193}
18194
18195// ── inspect_wmi_health ────────────────────────────────────────────────────────
18196
18197#[cfg(windows)]
18198fn inspect_wmi_health() -> Result<String, String> {
18199    let script = r#"
18200$result = [System.Text.StringBuilder]::new()
18201$result.AppendLine("=== WMI Repository Health ===") | Out-Null
18202
18203# Basic WMI query test
18204try {
18205    $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
18206    $result.AppendLine("  Query (Win32_OperatingSystem): OK") | Out-Null
18207    $result.AppendLine("  OS: $($os.Caption) Build $($os.BuildNumber)") | Out-Null
18208} catch {
18209    $result.AppendLine("  Query FAILED: $_") | Out-Null
18210    $result.AppendLine("  FINDING: WMI may be corrupt. Run winmgmt /verifyrepository") | Out-Null
18211}
18212
18213# Repository integrity
18214try {
18215    $verify = & winmgmt /verifyrepository 2>&1
18216    $result.AppendLine("  winmgmt /verifyrepository: $verify") | Out-Null
18217} catch {
18218    $result.AppendLine("  winmgmt check unavailable: $_") | Out-Null
18219}
18220
18221# WMI service state
18222$svc = Get-Service winmgmt -ErrorAction SilentlyContinue
18223if ($svc) {
18224    $result.AppendLine("  Service (winmgmt): $($svc.Status) / $($svc.StartType)") | Out-Null
18225}
18226
18227# Repository folder size
18228$repPath = "$env:SystemRoot\System32\wbem\Repository"
18229if (Test-Path $repPath) {
18230    $bytes = (Get-ChildItem $repPath -Recurse -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
18231    $mb = [math]::Round($bytes / 1MB, 1)
18232    $result.AppendLine("  Repository size: $mb MB  ($repPath)") | Out-Null
18233    if ($mb -gt 200) {
18234        $result.AppendLine("  FINDING: Repository is unusually large (>200 MB). May indicate corruption or bloat.") | Out-Null
18235    }
18236}
18237
18238$result.AppendLine("") | Out-Null
18239$result.AppendLine("=== Recovery Steps (if corrupt) ===") | Out-Null
18240$result.AppendLine("  1. net stop winmgmt") | Out-Null
18241$result.AppendLine("  2. winmgmt /salvagerepository   (try first)") | Out-Null
18242$result.AppendLine("  3. winmgmt /resetrepository     (last resort — loses custom namespaces)") | Out-Null
18243$result.AppendLine("  4. net start winmgmt") | Out-Null
18244
18245Write-Output $result.ToString().TrimEnd()
18246"#;
18247    let out = run_powershell(script)?;
18248    Ok(format!("Host inspection: wmi_health\n\n{out}"))
18249}
18250
18251#[cfg(not(windows))]
18252fn inspect_wmi_health() -> Result<String, String> {
18253    Ok("Host inspection: wmi_health\n\nWMI is Windows-only. On Linux use 'systemctl status' for service health.\n".to_string())
18254}
18255
18256// ── inspect_local_security_policy ────────────────────────────────────────────
18257
18258#[cfg(windows)]
18259fn inspect_local_security_policy() -> Result<String, String> {
18260    let script = r#"
18261$result = [System.Text.StringBuilder]::new()
18262$result.AppendLine("=== Local Account & Password Policy ===") | Out-Null
18263$na = net accounts 2>&1
18264foreach ($line in $na) {
18265    if ($line -match '(password|lockout|observation|force|maximum|minimum|length|duration)') {
18266        $result.AppendLine("  $($line.Trim())") | Out-Null
18267    }
18268}
18269
18270$result.AppendLine("") | Out-Null
18271$result.AppendLine("=== LAN Manager / NTLM Authentication Level ===") | Out-Null
18272try {
18273    $lmLevel = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name LmCompatibilityLevel -ErrorAction SilentlyContinue).LmCompatibilityLevel
18274    if ($null -eq $lmLevel) { $lmLevel = 3 }
18275    $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'}
18276    $result.AppendLine("  LmCompatibilityLevel: $lmLevel — $($map[$lmLevel])") | Out-Null
18277    if ($lmLevel -lt 3) {
18278        $result.AppendLine("  FINDING: LmCompatibilityLevel < 3 allows weak LM/NTLM. Recommend level 3 or higher.") | Out-Null
18279    }
18280} catch {}
18281
18282$result.AppendLine("") | Out-Null
18283$result.AppendLine("=== UAC Settings ===") | Out-Null
18284try {
18285    $uac = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -ErrorAction SilentlyContinue
18286    if ($uac) {
18287        $result.AppendLine("  UAC Enabled             : $($uac.EnableLUA)   (1=on, 0=disabled)") | Out-Null
18288        $behavMap = @{0='No prompt (risk)'; 1='Prompt for creds'; 2='Prompt for consent'; 5='Secure consent (default)'}
18289        $bval = $uac.ConsentPromptBehaviorAdmin
18290        $result.AppendLine("  Admin Prompt Behavior   : $bval — $($behavMap[$bval])") | Out-Null
18291        if ($uac.EnableLUA -eq 0) {
18292            $result.AppendLine("  FINDING: UAC is disabled. Processes run with full admin rights without prompting.") | Out-Null
18293        }
18294    }
18295} catch {}
18296
18297Write-Output $result.ToString().TrimEnd()
18298"#;
18299    let out = run_powershell(script)?;
18300    Ok(format!("Host inspection: local_security_policy\n\n{out}"))
18301}
18302
18303#[cfg(not(windows))]
18304fn inspect_local_security_policy() -> Result<String, String> {
18305    let mut out = String::from("Host inspection: local_security_policy\n\n");
18306    if let Ok(content) = std::fs::read_to_string("/etc/login.defs") {
18307        out.push_str("=== /etc/login.defs ===\n");
18308        for line in content.lines() {
18309            let t = line.trim();
18310            if !t.is_empty() && !t.starts_with('#') {
18311                out.push_str(&format!("  {t}\n"));
18312            }
18313        }
18314    }
18315    Ok(out)
18316}
18317
18318// ── inspect_usb_history ───────────────────────────────────────────────────────
18319
18320#[cfg(windows)]
18321fn inspect_usb_history(max_entries: usize) -> Result<String, String> {
18322    let limit = max_entries.min(50);
18323    let script = format!(
18324        r#"
18325$result = [System.Text.StringBuilder]::new()
18326$result.AppendLine("=== USB Device History (USBSTOR Registry) ===") | Out-Null
18327$usbPath = 'HKLM:\SYSTEM\CurrentControlSet\Enum\USBSTOR'
18328if (Test-Path $usbPath) {{
18329    $count = 0
18330    $seen = @{{}}
18331    $classes = Get-ChildItem $usbPath -ErrorAction SilentlyContinue
18332    foreach ($class in $classes) {{
18333        $instances = Get-ChildItem $class.PSPath -ErrorAction SilentlyContinue
18334        foreach ($inst in $instances) {{
18335            if ($count -ge {limit}) {{ break }}
18336            try {{
18337                $props = Get-ItemProperty $inst.PSPath -ErrorAction SilentlyContinue
18338                $fn = if ($props.FriendlyName) {{ $props.FriendlyName }} else {{ $class.PSChildName }}
18339                if (-not $seen[$fn]) {{
18340                    $seen[$fn] = $true
18341                    $result.AppendLine("  $fn") | Out-Null
18342                    $count++
18343                }}
18344            }} catch {{}}
18345        }}
18346    }}
18347    if ($count -eq 0) {{
18348        $result.AppendLine("  No USB storage devices found in registry.") | Out-Null
18349    }} else {{
18350        $result.AppendLine("") | Out-Null
18351        $result.AppendLine("  ($count unique devices; requires elevation for full history)") | Out-Null
18352    }}
18353}} else {{
18354    $result.AppendLine("  USBSTOR key not found. Requires elevation or USB storage policy may block it.") | Out-Null
18355}}
18356Write-Output $result.ToString().TrimEnd()
18357"#,
18358        limit = limit
18359    );
18360    let out = run_powershell(&script)?;
18361    Ok(format!("Host inspection: usb_history\n\n{out}"))
18362}
18363
18364#[cfg(not(windows))]
18365fn inspect_usb_history(_max_entries: usize) -> Result<String, String> {
18366    let mut out = String::from("Host inspection: usb_history\n\n");
18367    if let Ok(o) = Command::new("journalctl")
18368        .args(["-k", "--no-pager", "-q", "--since", "30 days ago"])
18369        .output()
18370    {
18371        let s = String::from_utf8_lossy(&o.stdout);
18372        let usb_lines: Vec<&str> = s
18373            .lines()
18374            .filter(|l| l.to_ascii_lowercase().contains("usb"))
18375            .take(30)
18376            .collect();
18377        if !usb_lines.is_empty() {
18378            out.push_str("=== Recent USB kernel events (journalctl) ===\n");
18379            for line in usb_lines {
18380                out.push_str(&format!("  {line}\n"));
18381            }
18382        }
18383    } else {
18384        out.push_str("USB history via journalctl not available.\n");
18385    }
18386    Ok(out)
18387}
18388
18389// ── inspect_print_spooler ─────────────────────────────────────────────────────
18390
18391#[cfg(windows)]
18392fn inspect_print_spooler() -> Result<String, String> {
18393    let script = r#"
18394$result = [System.Text.StringBuilder]::new()
18395
18396$svc = Get-Service Spooler -ErrorAction SilentlyContinue
18397$result.AppendLine("=== Print Spooler Service ===") | Out-Null
18398if ($svc) {
18399    $result.AppendLine("  Status     : $($svc.Status)") | Out-Null
18400    $result.AppendLine("  Start Type : $($svc.StartType)") | Out-Null
18401} else {
18402    $result.AppendLine("  Spooler service not found.") | Out-Null
18403}
18404
18405# PrintNightmare mitigations (CVE-2021-34527)
18406$result.AppendLine("") | Out-Null
18407$result.AppendLine("=== PrintNightmare Hardening (CVE-2021-34527) ===") | Out-Null
18408try {
18409    $val = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Print' -Name RpcAuthnLevelPrivacyEnabled -ErrorAction SilentlyContinue).RpcAuthnLevelPrivacyEnabled
18410    if ($val -eq 1) {
18411        $result.AppendLine("  RpcAuthnLevelPrivacyEnabled: 1 — HARDENED (good)") | Out-Null
18412    } else {
18413        $result.AppendLine("  RpcAuthnLevelPrivacyEnabled: $val — NOT hardened") | Out-Null
18414        $result.AppendLine("  FINDING: PrintNightmare RPC mitigation not applied. Set to 1 via registry.") | Out-Null
18415    }
18416} catch { $result.AppendLine("  Mitigation key not readable: $_") | Out-Null }
18417
18418try {
18419    $pnpPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Printers\PointAndPrint'
18420    if (Test-Path $pnpPath) {
18421        $pnp = Get-ItemProperty $pnpPath -ErrorAction SilentlyContinue
18422        $result.AppendLine("  RestrictDriverInstallationToAdministrators: $($pnp.RestrictDriverInstallationToAdministrators)") | Out-Null
18423        $result.AppendLine("  NoWarningNoElevationOnInstall              : $($pnp.NoWarningNoElevationOnInstall)") | Out-Null
18424        if ($pnp.NoWarningNoElevationOnInstall -eq 1) {
18425            $result.AppendLine("  FINDING: Point and Print allows silent driver install without elevation (risk).") | Out-Null
18426        }
18427    } else {
18428        $result.AppendLine("  No Point and Print policy (using Windows defaults).") | Out-Null
18429    }
18430} catch {}
18431
18432# Pending print jobs
18433$result.AppendLine("") | Out-Null
18434$result.AppendLine("=== Print Queue ===") | Out-Null
18435try {
18436    $jobs = Get-PrintJob -ErrorAction SilentlyContinue
18437    if ($jobs) {
18438        foreach ($j in $jobs | Select-Object -First 5) {
18439            $result.AppendLine("  $($j.DocumentName) — $($j.JobStatus)") | Out-Null
18440        }
18441    } else {
18442        $result.AppendLine("  No pending print jobs.") | Out-Null
18443    }
18444} catch {
18445    $result.AppendLine("  Print queue check requires elevation.") | Out-Null
18446}
18447
18448Write-Output $result.ToString().TrimEnd()
18449"#;
18450    let out = run_powershell(script)?;
18451    Ok(format!("Host inspection: print_spooler\n\n{out}"))
18452}
18453
18454#[cfg(not(windows))]
18455fn inspect_print_spooler() -> Result<String, String> {
18456    let mut out = String::from("Host inspection: print_spooler\n\n");
18457    if let Ok(o) = Command::new("lpstat").args(["-s"]).output() {
18458        let s = String::from_utf8_lossy(&o.stdout);
18459        if !s.trim().is_empty() {
18460            out.push_str("=== CUPS Status (lpstat -s) ===\n");
18461            out.push_str(s.trim_end());
18462            out.push('\n');
18463        }
18464    } else {
18465        out.push_str("CUPS not detected (lpstat not found).\n");
18466    }
18467    Ok(out)
18468}
18469
18470#[cfg(not(windows))]
18471fn inspect_defender_quarantine(_max_entries: usize) -> Result<String, String> {
18472    let mut out = String::from("Host inspection: defender_quarantine\n\n");
18473    out.push_str("Windows Defender is Windows-only.\n");
18474    // Check ClamAV on Linux/macOS
18475    if let Ok(o) = Command::new("clamscan").arg("--version").output() {
18476        if o.status.success() {
18477            out.push_str("\nClamAV detected. Run `clamscan -r --infected /path` for a scan.\n");
18478            if let Ok(log) = std::fs::read_to_string("/var/log/clamav/clamav.log") {
18479                out.push_str("\n=== ClamAV Recent Log ===\n");
18480                for line in log.lines().rev().take(20) {
18481                    out.push_str(&format!("  {line}\n"));
18482                }
18483            }
18484        }
18485    } else {
18486        out.push_str("No AV tool detected (ClamAV not found).\n");
18487    }
18488    Ok(out)
18489}